diff --git a/backend/api/cms/page/blocks/homepage_hero.py b/backend/api/cms/page/blocks/homepage_hero.py index 037136475a..93ada1b645 100644 --- a/backend/api/cms/page/blocks/homepage_hero.py +++ b/backend/api/cms/page/blocks/homepage_hero.py @@ -1,5 +1,6 @@ from enum import Enum from typing import Self +from api.cms.base.blocks.cta import CTA from api.cms.page.registry import register_page_block import strawberry @@ -15,11 +16,32 @@ class HomepageHeroCity(Enum): class HomepageHero: id: strawberry.ID city: HomepageHeroCity | None + pretitle: str + title: str + subtitle: str + highlight: str + illustration: str + primary_cta: CTA | None + secondary_cta: CTA | None @classmethod def from_block(cls, block) -> Self: - city = block.value.get("city") + # Blocks saved before a field was added don't have a value for it, + # Wagtail fills those with the block default, which is None. + city = block.value["city"] + primary_cta = block.value["primary_cta"] + secondary_cta = block.value["secondary_cta"] + return cls( id=block.id, city=HomepageHeroCity(city) if city else None, + pretitle=block.value["pretitle"] or "", + title=block.value["title"] or "", + subtitle=block.value["subtitle"] or "", + highlight=block.value["highlight"] or "", + illustration=block.value["illustration"] or "", + primary_cta=(CTA.from_block(primary_cta) if primary_cta["label"] else None), + secondary_cta=( + CTA.from_block(secondary_cta) if secondary_cta["label"] else None + ), ) diff --git a/backend/api/cms/tests/factories.py b/backend/api/cms/tests/factories.py index f2b4b5286e..c9d350cd7f 100644 --- a/backend/api/cms/tests/factories.py +++ b/backend/api/cms/tests/factories.py @@ -1,3 +1,4 @@ +from cms.components.base.blocks.cta import CTA from cms.components.page.blocks.homepage_hero import HomepageHero from cms.components.page.models import GenericPage from cms.components.page.blocks.text_section import TextSection @@ -56,7 +57,18 @@ class Params: p = factory.Faker("text", max_nb_chars=300) +class CTAFactory(StructBlockFactory): + label = "" + link = "" + + class Meta: + model = CTA + + class HomepageHeroFactory(StructBlockFactory): + primary_cta = factory.SubFactory(CTAFactory) + secondary_cta = factory.SubFactory(CTAFactory) + class Meta: model = HomepageHero diff --git a/backend/api/cms/tests/page/queries/test_cms_page.py b/backend/api/cms/tests/page/queries/test_cms_page.py index 437e9db83e..95874527bb 100644 --- a/backend/api/cms/tests/page/queries/test_cms_page.py +++ b/backend/api/cms/tests/page/queries/test_cms_page.py @@ -467,3 +467,117 @@ def test_page_filter_by_site_and_language(graphql_client, locale): assert response["data"] == { "cmsPage": {"body": [{"title": "There they are, all standing in a row"}]} } + + +HOMEPAGE_HERO_QUERY = """ +query Page ($hostname: String!, $language: String!, $slug: String!) { + cmsPage(hostname: $hostname, language: $language, slug: $slug){ + ...on GenericPage { + body { + ... on HomepageHero { + city + pretitle + title + subtitle + highlight + illustration + primaryCta { + label + link + } + secondaryCta { + label + link + } + } + } + } + } +} +""" + + +def test_homepage_hero_with_copy_and_ctas(graphql_client, locale): + parent = GenericPageFactory() + page = GenericPageFactory( + slug="home", + locale=locale("en"), + parent=parent, + title="Home", + body__0__homepage_hero__city="bologna", + body__0__homepage_hero__pretitle="Bologna, May 27 - 30, 2027", + body__0__homepage_hero__title="PyCon Italia 2027", + body__0__homepage_hero__subtitle=( + "Four days of talks, tutorials and community, in Bologna" + ), + body__0__homepage_hero__highlight="1,000+ attendees", + body__0__homepage_hero__illustration="snakeWithBalloon", + body__0__homepage_hero__primary_cta__label="Buy tickets", + body__0__homepage_hero__primary_cta__link="/tickets", + body__0__homepage_hero__secondary_cta__label="See the programme", + body__0__homepage_hero__secondary_cta__link="/schedule", + ) + page.save_revision().publish() + SiteFactory(hostname="pycon", port=80, root_page=parent) + + response = graphql_client.query( + HOMEPAGE_HERO_QUERY, + variables={"hostname": "pycon", "slug": "home", "language": "en"}, + ) + + assert response["data"] == { + "cmsPage": { + "body": [ + { + "city": "BOLOGNA", + "pretitle": "Bologna, May 27 - 30, 2027", + "title": "PyCon Italia 2027", + "subtitle": ( + "Four days of talks, tutorials and community, in Bologna" + ), + "highlight": "1,000+ attendees", + "illustration": "snakeWithBalloon", + "primaryCta": {"label": "Buy tickets", "link": "/tickets"}, + "secondaryCta": { + "label": "See the programme", + "link": "/schedule", + }, + } + ], + } + } + + +def test_homepage_hero_saved_before_the_copy_fields_existed(graphql_client, locale): + parent = GenericPageFactory() + page = GenericPageFactory( + slug="home", + locale=locale("en"), + parent=parent, + title="Home", + body__0__homepage_hero__city="bologna", + ) + page.save_revision().publish() + SiteFactory(hostname="pycon", port=80, root_page=parent) + + response = graphql_client.query( + HOMEPAGE_HERO_QUERY, + variables={"hostname": "pycon", "slug": "home", "language": "en"}, + ) + + assert response["data"] == { + "cmsPage": { + "body": [ + { + "city": "BOLOGNA", + "pretitle": "", + "title": "", + "subtitle": "", + "highlight": "", + "illustration": "", + "primaryCta": None, + "secondaryCta": None, + } + ], + } + } diff --git a/backend/cms/components/page/blocks/homepage_hero.py b/backend/cms/components/page/blocks/homepage_hero.py index 585856b0ad..1726d82504 100644 --- a/backend/cms/components/page/blocks/homepage_hero.py +++ b/backend/cms/components/page/blocks/homepage_hero.py @@ -1,3 +1,5 @@ +from cms.components.base.blocks.cta import CTA +from cms.components.page.fields import IllustrationChoiceBlock from wagtail import blocks @@ -8,6 +10,34 @@ class HomepageHero(blocks.StructBlock): ("bologna", "Bologna"), ] ) + pretitle = blocks.CharBlock( + required=False, + help_text="When and where, e.g. 'Bologna, May 27 – 30, 2027'", + ) + title = blocks.CharBlock( + required=False, + help_text="The conference name as text, e.g. 'PyCon Italia 2027'", + ) + subtitle = blocks.CharBlock( + required=False, + help_text=( + "One line describing the event, e.g. 'Four days of talks, " + "tutorials and community, in Bologna'" + ), + ) + highlight = blocks.CharBlock( + required=False, + help_text="Optional credibility marker, e.g. '1,000+ attendees'", + ) + primary_cta = CTA(label="Primary CTA") + secondary_cta = CTA(label="Secondary CTA") + illustration = IllustrationChoiceBlock( + required=False, + help_text=( + "Shown next to the copy instead of the animated city illustration. " + "Renders immediately, so prefer it when the fold has to be fast." + ), + ) class Meta: label = "Homepage Hero" diff --git a/backend/cms/components/page/migrations/0009_alter_genericpage_body.py b/backend/cms/components/page/migrations/0009_alter_genericpage_body.py new file mode 100644 index 0000000000..d7677882c0 --- /dev/null +++ b/backend/cms/components/page/migrations/0009_alter_genericpage_body.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.8 on 2026-08-15 22:38 + +import cms.components.base.blocks.accordion +import cms.components.page.blocks.communities_section +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('page', '0008_alter_genericpage_body'), + ] + + operations = [ + migrations.AlterField( + model_name='genericpage', + name='body', + field=wagtail.fields.StreamField([('text_section', 7), ('map', 11), ('slider_cards_section', 18), ('sponsors_section', 21), ('home_intro_section', 22), ('keynoters_section', 23), ('schedule_preview_section', 26), ('socials_section', 27), ('special_guest_section', 30), ('information_section', 33), ('news_grid_section', 34), ('checkout_section', 35), ('live_streaming_section', 34), ('homepage_hero', 42), ('dynamic_content_display_section', 44), ('communities_section', 46)], block_lookup={0: ('wagtail.blocks.CharBlock', (), {'required': False}), 1: ('wagtail.blocks.BooleanBlock', (), {'default': False, 'required': False}), 2: ('wagtail.blocks.RichTextBlock', (), {'required': False}), 3: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('text-1', 'Text-1'), ('text-2', 'Text-2')], 'required': False}), 4: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('cathedral', 'Cathedral'), ('florence', 'Florence'), ('florence2', 'Florence2'), ('handWithSnakeInside', 'Hand With Snake Inside'), ('snake1', 'Snake1'), ('snake2', 'Snake2'), ('snake4', 'Snake4'), ('snake5', 'Snake5'), ('snakeBody', 'Snake Body'), ('snakeCouple', 'Snake Couple'), ('snakeDNA', 'Snake D N A'), ('snakeHead', 'Snake Head'), ('snakeInDragon', 'Snake In Dragon'), ('snakeInDragonInverted', 'Snake In Dragon Inverted'), ('snakeLetter', 'Snake Letter'), ('snakeLongNeck', 'Snake Long Neck'), ('snakePencil', 'Snake Pencil'), ('snakeTail', 'Snake Tail'), ('snakeWithBalloon', 'Snake With Balloon'), ('snakeWithContacts', 'Snake With Contacts'), ('snakesWithBanner', 'Snakes With Banner'), ('snakesWithCocktail', 'Snakes With Cocktail'), ('snakesWithDirections', 'Snakes With Directions'), ('snakesWithOutlines', 'Snakes With Outlines'), ('tripleSnakes', 'Triple Snakes')], 'required': False}), 5: ('wagtail.blocks.ListBlock', (cms.components.base.blocks.accordion.Accordion,), {}), 6: ('wagtail.blocks.StructBlock', [[('label', 0), ('link', 0)]], {}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('is_main_title', 1), ('subtitle', 0), ('body', 2), ('body_text_size', 3), ('illustration', 4), ('accordions', 5), ('cta', 6)]], {}), 8: ('wagtail.blocks.DecimalBlock', (), {'decimal_places': 8, 'max_digits': 11}), 9: ('wagtail.blocks.IntegerBlock', (), {'default': 15}), 10: ('wagtail.blocks.URLBlock', (), {}), 11: ('wagtail.blocks.StructBlock', [[('longitude', 8), ('latitude', 8), ('zoom', 9), ('link', 10)]], {}), 12: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('xl', 'Extra Large'), ('3xl', '3 Extra Large')]}), 13: ('wagtail.blocks.CharBlock', (), {}), 14: ('wagtail.blocks.RichTextBlock', (), {}), 15: ('wagtail.blocks.StructBlock', [[('title', 13), ('body', 14), ('cta', 6)]], {}), 16: ('wagtail.blocks.StructBlock', [[('title', 13), ('body', 14), ('price', 13), ('price_tier', 13), ('cta', 6)]], {}), 17: ('wagtail.blocks.StreamBlock', [[('simple_text_card', 15), ('price_card', 16)]], {}), 18: ('wagtail.blocks.StructBlock', [[('title', 0), ('spacing', 12), ('snake_background', 1), ('cards', 17)]], {}), 19: ('wagtail.blocks.CharBlock', (), {'required': True}), 20: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('side-by-side', 'Side-by-side'), ('vertical', 'Vertical')], 'required': False}), 21: ('wagtail.blocks.StructBlock', [[('title', 19), ('body', 19), ('cta', 6), ('layout', 20)]], {}), 22: ('wagtail.blocks.StructBlock', [[('pretitle', 0), ('title', 0)]], {}), 23: ('wagtail.blocks.StructBlock', [[('title', 19), ('cta', 6)]], {}), 24: ('wagtail.blocks.StructBlock', [[('label', 0), ('link', 0)]], {'label': 'Primary CTA'}), 25: ('wagtail.blocks.StructBlock', [[('label', 0), ('link', 0)]], {'label': 'Secondary CTA'}), 26: ('wagtail.blocks.StructBlock', [[('title', 19), ('primary_cta', 24), ('secondary_cta', 25)]], {}), 27: ('wagtail.blocks.StructBlock', [[('label', 19), ('hashtag', 19)]], {}), 28: ('wagtail.images.blocks.ImageChooserBlock', (), {'required': True}), 29: ('wagtail.blocks.DateBlock', (), {'required': True}), 30: ('wagtail.blocks.StructBlock', [[('title', 19), ('guest_name', 19), ('guest_photo', 28), ('guest_job_title', 19), ('event_date', 29), ('cta', 6)]], {}), 31: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('coral', 'coral'), ('caramel', 'caramel'), ('cream', 'cream'), ('yellow', 'yellow'), ('green', 'green'), ('purple', 'purple'), ('pink', 'pink'), ('blue', 'blue'), ('red', 'red'), ('success', 'success'), ('warning', 'warning'), ('neutral', 'neutral'), ('error', 'error'), ('black', 'black'), ('grey', 'grey'), ('white', 'white'), ('milk', 'milk')]}), 32: ('wagtail.blocks.DateTimeBlock', (), {'required': False}), 33: ('wagtail.blocks.StructBlock', [[('title', 19), ('body', 2), ('illustration', 4), ('background_color', 31), ('countdown_to_datetime', 32), ('countdown_to_deadline', 0), ('cta', 6)]], {}), 34: ('wagtail.blocks.StructBlock', [[]], {}), 35: ('wagtail.blocks.StructBlock', [[('show_conference_tickets_products', 1), ('show_social_events_products', 1), ('show_tours_products', 1), ('show_gadgets_products', 1), ('show_membership_products', 1)]], {}), 36: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('florence', 'Florence'), ('bologna', 'Bologna')]}), 37: ('wagtail.blocks.CharBlock', (), {'help_text': "When and where, e.g. 'Bologna, May 27 – 30, 2027'", 'required': False}), 38: ('wagtail.blocks.CharBlock', (), {'help_text': "The conference name as text, e.g. 'PyCon Italia 2027'", 'required': False}), 39: ('wagtail.blocks.CharBlock', (), {'help_text': "One line describing the event, e.g. 'Four days of talks, tutorials and community, in Bologna'", 'required': False}), 40: ('wagtail.blocks.CharBlock', (), {'help_text': "Optional credibility marker, e.g. '1,000+ attendees'", 'required': False}), 41: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('cathedral', 'Cathedral'), ('florence', 'Florence'), ('florence2', 'Florence2'), ('handWithSnakeInside', 'Hand With Snake Inside'), ('snake1', 'Snake1'), ('snake2', 'Snake2'), ('snake4', 'Snake4'), ('snake5', 'Snake5'), ('snakeBody', 'Snake Body'), ('snakeCouple', 'Snake Couple'), ('snakeDNA', 'Snake D N A'), ('snakeHead', 'Snake Head'), ('snakeInDragon', 'Snake In Dragon'), ('snakeInDragonInverted', 'Snake In Dragon Inverted'), ('snakeLetter', 'Snake Letter'), ('snakeLongNeck', 'Snake Long Neck'), ('snakePencil', 'Snake Pencil'), ('snakeTail', 'Snake Tail'), ('snakeWithBalloon', 'Snake With Balloon'), ('snakeWithContacts', 'Snake With Contacts'), ('snakesWithBanner', 'Snakes With Banner'), ('snakesWithCocktail', 'Snakes With Cocktail'), ('snakesWithDirections', 'Snakes With Directions'), ('snakesWithOutlines', 'Snakes With Outlines'), ('tripleSnakes', 'Triple Snakes')], 'help_text': 'Shown next to the copy instead of the animated city illustration. Renders immediately, so prefer it when the fold has to be fast.', 'required': False}), 42: ('wagtail.blocks.StructBlock', [[('city', 36), ('pretitle', 37), ('title', 38), ('subtitle', 39), ('highlight', 40), ('primary_cta', 24), ('secondary_cta', 25), ('illustration', 41)]], {}), 43: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('speakers', 'Speakers'), ('keynoters', 'Keynoters'), ('proposals', 'Proposals')]}), 44: ('wagtail.blocks.StructBlock', [[('source', 43)]], {}), 45: ('wagtail.blocks.ListBlock', (cms.components.page.blocks.communities_section.Community,), {}), 46: ('wagtail.blocks.StructBlock', [[('title', 19), ('communities', 45)]], {})}), + ), + ] diff --git a/backend/schema.graphql b/backend/schema.graphql index 3c9ed32744..473a7ed0e4 100644 --- a/backend/schema.graphql +++ b/backend/schema.graphql @@ -542,6 +542,13 @@ type HomeIntroSection { type HomepageHero { id: ID! city: HomepageHeroCity + pretitle: String! + title: String! + subtitle: String! + highlight: String! + illustration: String! + primaryCta: CTA + secondaryCta: CTA } enum HomepageHeroCity { diff --git a/frontend/src/components/blocks/homepage-hero/index.tsx b/frontend/src/components/blocks/homepage-hero/index.tsx index 83b1192039..ca881ff815 100644 --- a/frontend/src/components/blocks/homepage-hero/index.tsx +++ b/frontend/src/components/blocks/homepage-hero/index.tsx @@ -1,30 +1,85 @@ import { + Button, + Container, + Grid, + GridColumn, + Heading, HeroIllustration, HeroIllustrationBologna, LayoutContent, ScrollDownArrowBar, + Spacer, + Text, } from "@python-italia/pycon-styleguide"; +import type { Illustration } from "@python-italia/pycon-styleguide/dist/illustrations/types"; +import { getIllustration } from "@python-italia/pycon-styleguide/illustrations"; import React from "react"; -import { HomepageHeroCity } from "~/types"; +import { type Cta, HomepageHeroCity } from "~/types"; +import { type ModalID, useSetCurrentModal } from "../../modal/context"; type Props = { cycle: "day" | "night"; - city: HomepageHeroCity; + city: HomepageHeroCity | null; + pretitle: string; + title: string; + subtitle: string; + highlight: string; + illustration: string; + primaryCta: Cta | null; + secondaryCta: Cta | null; }; const HeroIllustrationFlorenceMemo = React.memo(HeroIllustration); const HeroIllustrationBolognaMemo = React.memo(HeroIllustrationBologna); -export const HomepageHero = ({ cycle, city }: Props) => { +const CityIllustration = ({ city, cycle }: Pick) => ( + <> + {city === HomepageHeroCity.Florence && ( + + )} + {city === HomepageHeroCity.Bologna && ( + + )} + +); + +export const HomepageHero = ({ + cycle, + city, + pretitle, + title, + subtitle, + highlight, + illustration, + primaryCta, + secondaryCta, +}: Props) => { + const hasCopy = !!(title || pretitle || subtitle || primaryCta); + + if (!hasCopy) { + return ; + } + + return ( + + ); +}; + +const FullScreenHero = ({ cycle, city }: Pick) => { return (
- {city === HomepageHeroCity.Florence && ( - - )} - {city === HomepageHeroCity.Bologna && ( - - )} +
{ ); }; +const SplitHero = ({ + cycle, + city, + pretitle, + title, + subtitle, + highlight, + illustration, + primaryCta, + secondaryCta, +}: Props) => { + const StaticIllustration = illustration + ? getIllustration(illustration as Illustration) + : null; + const hasIllustration = !!StaticIllustration || !!city; + + return ( + + + + + {pretitle && ( + <> + + {pretitle} + + + + )} + + {title && {title}} + + {subtitle && ( + <> + + + {subtitle} + + + )} + + {(primaryCta || secondaryCta) && ( + <> + +
+ {primaryCta && } + {secondaryCta && ( + + )} +
+ + )} + + {highlight && ( + <> + + + {highlight} + + + )} +
+ + {hasIllustration && ( + + {StaticIllustration ? ( +
+ +
+ ) : ( + + )} +
+ )} +
+ +
+ ); +}; + +// The city illustrations are scenes drawn for a full viewport: scale the whole +// scene down instead of cropping it, so nothing (the towers, the snake) is lost +// when it only gets half of the screen. +const ILLUSTRATION_WIDTH = 1440; +const ILLUSTRATION_HEIGHT = 900; + +const ScaledCityIllustration = ({ + city, + cycle, +}: Pick) => { + const containerRef = React.useRef(null); + const [scale, setScale] = React.useState(0); + + React.useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const observer = new ResizeObserver(([entry]) => { + setScale(entry.contentRect.width / ILLUSTRATION_WIDTH); + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + return ( +
+
+ +
+
+ ); +}; + +const HeroCTA = ({ + cta, + variant, +}: { + cta: Cta; + variant: "primary" | "secondary"; +}) => { + const setCurrentModal = useSetCurrentModal(); + const isModalCTA = cta.link?.startsWith("modal:"); + const openModal = (e) => { + if (!isModalCTA) { + return; + } + e.preventDefault(); + + const modalId = cta.link.replace("modal:", "") as ModalID; + setCurrentModal(modalId); + }; + + return ( + + ); +}; + HomepageHero.getStaticProps = () => { const utcHours = new Date().getUTCHours(); const cycle = utcHours > 5 && utcHours < 17 ? "day" : "night"; diff --git a/frontend/src/pages/schedule/fragments/blocks.graphql b/frontend/src/pages/schedule/fragments/blocks.graphql index 7eb16fdabf..b80ece2279 100644 --- a/frontend/src/pages/schedule/fragments/blocks.graphql +++ b/frontend/src/pages/schedule/fragments/blocks.graphql @@ -129,6 +129,17 @@ fragment Blocks on Block { ... on HomepageHero { id city + pretitle + title + subtitle + highlight + illustration + primaryCta { + ...CTAInfo + } + secondaryCta { + ...CTAInfo + } } ... on DynamicContentDisplaySection {