diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index c0429f94a..a90b13c5c 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -61,6 +61,11 @@ class EventExternalLink(APIResponse): url: str +class EventCountdown(APIResponse): + starts_at: str + ends_at: str + + class EventDetail(APIResponse): """One event, with everything the manage page edits or shows.""" @@ -82,6 +87,9 @@ class EventDetail(APIResponse): # The organiser's own link, or the one Zoom issued when the meeting was booked. meeting_link: str | None = None is_published: bool + show_countdown: bool = False + # None once the event is over. + countdown: EventCountdown | None = None primary_host: EventHostRef | None = None co_hosts: list[EventHostRef] = Field(default_factory=list) external_links: list[EventExternalLink] = Field(default_factory=list) diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index 151316eba..08a0ceab5 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -37,6 +37,7 @@ ) from buzz.api.filters.conditions import ListConditions, event_questions, filter_field, question_fields from buzz.api.filters.schemas import FilterField +from buzz.events.countdown import event_countdown from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, BuzzEvent from buzz.permissions import has_team_access, my_teams from buzz.utils import is_app_installed @@ -145,6 +146,7 @@ def split_by_date(events: list[MyEvent]) -> tuple[list[MyEvent], list[MyEvent]]: "venue", "meeting_link", "is_published", + "show_countdown", ) @@ -167,6 +169,7 @@ def event_detail(event: str) -> EventDetail: "primary_host": primary_host_of(row.team), "co_hosts": co_hosts_of(event), "external_links": external_links_of(event), + "countdown": event_countdown(row), } ) diff --git a/buzz/events/countdown.py b/buzz/events/countdown.py new file mode 100644 index 000000000..22444ce64 --- /dev/null +++ b/buzz/events/countdown.py @@ -0,0 +1,20 @@ +from datetime import datetime +from datetime import time as clock_time + +from frappe.utils import get_time, getdate + +from buzz.www.event.meta import event_time_zone + + +def event_countdown(event) -> dict | None: + """Start and end of the banner countdown, or None once the event is over.""" + # An event with no closing time runs to the end of its last day. + # Whole seconds: JavaScript only promises to parse milliseconds. + zone = event_time_zone(event) + start_time = get_time(event.start_time) if event.start_time else clock_time.min + end_time = get_time(event.end_time) if event.end_time else clock_time(23, 59, 59) + starts_at = datetime.combine(getdate(event.start_date), start_time, zone) + ends_at = datetime.combine(getdate(event.end_date or event.start_date), end_time, zone) + if ends_at <= datetime.now(zone): + return None + return {"starts_at": starts_at.isoformat(), "ends_at": ends_at.isoformat()} diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 0ad067b64..546576634 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -36,6 +36,7 @@ "website_tab", "is_published", "is_featured", + "show_countdown", "route", "default_ticket_type", "column_break_ndtl", @@ -247,6 +248,13 @@ "label": "Featured", "permlevel": 1 }, + { + "default": "0", + "description": "Shows the time until the event starts on the event page banner.", + "fieldname": "show_countdown", + "fieldtype": "Check", + "label": "Show Event Countdown" + }, { "fieldname": "tab_2_tab", "fieldtype": "Tab Break", @@ -681,7 +689,7 @@ "link_fieldname": "event" } ], - "modified": "2026-10-02 12:00:00.000000", + "modified": "2026-10-12 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Event", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index 8ed715557..66cd444c8 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -88,6 +88,7 @@ class BuzzEvent(Document): send_booking_confirmation_email: DF.Check send_ticket_email: DF.Check short_description: DF.SmallText | None + show_countdown: DF.Check show_sponsorship_section: DF.Check sponsor_deck_attachments: DF.Table[SponsorshipDeckItem] sponsor_deck_cc: DF.SmallText | None diff --git a/buzz/public/themes/base.css b/buzz/public/themes/base.css index 010e97aa3..59ce1dba2 100644 --- a/buzz/public/themes/base.css +++ b/buzz/public/themes/base.css @@ -261,118 +261,12 @@ body:has(.login-frame:not([data-ready])) { border-radius: var(--radius-banner); } -/* Countdown pill, straddling the banner's bottom edge like the dashboard's */ +/* Holds the countdown pill (countdown.css) on the banner's bottom edge */ .event-banner-frame { position: relative; } -.countdown-pill { - position: absolute; - bottom: 0; - left: 50%; - display: inline-flex; - align-items: center; - gap: 6px; - height: 26px; - padding: 0 10px; - border-radius: 999px; - background: var(--surface-raised); - box-shadow: - 0 0 0 1px var(--border), - 0 4px 12px rgb(0 0 0 / 0.12); - color: var(--ink-muted); - font-size: 12px; - font-weight: 500; - letter-spacing: 0; - white-space: nowrap; - transform: translate(-50%, 50%); - animation: countdown-in 250ms var(--ease-out) both; -} - -.countdown-pill[hidden] { - display: none; -} - -.countdown-pill:not([data-live]) :is(.live-dot, .countdown-live), -.countdown-pill[data-live] .countdown-upcoming { - display: none; -} - -.countdown-value { - display: inline-flex; - color: var(--ink-strong); - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -.roll-slot { - display: inline-grid; - overflow: hidden; -} - -.roll-slot > span { - grid-area: 1 / 1; -} - -.roll-in { - animation: roll-in 300ms var(--ease-out) both; -} - -.roll-out { - animation: roll-out 300ms var(--ease-out) both; -} - -.live-dot { - position: relative; - display: inline-flex; - width: 8px; - height: 8px; -} - -.live-dot::before, -.live-dot::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 999px; - background: #16a34a; -} - -.live-dot::before { - opacity: 0.75; - animation: live-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; -} - -@keyframes countdown-in { - from { - opacity: 0; - transform: translate(-50%, 50%) scale(0.9); - } -} - -@keyframes roll-in { - from { - opacity: 0; - transform: translateY(-70%); - } -} - -@keyframes roll-out { - to { - opacity: 0; - transform: translateY(70%); - } -} - -@keyframes live-ping { - 75%, - 100% { - opacity: 0; - transform: scale(2); - } -} - .event-intro { display: flex; align-items: center; @@ -1272,9 +1166,6 @@ summary:focus-visible { } @media (prefers-reduced-motion: reduce) { - .countdown-pill, - .roll-in, - .roll-out, .live-dot::before { animation: none; } diff --git a/buzz/public/themes/countdown.css b/buzz/public/themes/countdown.css new file mode 100644 index 000000000..1d993cd88 --- /dev/null +++ b/buzz/public/themes/countdown.css @@ -0,0 +1,117 @@ +/* Countdown pill on an event banner, shared by the event page and the dashboard. + The banner's frame positions it; the host page supplies the colour tokens. */ + +.countdown-pill { + position: absolute; + bottom: 0; + left: 50%; + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border-radius: 999px; + background: var(--surface-raised); + box-shadow: + 0 0 0 1px var(--border), + 0 4px 12px rgb(0 0 0 / 0.12); + color: var(--ink-muted); + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + white-space: nowrap; + transform: translate(-50%, 50%); + animation: countdown-in 250ms var(--ease-out) both; +} + +.countdown-pill[hidden] { + display: none; +} + +.countdown-pill:not([data-live]) :is(.live-dot, .countdown-live), +.countdown-pill[data-live] .countdown-upcoming { + display: none; +} + +.countdown-value { + display: inline-flex; + color: var(--ink-strong); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.roll-slot { + display: inline-grid; + overflow: hidden; +} + +.roll-slot > span { + grid-area: 1 / 1; +} + +.roll-in { + animation: roll-in 300ms var(--ease-out) both; +} + +.roll-out { + animation: roll-out 300ms var(--ease-out) both; +} + +.live-dot { + position: relative; + display: inline-flex; + width: 8px; + height: 8px; +} + +.live-dot::before, +.live-dot::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 999px; + background: #16a34a; +} + +.live-dot::before { + opacity: 0.75; + animation: live-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +@keyframes countdown-in { + from { + opacity: 0; + transform: translate(-50%, 50%) scale(0.9); + } +} + +@keyframes roll-in { + from { + opacity: 0; + transform: translateY(-70%); + } +} + +@keyframes roll-out { + to { + opacity: 0; + transform: translateY(70%); + } +} + +@keyframes live-ping { + 75%, + 100% { + opacity: 0; + transform: scale(2); + } +} + +@media (prefers-reduced-motion: reduce) { + .countdown-pill, + .roll-in, + .roll-out, + .live-dot::before { + animation: none; + } +} diff --git a/buzz/templates/event_page/layout.html b/buzz/templates/event_page/layout.html index 329564bc9..b5c0a5765 100644 --- a/buzz/templates/event_page/layout.html +++ b/buzz/templates/event_page/layout.html @@ -21,6 +21,7 @@ + diff --git a/buzz/www/event/index.py b/buzz/www/event/index.py index 204156952..cd60d6722 100644 --- a/buzz/www/event/index.py +++ b/buzz/www/event/index.py @@ -1,5 +1,3 @@ -from datetime import datetime -from datetime import time as clock_time from functools import cached_property from itertools import groupby from urllib.parse import urlparse @@ -19,6 +17,7 @@ from buzz.api.booking.services import are_registrations_closed from buzz.api.events.services import co_hosts_of, primary_host_of, registration_link from buzz.emails import venue_map_url as venue_link +from buzz.events.countdown import event_countdown from buzz.events.doctype.sponsorship_tier.sponsorship_tier import default_price from buzz.events.online_meeting import OnlineMeeting from buzz.utils import datetime_in_time_zone, format_gmt_offset, get_time_zone_label @@ -124,23 +123,11 @@ def as_context(self) -> dict: "online_label": OnlineMeeting(self.event).label, "register_url": registration_link(self.event), "registrations_closed": are_registrations_closed(self.event), - "countdown": self.countdown(), + "countdown": event_countdown(self.event) if self.event.show_countdown else None, } meta = EventMeta(self.event, self.page, context) return context | {"meta": meta.as_dict(), "structured_data": meta.structured_data()} - def countdown(self) -> dict | None: - # An event with no closing time runs to the end of its last day; past that, no countdown. - # Whole seconds: JavaScript only promises to parse milliseconds. - zone = event_time_zone(self.event) - start_time = get_time(self.event.start_time) if self.event.start_time else clock_time.min - end_time = get_time(self.event.end_time) if self.event.end_time else clock_time(23, 59, 59) - starts_at = datetime.combine(getdate(self.event.start_date), start_time, zone) - ends_at = datetime.combine(getdate(self.event.end_date or self.event.start_date), end_time, zone) - if ends_at <= datetime.now(zone): - return None - return {"starts_at": starts_at.isoformat(), "ends_at": ends_at.isoformat()} - def tabs(self) -> list[dict]: sections = [ ("about", _("About"), True), diff --git a/buzz/www/event/test_event_render.py b/buzz/www/event/test_event_render.py index 886781966..49c9324d7 100644 --- a/buzz/www/event/test_event_render.py +++ b/buzz/www/event/test_event_render.py @@ -92,17 +92,34 @@ def test_online_event_names_the_platform_but_never_shows_the_link(self): self.assertEqual(EventPage(self.route).as_context()["online_label"], "Online") def test_upcoming_event_renders_a_countdown(self): - self.set_event({"time_zone": "Asia/Kolkata", "start_date": "2099-01-05", "start_time": "09:30:00"}) + self.set_event( + { + "show_countdown": 1, + "time_zone": "Asia/Kolkata", + "start_date": "2099-01-05", + "start_time": "09:30:00", + } + ) html = render(self.route) self.assertIn('starts-at="2099-01-05T09:30:00+05:30"', html) def test_ended_event_has_no_countdown(self): - self.set_event({"start_date": "2020-01-05", "end_date": "2020-01-05"}) + self.set_event({"show_countdown": 1, "start_date": "2020-01-05", "end_date": "2020-01-05"}) + self.assertNotIn(" +import "@public/themes/countdown.css" +import { EventCountdown } from "@public/js/event_countdown" + +// The event page's own pill, so the banner here reads the same as the one guests see. +if (!customElements.get("event-countdown")) customElements.define("event-countdown", EventCountdown) + +defineProps<{ startsAt: string; endsAt: string }>() + + + + + diff --git a/dashboard/src/components/dashboard/events/EventBanner.vue b/dashboard/src/components/dashboard/events/EventBanner.vue index 0cc43bfc6..181f2f0a2 100644 --- a/dashboard/src/components/dashboard/events/EventBanner.vue +++ b/dashboard/src/components/dashboard/events/EventBanner.vue @@ -62,60 +62,65 @@ watch(image, () => { @success="(file: { file_url: string }) => (image = file.file_url)" > diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index d3bdff455..b91c92a30 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -350,6 +350,9 @@ export interface EventDetail { venue: EventVenueDetail | null meeting_link: string | null is_published: boolean + show_countdown: boolean + // Null once the event is over. + countdown: { starts_at: string; ends_at: string } | null primary_host: EventHostRef | null co_hosts: EventHostRef[] external_links: EventExternalLink[] diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 47e549110..820ca91c3 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -20,7 +20,10 @@ export default defineConfig({ outDir: "../buzz/public/dashboard", }, }), - vue(), + vue({ + // The event page's custom elements, shared with the dashboard. + template: { compilerOptions: { isCustomElement: (tag) => tag === "event-countdown" } }, + }), ], resolve: { alias: {