diff --git a/assets/images/banner-images/hero-1.png b/assets/images/banner-images/hero-1.png new file mode 100644 index 0000000000..60df8ea86e Binary files /dev/null and b/assets/images/banner-images/hero-1.png differ diff --git a/assets/images/banner-images/hero-2.png b/assets/images/banner-images/hero-2.png new file mode 100644 index 0000000000..0d02dfd6c0 Binary files /dev/null and b/assets/images/banner-images/hero-2.png differ diff --git a/assets/images/banner-images/hero-3.png b/assets/images/banner-images/hero-3.png new file mode 100644 index 0000000000..7de28041c9 Binary files /dev/null and b/assets/images/banner-images/hero-3.png differ diff --git a/assets/images/banner-images/hero-4.png b/assets/images/banner-images/hero-4.png new file mode 100644 index 0000000000..8d2bcb1eba Binary files /dev/null and b/assets/images/banner-images/hero-4.png differ diff --git a/assets/images/banner-images/hero-5.png b/assets/images/banner-images/hero-5.png new file mode 100644 index 0000000000..ef06249436 Binary files /dev/null and b/assets/images/banner-images/hero-5.png differ diff --git a/assets/images/banner-images/hero-6.png b/assets/images/banner-images/hero-6.png new file mode 100644 index 0000000000..3f68a2ebdf Binary files /dev/null and b/assets/images/banner-images/hero-6.png differ diff --git a/src/frame/lib/frontmatter.js b/src/frame/lib/frontmatter.js index ca99e44ffc..9de39a7ac3 100644 --- a/src/frame/lib/frontmatter.js +++ b/src/frame/lib/frontmatter.js @@ -202,6 +202,10 @@ export const schema = { product_video_transcript: { type: 'string', }, + // Hero image for landing pages + heroImage: { + type: 'string', + }, interactive: { type: 'boolean', }, diff --git a/src/landings/components/ProductLandingContext.tsx b/src/landings/components/ProductLandingContext.tsx index deecd64572..f732228e93 100644 --- a/src/landings/components/ProductLandingContext.tsx +++ b/src/landings/components/ProductLandingContext.tsx @@ -39,6 +39,7 @@ export type ProductLandingContextT = { introLinks: Record | null productVideo: string productVideoTranscript: string + heroImage?: string featuredLinks: Record> productUserExamples: Array<{ username: string; description: string }> productCommunityExamples: Array<{ repo: string; description: string }> @@ -113,6 +114,7 @@ export const getProductLandingContextFromRequest = async ( ...pick(page, ['introPlainText', 'beta_product', 'intro']), productVideo, productVideoTranscript: page.product_video_transcript || null, + heroImage: page.heroImage || null, hasGuidesPage, product: { href: productTree.href, diff --git a/src/landings/components/bespoke/BespokeLanding.tsx b/src/landings/components/bespoke/BespokeLanding.tsx index 29746737c2..0e61f7bf56 100644 --- a/src/landings/components/bespoke/BespokeLanding.tsx +++ b/src/landings/components/bespoke/BespokeLanding.tsx @@ -1,14 +1,14 @@ import { useMemo } from 'react' import { DefaultLayout } from '@/frame/components/DefaultLayout' -import { useBespokeContext } from '@/landings/context/BespokeContext' +import { useLandingContext } from '@/landings/context/LandingContext' import { LandingHero } from '@/landings/components/shared/LandingHero' import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter' import type { ArticleCardItems } from '@/landings/types' export const BespokeLanding = () => { - const { title, intro, tocItems } = useBespokeContext() + const { title, intro, heroImage, introLinks, tocItems } = useLandingContext() const flatArticles: ArticleCardItems = useMemo( () => tocItems.flatMap((item) => item.childTocItems || []), @@ -18,7 +18,7 @@ export const BespokeLanding = () => { return (
- +
diff --git a/src/landings/components/discovery/DiscoveryLanding.tsx b/src/landings/components/discovery/DiscoveryLanding.tsx index 4bd247378c..61c87ba0b8 100644 --- a/src/landings/components/discovery/DiscoveryLanding.tsx +++ b/src/landings/components/discovery/DiscoveryLanding.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { DefaultLayout } from '@/frame/components/DefaultLayout' -import { useDiscoveryContext } from '@/landings/context/DiscoveryContext' +import { useLandingContext } from '@/landings/context/LandingContext' import { LandingHero } from '@/landings/components/shared/LandingHero' import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter' import { LandingCarousel } from '@/landings/components/shared/LandingCarousel' @@ -9,7 +9,7 @@ import { LandingCarousel } from '@/landings/components/shared/LandingCarousel' import type { ArticleCardItems } from '@/landings/types' export const DiscoveryLanding = () => { - const { title, intro, tocItems, recommended } = useDiscoveryContext() + const { title, intro, heroImage, introLinks, tocItems, recommended } = useLandingContext() const flatArticles: ArticleCardItems = useMemo( () => tocItems.flatMap((item) => item.childTocItems || []), @@ -19,7 +19,7 @@ export const DiscoveryLanding = () => { return (
- +
diff --git a/src/landings/components/journey/JourneyLanding.tsx b/src/landings/components/journey/JourneyLanding.tsx index 9ce831ef76..7eb489fe0e 100644 --- a/src/landings/components/journey/JourneyLanding.tsx +++ b/src/landings/components/journey/JourneyLanding.tsx @@ -1,14 +1,14 @@ import { DefaultLayout } from '@/frame/components/DefaultLayout' -import { useJourneyContext } from '@/landings/context/JourneyContext' +import { useLandingContext } from '@/landings/context/LandingContext' import { LandingHero } from '@/landings/components/shared/LandingHero' export const JourneyLanding = () => { - const { title, intro } = useJourneyContext() + const { title, intro, heroImage, introLinks } = useLandingContext() return (
- +
TODO
diff --git a/src/landings/components/shared/LandingHero.module.scss b/src/landings/components/shared/LandingHero.module.scss new file mode 100644 index 0000000000..9068b7d93e --- /dev/null +++ b/src/landings/components/shared/LandingHero.module.scss @@ -0,0 +1,191 @@ +.landingHero { + position: relative; + display: flex; + align-items: center; + padding: 4rem 0; + background-color: var(--bgColor-muted, var(--color-canvas-subtle, #f6f8fa)); + background-size: cover; + background-position: center center; + background-repeat: no-repeat; + overflow: hidden; + height: 28rem; + width: 100%; +} + +.heroContent { + position: relative; + width: 50rem; + max-width: 50rem; + padding: 0 7rem; + display: flex; + align-items: center; +} + +.heroText { + text-align: left; + width: 100%; +} + +.heroHeading { + font-size: 4rem; + font-weight: 600; + line-height: 1.2; + margin: 0 0 1rem 0; + color: var(--fgColor-default, var(--color-fg-default, #1f2328)); + max-width: 48rem; +} + +.heroDescription { + font-size: 1.25rem; + line-height: 1.5; + color: var(--fgColor-muted, var(--color-fg-muted, #656d76)); + margin: 0 0 2rem 0; + max-width: 36rem; +} + +.heroActions { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.heroAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-weight: 500; + text-decoration: none; + transition: all 0.2s ease; + min-height: 2.75rem; + border: 1px solid transparent; +} + +.heroPrimaryAction { + background-color: var( + --bgColor-success-emphasis, + var(--color-btn-primary-bg, #1f883d) + ); + color: var(--fgColor-onEmphasis, var(--color-btn-primary-text, #ffffff)); + border-color: var( + --borderColor-success-emphasis, + var(--color-btn-primary-border, #1f883d) + ); + + &:hover { + background-color: var( + --bgColor-success-emphasis, + var(--color-btn-primary-hover-bg, #1a7f37) + ); + border-color: var( + --borderColor-success-emphasis, + var(--color-btn-primary-hover-border, #1a7f37) + ); + text-decoration: none; + } + + &:focus { + outline: 2px solid + var( + --borderColor-success-emphasis, + var(--color-btn-primary-focus, #1f883d) + ); + outline-offset: 2px; + } +} + +.heroSecondaryAction { + background-color: transparent; + color: var(--fgColor-default, var(--color-fg-default, #1f2328)); + border-color: var( + --borderColor-default, + var(--color-border-default, #d1d9e0) + ); + + &:hover { + background-color: var(--bgColor-muted, var(--color-canvas-subtle, #f3f4f6)); + border-color: var( + --borderColor-default, + var(--color-border-default, #d1d9e0) + ); + text-decoration: none; + } + + &:focus { + outline: 2px solid + var(--borderColor-accent-emphasis, var(--color-accent-emphasis, #0969da)); + outline-offset: 2px; + } +} + +@media (max-width: 865px) { + .landingHero { + height: 24rem; + background-image: none !important; + flex-direction: column; + text-align: center; + justify-content: center; + background-color: var(--bgColor-muted, var(--color-canvas-subtle, #f6f8fa)); + } + + .heroContent { + width: 100%; + order: 2; + padding: 0 1rem; + } + + .heroHeading { + font-size: 3rem; + } + + .heroDescription { + font-size: 1.1rem; + } + + .heroActions { + justify-content: center; + } + + .heroAction { + width: auto; + min-width: 12rem; + } + + .heroText { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + } +} + +@media (max-width: 480px) { + .landingHero { + height: 32rem; + background-image: none !important; + background-color: var(--bgColor-muted, var(--color-canvas-subtle, #f6f8fa)); + flex-direction: column; + text-align: center; + padding: 2rem 0 1rem; + justify-content: center; + } + + .heroContent { + width: 100%; + order: 2; + padding: 1rem; + } + + .heroText { + display: flex; + flex-direction: column; + text-align: center; + align-items: center; + } + + .heroActions { + justify-content: center; + align-items: center; + } +} diff --git a/src/landings/components/shared/LandingHero.tsx b/src/landings/components/shared/LandingHero.tsx index fc717b2799..83649ecb4d 100644 --- a/src/landings/components/shared/LandingHero.tsx +++ b/src/landings/components/shared/LandingHero.tsx @@ -1,18 +1,61 @@ -import { Lead } from '@/frame/components/ui/Lead/Lead' +import styles from './LandingHero.module.scss' +import { useTranslation } from '@/languages/components/useTranslation' type LandingHeroProps = { title: string intro?: string + heroImage?: string + introLinks?: Record | null } -export const LandingHero = ({ title, intro }: LandingHeroProps) => { +export const LandingHero = ({ title, intro, heroImage, introLinks }: LandingHeroProps) => { + const { t } = useTranslation(['product_landing']) + + const linkEntries = introLinks ? Object.entries(introLinks) : [] + const primaryAction = linkEntries[0] + const secondaryAction = linkEntries[1] + return ( -
-
-

TODO: Landing hero placeholder

-

{title}

- {intro && {intro}} +
+
+
+

{title}

+ {intro && ( +
+
+
+ )} + {(primaryAction || secondaryAction) && ( +
+ {primaryAction && ( + + {t(primaryAction[0])} + + )} + {secondaryAction && ( + + {t(secondaryAction[0])} + + )} +
+ )} +
-
+
) } diff --git a/src/landings/context/BespokeContext.tsx b/src/landings/context/BespokeContext.tsx deleted file mode 100644 index f7bd5b9ca9..0000000000 --- a/src/landings/context/BespokeContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { createContext, useContext } from 'react' -import { FeaturedLink, getFeaturedLinksFromReq } from '@/landings/components/ProductLandingContext' -import { mapRawTocItemToTocItem } from '@/landings/types' -import type { TocItem } from '@/landings/types' -import type { LearningTrack } from '@/types' - -export type BespokeContextT = { - title: string - intro: string - productCallout: string - permissions: string - tocItems: Array - variant?: 'compact' | 'expanded' - featuredLinks: Record> - renderedPage: string - currentLearningTrack?: LearningTrack - currentLayout: string -} - -export const BespokeContext = createContext(null) - -export const useBespokeContext = (): BespokeContextT => { - const context = useContext(BespokeContext) - - if (!context) { - throw new Error('"useBespokeContext" may only be used inside "BespokeContext.Provider"') - } - - return context -} - -export const getBespokeContextFromRequest = async (req: any): Promise => { - const page = req.context.page - - return { - title: page.title, - productCallout: page.product || '', - permissions: page.permissions || '', - intro: page.intro, - tocItems: (req.context.genericTocFlat || req.context.genericTocNested || []).map( - mapRawTocItemToTocItem, - ), - variant: req.context.genericTocFlat ? 'expanded' : 'compact', - featuredLinks: getFeaturedLinksFromReq(req), - renderedPage: req.context.renderedPage, - currentLearningTrack: req.context.currentLearningTrack, - currentLayout: req.context.currentLayoutName, - } -} diff --git a/src/landings/context/DiscoveryContext.tsx b/src/landings/context/DiscoveryContext.tsx deleted file mode 100644 index c4b13f2702..0000000000 --- a/src/landings/context/DiscoveryContext.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { createContext, useContext } from 'react' -import { FeaturedLink, getFeaturedLinksFromReq } from '@/landings/components/ProductLandingContext' -import { mapRawTocItemToTocItem } from '@/landings/types' -import type { TocItem } from '@/landings/types' -import type { LearningTrack } from '@/types' - -export type DiscoveryContextT = { - title: string - intro: string - productCallout: string - permissions: string - tocItems: Array - variant?: 'compact' | 'expanded' - featuredLinks: Record> - renderedPage: string - currentLearningTrack?: LearningTrack - currentLayout: string - recommended?: string[] // Array of article paths -} - -export const DiscoveryContext = createContext(null) - -export const useDiscoveryContext = (): DiscoveryContextT => { - const context = useContext(DiscoveryContext) - - if (!context) { - throw new Error('"useDiscoveryContext" may only be used inside "DiscoveryContext.Provider"') - } - - return context -} - -export const getDiscoveryContextFromRequest = async (req: any): Promise => { - const page = req.context.page - - // Support legacy `spotlight` property as `recommended` for pages like Copilot Cookbook - // However, `spotlight` will have lower priority than the `recommended` property - let recommended: string[] = [] - if (page.recommended && page.recommended.length > 0) { - recommended = page.recommended - } else if (page.spotlight && page.spotlight.length > 0) { - // Remove the `image` property from spotlight items, since we don't use those for the carousel - recommended = page.spotlight.map((item: any) => item.article) - } - - return { - title: page.title, - productCallout: page.product || '', - permissions: page.permissions || '', - intro: page.intro, - tocItems: (req.context.genericTocFlat || req.context.genericTocNested || []).map( - mapRawTocItemToTocItem, - ), - variant: req.context.genericTocFlat ? 'expanded' : 'compact', - featuredLinks: getFeaturedLinksFromReq(req), - renderedPage: req.context.renderedPage, - currentLearningTrack: req.context.currentLearningTrack, - currentLayout: req.context.currentLayoutName, - recommended, - } -} diff --git a/src/landings/context/JourneyContext.tsx b/src/landings/context/JourneyContext.tsx deleted file mode 100644 index 47c0c83401..0000000000 --- a/src/landings/context/JourneyContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { createContext, useContext } from 'react' -import { FeaturedLink, getFeaturedLinksFromReq } from '@/landings/components/ProductLandingContext' -import { mapRawTocItemToTocItem } from '@/landings/types' -import type { TocItem } from '@/landings/types' -import type { LearningTrack } from '@/types' - -export type JourneyContextT = { - title: string - intro: string - productCallout: string - permissions: string - tocItems: Array - variant?: 'compact' | 'expanded' - featuredLinks: Record> - renderedPage: string - currentLearningTrack?: LearningTrack - currentLayout: string -} - -export const JourneyContext = createContext(null) - -export const useJourneyContext = (): JourneyContextT => { - const context = useContext(JourneyContext) - - if (!context) { - throw new Error('"useJourneyContext" may only be used inside "JourneyContext.Provider"') - } - - return context -} - -export const getJourneyContextFromRequest = async (req: any): Promise => { - const page = req.context.page - - return { - title: page.title, - productCallout: page.product || '', - permissions: page.permissions || '', - intro: page.intro, - tocItems: (req.context.genericTocFlat || req.context.genericTocNested || []).map( - mapRawTocItemToTocItem, - ), - variant: req.context.genericTocFlat ? 'expanded' : 'compact', - featuredLinks: getFeaturedLinksFromReq(req), - renderedPage: req.context.renderedPage, - currentLearningTrack: req.context.currentLearningTrack, - currentLayout: req.context.currentLayoutName, - } -} diff --git a/src/landings/context/LandingContext.tsx b/src/landings/context/LandingContext.tsx new file mode 100644 index 0000000000..0739c14229 --- /dev/null +++ b/src/landings/context/LandingContext.tsx @@ -0,0 +1,76 @@ +import { createContext, useContext } from 'react' +import { FeaturedLink, getFeaturedLinksFromReq } from '@/landings/components/ProductLandingContext' +import { mapRawTocItemToTocItem } from '@/landings/types' +import type { TocItem } from '@/landings/types' +import type { LearningTrack } from '@/types' + +export type LandingType = 'bespoke' | 'discovery' | 'journey' + +export type LandingContextT = { + landingType: LandingType + title: string + intro: string + productCallout: string + permissions: string + tocItems: Array + variant?: 'compact' | 'expanded' + featuredLinks: Record> + renderedPage: string + currentLearningTrack?: LearningTrack + currentLayout: string + heroImage?: string + // For discovery landing pages + recommended?: string[] // Array of article paths + // For discovery landing pages + introLinks?: Record +} + +export const LandingContext = createContext(null) + +export const useLandingContext = (): LandingContextT => { + const context = useContext(LandingContext) + + if (!context) { + throw new Error('"useLandingContext" may only be used inside "LandingContext.Provider"') + } + + return context +} + +export const getLandingContextFromRequest = async ( + req: any, + landingType: LandingType, +): Promise => { + const page = req.context.page + + let recommended: string[] = [] + if (landingType === 'discovery') { + // Support legacy `spotlight` property as `recommended` for pages like Copilot Cookbook + // However, `spotlight` will have lower priority than the `recommended` property + if (page.recommended && page.recommended.length > 0) { + recommended = page.recommended + } else if (page.spotlight && page.spotlight.length > 0) { + // Remove the `image` property from spotlight items, since we don't use those for the carousel + recommended = page.spotlight.map((item: any) => item.article) + } + } + + return { + landingType, + title: page.title, + productCallout: page.product || '', + permissions: page.permissions || '', + intro: page.intro, + tocItems: (req.context.genericTocFlat || req.context.genericTocNested || []).map( + mapRawTocItemToTocItem, + ), + variant: req.context.genericTocFlat ? 'expanded' : 'compact', + featuredLinks: getFeaturedLinksFromReq(req), + renderedPage: req.context.renderedPage, + currentLearningTrack: req.context.currentLearningTrack, + currentLayout: req.context.currentLayoutName, + heroImage: page.heroImage || '/assets/images/banner-images/hero-1.png', + introLinks: page.introLinks || null, + recommended, + } +} diff --git a/src/landings/pages/product.tsx b/src/landings/pages/product.tsx index eeed5a9c96..c5414d9082 100644 --- a/src/landings/pages/product.tsx +++ b/src/landings/pages/product.tsx @@ -49,22 +49,12 @@ import { } from '@/frame/components/context/CategoryLandingContext' import { BespokeLanding } from '@/landings/components/bespoke/BespokeLanding' import { - BespokeContext, - getBespokeContextFromRequest, - BespokeContextT, -} from '@/landings/context/BespokeContext' + LandingContext, + getLandingContextFromRequest, + LandingContextT, +} from '@/landings/context/LandingContext' import { DiscoveryLanding } from '@/landings/components/discovery/DiscoveryLanding' -import { - DiscoveryContext, - DiscoveryContextT, - getDiscoveryContextFromRequest, -} from '@/landings/context/DiscoveryContext' import { JourneyLanding } from '@/landings/components/journey/JourneyLanding' -import { - getJourneyContextFromRequest, - JourneyContext, - JourneyContextT, -} from '@/landings/context/JourneyContext' function initiateArticleScripts() { copyCode() @@ -79,9 +69,9 @@ type Props = { tocLandingContext?: TocLandingContextT articleContext?: ArticleContextT categoryLandingContext?: CategoryLandingContextT - bespokeContext?: BespokeContextT - discoveryContext?: DiscoveryContextT - journeyContext?: JourneyContextT + bespokeContext?: LandingContextT + discoveryContext?: LandingContextT + journeyContext?: LandingContextT } const GlobalPage = ({ mainContext, @@ -108,21 +98,21 @@ const GlobalPage = ({ let content if (bespokeContext) { content = ( - + - + ) } else if (discoveryContext) { content = ( - + - + ) } else if (journeyContext) { content = ( - + - + ) } else if (productLandingContext) { content = ( @@ -184,23 +174,23 @@ export const getServerSideProps: GetServerSideProps = async (context) => // This looks a little funky, but it's so we only send one context's data to the client // TODO: TEMP: This is a temporary solution to turn off/on new landing pages while we develop them if (currentLayoutName === 'bespoke-landing' || req.query?.feature === 'bespoke-landing') { - props.bespokeContext = await getBespokeContextFromRequest(req) - additionalUINamespaces.push('bespoke_landing') + props.bespokeContext = await getLandingContextFromRequest(req, 'bespoke') + additionalUINamespaces.push('bespoke_landing', 'product_landing') } else if (currentLayoutName === 'journey-landing' || req.query?.feature === 'journey-landing') { - props.journeyContext = await getJourneyContextFromRequest(req) - additionalUINamespaces.push('journey_landing') + props.journeyContext = await getLandingContextFromRequest(req, 'journey') + additionalUINamespaces.push('journey_landing', 'product_landing') } else if ( currentLayoutName === 'discovery-landing' || req?.query?.feature === 'discovery-landing' ) { - props.discoveryContext = await getDiscoveryContextFromRequest(req) - additionalUINamespaces.push('discovery_landing') + props.discoveryContext = await getLandingContextFromRequest(req, 'discovery') + additionalUINamespaces.push('discovery_landing', 'product_landing') } else if (currentLayoutName === 'product-landing') { props.productLandingContext = await getProductLandingContextFromRequest(req) additionalUINamespaces.push('product_landing') } else if (currentLayoutName === 'product-guides') { props.productGuidesContext = getProductGuidesContextFromRequest(req) - additionalUINamespaces.push('product_guides') + additionalUINamespaces.push('product_guides', 'product_landing') } else if (relativePath?.endsWith('index.md')) { if (currentLayoutName === 'category-landing') { props.categoryLandingContext = getCategoryLandingContextFromRequest(req)