diff --git a/backend/api/cms/page/blocks/homepage_hero.py b/backend/api/cms/page/blocks/homepage_hero.py index 037136475a..75dc8af10c 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 @@ -10,16 +11,48 @@ class HomepageHeroCity(Enum): BOLOGNA = "bologna" +@strawberry.enum +class HomepageHeroVariant(Enum): + ILLUSTRATION_ONLY = "illustration_only" + OVERLAY = "overlay" + + @register_page_block @strawberry.type class HomepageHero: id: strawberry.ID city: HomepageHeroCity | None + variant: HomepageHeroVariant + title: str + subtitle: str + body: str + highlight: str + primary_cta: CTA | None + secondary_cta: CTA | None @classmethod def from_block(cls, block) -> Self: city = block.value.get("city") + variant = block.value.get("variant") + primary_cta = block.value.get("primary_cta") + secondary_cta = block.value.get("secondary_cta") + return cls( id=block.id, city=HomepageHeroCity(city) if city else None, + variant=HomepageHeroVariant(variant or "illustration_only"), + title=block.value.get("title") or "", + subtitle=block.value.get("subtitle") or "", + body=block.value.get("body") or "", + highlight=block.value.get("highlight") or "", + primary_cta=( + CTA.from_block(primary_cta) + if primary_cta and primary_cta["label"] + else None + ), + secondary_cta=( + CTA.from_block(secondary_cta) + if secondary_cta and secondary_cta["label"] + else None + ), ) diff --git a/backend/api/cms/tests/factories.py b/backend/api/cms/tests/factories.py index f2b4b5286e..7b5b0aa874 100644 --- a/backend/api/cms/tests/factories.py +++ b/backend/api/cms/tests/factories.py @@ -6,6 +6,7 @@ SliderCardsSection, ) from cms.components.page.models import BodyBlock +from cms.components.base.blocks.cta import CTA from cms.components.base.blocks.map import Map from wagtail_factories import ( CharBlockFactory, @@ -56,7 +57,18 @@ class Params: p = factory.Faker("text", max_nb_chars=300) +class CTAFactory(StructBlockFactory): + label = factory.SubFactory(CharBlockFactory) + link = factory.SubFactory(CharBlockFactory) + + 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..5cd1b5394f 100644 --- a/backend/api/cms/tests/page/queries/test_cms_page.py +++ b/backend/api/cms/tests/page/queries/test_cms_page.py @@ -70,6 +70,84 @@ def test_page(graphql_client, locale): } +def test_page_with_homepage_hero_overlay(graphql_client, locale): + parent = GenericPageFactory() + page = GenericPageFactory( + slug="bubble-tea", + locale=locale("en"), + parent=parent, + title="Bubble", + body__0__homepage_hero__city="bologna", + body__0__homepage_hero__variant="overlay", + body__0__homepage_hero__title="PyCon Italia 2027", + body__0__homepage_hero__subtitle="Bologna, May 27 - 30, 2027", + body__0__homepage_hero__body="Four days of talks, tutorials and community", + body__0__homepage_hero__highlight="1,000+ attendees", + body__0__homepage_hero__primary_cta__label__value="Get your ticket", + body__0__homepage_hero__primary_cta__link__value="/tickets", + body__0__homepage_hero__secondary_cta__label__value="See the schedule", + body__0__homepage_hero__secondary_cta__link__value="/schedule", + body__1__homepage_hero__city="bologna", + body__1__homepage_hero__primary_cta__label__value="", + body__1__homepage_hero__secondary_cta__label__value="", + ) + page.save_revision().publish() + SiteFactory(hostname="pycon", port=80, root_page=parent) + query = """ + query Page ($hostname: String!, $language: String!, $slug: String!) { + cmsPage(hostname: $hostname, language: $language, slug: $slug){ + ...on GenericPage { + body { + ... on HomepageHero { + city + variant + title + subtitle + body + highlight + primaryCta { + label + link + } + secondaryCta { + label + link + } + } + } + } + } + } + """ + + response = graphql_client.query( + query, variables={"hostname": "pycon", "slug": "bubble-tea", "language": "en"} + ) + + assert response["data"]["cmsPage"]["body"] == [ + { + "city": "BOLOGNA", + "variant": "OVERLAY", + "title": "PyCon Italia 2027", + "subtitle": "Bologna, May 27 - 30, 2027", + "body": "Four days of talks, tutorials and community", + "highlight": "1,000+ attendees", + "primaryCta": {"label": "Get your ticket", "link": "/tickets"}, + "secondaryCta": {"label": "See the schedule", "link": "/schedule"}, + }, + { + "city": "BOLOGNA", + "variant": "ILLUSTRATION_ONLY", + "title": "", + "subtitle": "", + "body": "", + "highlight": "", + "primaryCta": None, + "secondaryCta": None, + }, + ] + + def test_page_with_ticket_restriction_and_ticket_returns_content( graphql_client, locale, user, mock_has_ticket ): diff --git a/backend/cms/components/page/blocks/homepage_hero.py b/backend/cms/components/page/blocks/homepage_hero.py index 585856b0ad..8de7c43076 100644 --- a/backend/cms/components/page/blocks/homepage_hero.py +++ b/backend/cms/components/page/blocks/homepage_hero.py @@ -1,3 +1,4 @@ +from cms.components.base.blocks.cta import CTA from wagtail import blocks @@ -8,6 +9,43 @@ class HomepageHero(blocks.StructBlock): ("bologna", "Bologna"), ] ) + variant = blocks.ChoiceBlock( + required=False, + default="illustration_only", + choices=[ + ("illustration_only", "Illustration only"), + ("overlay", "Overlay (copy on top of the illustration)"), + ], + help_text=( + "Illustration only keeps the full-screen illustration with no text. " + "Overlay puts the copy and the CTAs below on top of it." + ), + ) + title = blocks.CharBlock( + required=False, + label="Title", + help_text="The conference name as text, e.g. PyCon Italia 2027.", + ) + subtitle = blocks.CharBlock( + required=False, + label="Dates and city", + help_text="e.g. Bologna, May 27 - 30, 2027.", + ) + body = blocks.CharBlock( + required=False, + label="One line description", + help_text=( + "What kind of event this is, in one line, e.g. Four days of talks, " + "tutorials and community, in Bologna." + ), + ) + highlight = blocks.CharBlock( + required=False, + label="Scale", + help_text="Optional credibility marker, e.g. 1,000+ attendees.", + ) + primary_cta = CTA(label="Primary CTA") + secondary_cta = CTA(label="Secondary CTA") 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..0884cad298 --- /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 23:16 + +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.ChoiceBlock', [], {'choices': [('illustration_only', 'Illustration only'), ('overlay', 'Overlay (copy on top of the illustration)')], 'help_text': 'Illustration only keeps the full-screen illustration with no text. Overlay puts the copy and the CTAs below on top of it.', 'required': False}), 38: ('wagtail.blocks.CharBlock', (), {'help_text': 'The conference name as text, e.g. PyCon Italia 2027.', 'label': 'Title', 'required': False}), 39: ('wagtail.blocks.CharBlock', (), {'help_text': 'e.g. Bologna, May 27 - 30, 2027.', 'label': 'Dates and city', 'required': False}), 40: ('wagtail.blocks.CharBlock', (), {'help_text': 'What kind of event this is, in one line, e.g. Four days of talks, tutorials and community, in Bologna.', 'label': 'One line description', 'required': False}), 41: ('wagtail.blocks.CharBlock', (), {'help_text': 'Optional credibility marker, e.g. 1,000+ attendees.', 'label': 'Scale', 'required': False}), 42: ('wagtail.blocks.StructBlock', [[('city', 36), ('variant', 37), ('title', 38), ('subtitle', 39), ('body', 40), ('highlight', 41), ('primary_cta', 24), ('secondary_cta', 25)]], {}), 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..145190a016 100644 --- a/backend/schema.graphql +++ b/backend/schema.graphql @@ -542,6 +542,13 @@ type HomeIntroSection { type HomepageHero { id: ID! city: HomepageHeroCity + variant: HomepageHeroVariant! + title: String! + subtitle: String! + body: String! + highlight: String! + primaryCta: CTA + secondaryCta: CTA } enum HomepageHeroCity { @@ -549,6 +556,11 @@ enum HomepageHeroCity { BOLOGNA } +enum HomepageHeroVariant { + ILLUSTRATION_ONLY + OVERLAY +} + type InformationSection { id: ID! title: String! diff --git a/frontend/src/components/blocks/homepage-hero/index.tsx b/frontend/src/components/blocks/homepage-hero/index.tsx index 83b1192039..02608fb17b 100644 --- a/frontend/src/components/blocks/homepage-hero/index.tsx +++ b/frontend/src/components/blocks/homepage-hero/index.tsx @@ -1,21 +1,48 @@ import { + Button, + Container, + Heading, HeroIllustration, HeroIllustrationBologna, LayoutContent, ScrollDownArrowBar, + Spacer, + Text, } from "@python-italia/pycon-styleguide"; import React from "react"; -import { HomepageHeroCity } from "~/types"; +import { type ModalID, useSetCurrentModal } from "~/components/modal/context"; +import { useCurrentLanguage } from "~/locale/context"; +import { type Cta, HomepageHeroCity, HomepageHeroVariant } from "~/types"; +import { createHref } from "../../link"; type Props = { cycle: "day" | "night"; city: HomepageHeroCity; + variant: HomepageHeroVariant; + title: string; + subtitle: string; + body: string; + highlight: string; + primaryCta: Cta | null; + secondaryCta: Cta | null; }; const HeroIllustrationFlorenceMemo = React.memo(HeroIllustration); const HeroIllustrationBolognaMemo = React.memo(HeroIllustrationBologna); -export const HomepageHero = ({ cycle, city }: Props) => { +export const HomepageHero = ({ + cycle, + city, + variant, + title, + subtitle, + body, + highlight, + primaryCta, + secondaryCta, +}: Props) => { + const isOverlay = variant === HomepageHeroVariant.Overlay; + return (
@@ -27,6 +54,17 @@ export const HomepageHero = ({ cycle, city }: Props) => { )}
+ {isOverlay && ( + + )} + { ); }; +type OverlayProps = Pick< + Props, + "title" | "subtitle" | "body" | "highlight" | "primaryCta" | "secondaryCta" +>; + +const HeroOverlay = ({ + title, + subtitle, + body, + highlight, + primaryCta, + secondaryCta, +}: OverlayProps) => ( + // The whole overlay ignores pointer events so the illustration keeps its + // easter egg (clicking the church swaps day and night), only the CTAs take + // clicks back. +
+ + +
+ +
+ {title && ( + + {title} + + )} + {subtitle && ( + <> + + + {subtitle} + + + )} + {body && ( + <> + + + {body} + + + )} + {(primaryCta || secondaryCta) && ( + <> + +
+ {primaryCta && } + {secondaryCta && ( + + )} +
+ + )} + {highlight && ( + <> + + + {highlight} + + + )} +
+
+
+
+); + +/** + * The hero illustration cannot carry light text on its own: the day sky is a + * mid-tone periwinkle (#6A80EF) that only reaches 3.3:1 against milk, and the + * bottom half is crowded with hills, the church, the two towers and the snake. + * + * The vertical scrim carries mobile, where the copy spans the full width. On + * desktop it is lightened and paired with a horizontal one so the copy column + * on the left is the only part that gets darkened, leaving the landmarks on the + * right side of the frame readable. Combined, the copy area sits at ~0.55-0.75 + * black over the artwork, which is >=8:1 against milk in both cycles. + */ +const Scrim = () => ( + <> +
+
+ +); + +const HeroCta = ({ + cta, + ...buttonProps +}: { + cta: Cta; +} & Pick, "variant" | "background">) => { + const language = useCurrentLanguage(); + const setCurrentModal = useSetCurrentModal(); + const isModalCta = cta.link?.startsWith("modal:"); + + const openModal = (e) => { + if (!isModalCta) { + return; + } + + e.preventDefault(); + setCurrentModal(cta.link.replace("modal:", "") as 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..fc1a3abedf 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 + variant + title + subtitle + body + highlight + primaryCta { + ...CTAInfo + } + secondaryCta { + ...CTAInfo + } } ... on DynamicContentDisplaySection {