From fbb117c8d6470cfa8e35ea58e8025bcd4f91d492 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 30 Apr 2024 11:21:21 -0700 Subject: [PATCH 01/28] WORK IN PROGRESS (see vb/views.py) --- server/vb/views.py | 53 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/server/vb/views.py b/server/vb/views.py index fdbcd0c..c4fb2ab 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -1,13 +1,17 @@ import logging +import typing as t +import ludic.html as h from django import forms from django.conf import settings from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect, render +from django.templatetags.static import static from django.utils.timezone import now as dj_now from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST +from ludic.types import BaseElement from .models import Contest, EmailValidationLink, School from .ops import ( @@ -20,16 +24,55 @@ logger = logging.getLogger(__name__) +def base_page( + title: str = "VoterBowl", children: t.Iterable[BaseElement] | None = None +) -> BaseElement: + """Render a basic HTML page.""" + return h.html( + h.head( + h.title(title), + h.meta(name="description", content="VoterBowl: online voting competitions"), + h.meta(name="keywords", content="voting, competition, online"), + h.meta(charset="utf-8"), + # h.meta(http_equiv="X-UA-Compatible", content="IE=edge"), + h.meta(name="vierwport", content="width=device-width, initial-scale=1.0"), + h.meta(name="format-detection", content="telephone=no"), + h.link(rel="stylesheet", href=static("/css/modern-normalize.min.css")), + h.link(rel="stylesheet", href=static("/css/base.css")), + h.script(src=static("js/htmx.min.js")), + h.script(src=static("js/css-scope-inline.js")), + h.script(src=static("/js/surreal.js")), + ), + h.body(*(children or [])), + ) + + +def home_page() -> BaseElement: + """Render the home page.""" + return base_page( + children=[ + h.div( + h.main( + h.div(class_="container"), + h.div("LOGO GOES HERE", class_="center"), + h.div("CONTESTS", class_="ongoing") if ongoing_contests else None, + ) + ) + ] + ) + + +def render_element(element: BaseElement) -> HttpResponse: + """Render a ludic element as an HTTP response.""" + return HttpResponse(content=element.to_html(), content_type="text/html") + + @require_GET def home(request: HttpRequest) -> HttpResponse: """Render the voterbowl homepage.""" ongoing_contests = list(Contest.objects.ongoing().order_by("end_at")) upcoming_contests = list(Contest.objects.upcoming().order_by("start_at")) - return render( - request, - "home.dhtml", - {"ongoing_contests": ongoing_contests, "upcoming_contests": upcoming_contests}, - ) + return render_element(home_page()) @require_GET From 214be449524a227cee3615052023a5b5ae292bea Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 1 May 2024 07:40:31 -0700 Subject: [PATCH 02/28] WIP --- pyproject.toml | 3 ++ requirements.txt | 1 + server/vb/components/__init__.py | 0 server/vb/components/base.py | 52 ++++++++++++++++++++++++++++++ server/vb/components/home.py | 45 ++++++++++++++++++++++++++ server/vb/components/logo.py | 24 ++++++++++++++ server/vb/components/utils.py | 54 +++++++++++++++++++++++++++++++ server/vb/views.py | 55 +++----------------------------- 8 files changed, 184 insertions(+), 50 deletions(-) create mode 100644 server/vb/components/__init__.py create mode 100644 server/vb/components/base.py create mode 100644 server/vb/components/home.py create mode 100644 server/vb/components/logo.py create mode 100644 server/vb/components/utils.py diff --git a/pyproject.toml b/pyproject.toml index 7d5d49b..85c1f55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ django_settings_module = "server.settings" [tool.ruff] exclude = [".venv", "**/migrations/**"] +[tool.ruff.per-file-ignores] +"**/components/*.py" = ["E501"] + [tool.ruff.lint] extend-select = [ "E", # style errors diff --git a/requirements.txt b/requirements.txt index 6e9dbb2..ad29704 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ django-stubs>=4.2.7 django>=5,<6 djlint>=1.34.1 gunicorn>=21,<22 +htpy>=24.4.0 httpx>=0.20.0 mypy>=1.9.0 pillow>=10.2.0 diff --git a/server/vb/components/__init__.py b/server/vb/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/vb/components/base.py b/server/vb/components/base.py new file mode 100644 index 0000000..8ec0688 --- /dev/null +++ b/server/vb/components/base.py @@ -0,0 +1,52 @@ +import htpy as h +from django.templatetags.static import static +from markupsafe import Markup + + +def _gtag_scripts() -> h.Node: + """Render the Google Analytics scripts.""" + return [ + h.script( + src="https://www.googletagmanager.com/gtag/js?id=G-RDV3WS6HTE", + _async=True, + ), + h.script[ + Markup(""" + window.dataLayer = window.dataLayer || []; + + function gtag() { + dataLayer.push(arguments); + } + gtag('js', new Date()); + gtag('config', 'G-RDV3WS6HTE'); + """) + ], + ] + + +def base_page( + content: h.Node | None = None, + *, + extra_head: h.Node | None = None, + title: str = "VoterBowl", +) -> h.Element: + """Render the generic structure for all pages on voterbowl.org.""" + return h.html(lang="en")[ + h.head[ + _gtag_scripts(), + h.title[title], + h.meta(name="description", content="VoterBowl: online voting competitions"), + h.meta(name="keywords", content="voting, competition, online"), + h.meta(charset="utf-8"), + h.meta(http_equiv="X-UA-Compatible", content="IE=edge"), + h.meta(name="vierwport", content="width=device-width, initial-scale=1.0"), + h.meta(name="format-detection", content="telephone=no"), + h.link(rel="stylesheet", href=static("/css/modern-normalize.min.css")), + h.link(rel="stylesheet", href=static("/css/base.css")), + h.script(src=static("js/htmx.min.js")), + h.script(src=static("js/css-scope-inline.js")), + h.script(src=static("/js/surreal.js")), + extra_head, + ], + h.body[content], + ] diff --git a/server/vb/components/home.py b/server/vb/components/home.py new file mode 100644 index 0000000..8fdea36 --- /dev/null +++ b/server/vb/components/home.py @@ -0,0 +1,45 @@ +import typing as t + +import htpy as h + +from ..models import Contest +from .base import base_page +from .logo import voter_bowl_logo + + +def _ongoing_contest(ongoing_contest: Contest) -> h.Node: + pass + + +def _ongoing_contests(contests: list[Contest]) -> h.Node: + """Render a list of ongoing contests.""" + if contests: + return h.div[(_ongoing_contest(contest) for contest in contests)] + return None + + +def _upcoming_contests(upcoming_contests: t.Iterable[Contest]) -> h.Node: + pass + + +def home_page( + ongoing_contests: t.Iterable[Contest], + upcoming_contests: t.Iterable[Contest], +) -> h.Element: + """Render the home page for voterbowl.org.""" + ongoing_contests = list(ongoing_contests) + upcoming_contests = list(upcoming_contests) + return base_page( + [ + h.div[ + h.main[ + h.div("container")[ + h.div("center")[voter_bowl_logo()], + h.h2[ + "College students win prizes by checking if they are registered to vote." + ], + ] + ] + ] + ] + ) diff --git a/server/vb/components/logo.py b/server/vb/components/logo.py new file mode 100644 index 0000000..2e0bc31 --- /dev/null +++ b/server/vb/components/logo.py @@ -0,0 +1,24 @@ +from markupsafe import Markup + +VOTER_BOWL_LOGO = Markup(""" + + + + + + + + + + + + + + + + + + + + +""") diff --git a/server/vb/components/utils.py b/server/vb/components/utils.py new file mode 100644 index 0000000..ed91daf --- /dev/null +++ b/server/vb/components/utils.py @@ -0,0 +1,54 @@ +import typing as t +from collections.abc import Callable +from dataclasses import dataclass + +import htpy as h + +P = t.ParamSpec("P") +R = t.TypeVar("R") +C = t.TypeVar("C") + + +@dataclass +class _ChildrenWrapper(t.Generic[C, R]): + _component_func: t.Callable + _args: t.Tuple[t.Any, ...] + _kwargs: t.Dict[str, t.Any] + + def __getitem__(self, children: C) -> R: + return self._component_func(children, *self._args, **self._kwargs) # type: ignore + + +@dataclass +class _OuterChildrenWrapper(t.Generic[P, C, R]): + _component_func: t.Callable + + def __getitem__(self, children: C) -> R: + return self._component_func(children) + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> _ChildrenWrapper[C, R]: + return _ChildrenWrapper(self._component_func, args, kwargs) + + +def with_children( + component_func: Callable[t.Concatenate[C, P], R], +) -> _OuterChildrenWrapper[P, C, R]: + """Wrap a component function to allow for children to be passed in a more natural way.""" + return _OuterChildrenWrapper[P, C, R](component_func) + + +@with_children +def bs_button(children: h.Node, style: t.Literal["success", "danger"]) -> h.Element: + """Render a Bootstrap button.""" + return h.button(class_=["btn", f"btn-{style}"])[children] + + +@with_children +def card(children: h.Node) -> h.Element: + """Render only the children.""" + return h.div("card")[children] + + +print(bs_button(style="danger")["Delete my account"]) +print(bs_button(style="success")) +print(card[h.p["This is a paragraph."]]) diff --git a/server/vb/views.py b/server/vb/views.py index c4fb2ab..66e5a96 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -1,18 +1,15 @@ import logging -import typing as t -import ludic.html as h from django import forms from django.conf import settings from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect, render -from django.templatetags.static import static from django.utils.timezone import now as dj_now from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST -from ludic.types import BaseElement +from .components import home_page from .models import Contest, EmailValidationLink, School from .ops import ( enter_contest, @@ -24,55 +21,13 @@ logger = logging.getLogger(__name__) -def base_page( - title: str = "VoterBowl", children: t.Iterable[BaseElement] | None = None -) -> BaseElement: - """Render a basic HTML page.""" - return h.html( - h.head( - h.title(title), - h.meta(name="description", content="VoterBowl: online voting competitions"), - h.meta(name="keywords", content="voting, competition, online"), - h.meta(charset="utf-8"), - # h.meta(http_equiv="X-UA-Compatible", content="IE=edge"), - h.meta(name="vierwport", content="width=device-width, initial-scale=1.0"), - h.meta(name="format-detection", content="telephone=no"), - h.link(rel="stylesheet", href=static("/css/modern-normalize.min.css")), - h.link(rel="stylesheet", href=static("/css/base.css")), - h.script(src=static("js/htmx.min.js")), - h.script(src=static("js/css-scope-inline.js")), - h.script(src=static("/js/surreal.js")), - ), - h.body(*(children or [])), - ) - - -def home_page() -> BaseElement: - """Render the home page.""" - return base_page( - children=[ - h.div( - h.main( - h.div(class_="container"), - h.div("LOGO GOES HERE", class_="center"), - h.div("CONTESTS", class_="ongoing") if ongoing_contests else None, - ) - ) - ] - ) - - -def render_element(element: BaseElement) -> HttpResponse: - """Render a ludic element as an HTTP response.""" - return HttpResponse(content=element.to_html(), content_type="text/html") - - @require_GET def home(request: HttpRequest) -> HttpResponse: """Render the voterbowl homepage.""" - ongoing_contests = list(Contest.objects.ongoing().order_by("end_at")) - upcoming_contests = list(Contest.objects.upcoming().order_by("start_at")) - return render_element(home_page()) + ongoing_contests = Contest.objects.ongoing().order_by("end_at") + upcoming_contests = Contest.objects.upcoming().order_by("start_at") + print(home_page(ongoing_contests, upcoming_contests)) + return HttpResponse(home_page(ongoing_contests, upcoming_contests)) @require_GET From 0d36868e0e96d18bbbf7790d962ccde0b548abe7 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 1 May 2024 13:35:06 -0700 Subject: [PATCH 03/28] WIP --- server/vb/components/utils.py | 86 +++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/server/vb/components/utils.py b/server/vb/components/utils.py index ed91daf..72b46f4 100644 --- a/server/vb/components/utils.py +++ b/server/vb/components/utils.py @@ -1,54 +1,72 @@ import typing as t -from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field, replace import htpy as h -P = t.ParamSpec("P") -R = t.TypeVar("R") -C = t.TypeVar("C") +@dataclass(frozen=True) +class with_children[C, **P, R]: + """Wrap a function to make it look more like an htpy.Element.""" -@dataclass -class _ChildrenWrapper(t.Generic[C, R]): - _component_func: t.Callable - _args: t.Tuple[t.Any, ...] - _kwargs: t.Dict[str, t.Any] + _f: t.Callable[t.Concatenate[C, P], R] + _args: tuple[t.Any, ...] = field(default_factory=tuple) + _kwargs: t.Mapping[str, t.Any] = field(default_factory=dict) def __getitem__(self, children: C) -> R: - return self._component_func(children, *self._args, **self._kwargs) # type: ignore + """Render the component with the given children.""" + return self._f(children, *self._args, **self._kwargs) # type: ignore + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> t.Self: + """Return a new instance of the class with the given arguments.""" + return replace(self, _args=args, _kwargs=kwargs) -@dataclass -class _OuterChildrenWrapper(t.Generic[P, C, R]): - _component_func: t.Callable + def __str__(self) -> str: + """Return the name of the function being wrapped.""" + # CONSIDER: alternatively, require that all wrapped functions + # have a default value for the `children` argument, and invoke + # the function here? + return f"with_children[{self._f.__name__}]" - def __getitem__(self, children: C) -> R: - return self._component_func(children) - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> _ChildrenWrapper[C, R]: - return _ChildrenWrapper(self._component_func, args, kwargs) +@with_children +def card(children: h.Node, data_foo: str | None = None) -> h.Element: + """Render a card with the given children.""" + return h.div(".card", data_foo=data_foo)[children] -def with_children( - component_func: Callable[t.Concatenate[C, P], R], -) -> _OuterChildrenWrapper[P, C, R]: - """Wrap a component function to allow for children to be passed in a more natural way.""" - return _OuterChildrenWrapper[P, C, R](component_func) +@with_children +def list_items(children: t.Iterable[str]) -> h.Node: + """Render all children in list items.""" + return [h.li[child] for child in children] -@with_children -def bs_button(children: h.Node, style: t.Literal["success", "danger"]) -> h.Element: - """Render a Bootstrap button.""" - return h.button(class_=["btn", f"btn-{style}"])[children] +# Wrapped func that returns Element; children only +#

paragraph content

+print(card[h.p["paragraph content"]]) +# Wrapped func that returns Element; children + kwargs +#
content
+print(card(data_foo="bar")["content"]) -@with_children -def card(children: h.Node) -> h.Element: - """Render only the children.""" - return h.div("card")[children] +# Wrapped func that returns Node; children only +#
  • Neato
  • Burrito
+print(h.ul[list_items["Neato", "Burrito"]]) + +# The odd duck that doesn't behave like an h.Element: +# with_children[card] +print(card) + +# Another odd duck: +# with_children[card] +print(card()) + + +if t.TYPE_CHECKING: + # h.Element + t.reveal_type(card["content"]) + # h.Node + t.reveal_type(list_items["Neato", "Burrito"]) -print(bs_button(style="danger")["Delete my account"]) -print(bs_button(style="success")) -print(card[h.p["This is a paragraph."]]) + # with_children[...] + t.reveal_type(card) From 2a807b7561498e287d5db57fca6b1be785beddff Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 1 May 2024 16:48:06 -0700 Subject: [PATCH 04/28] Work in progress: homepage with all interactive stuff. --- README.md | 2 +- pyproject.toml | 2 +- requirements.txt | 2 + server/vb/components/base.py | 33 ++- server/vb/components/faq.py | 109 +++++++++ server/vb/components/footer.py | 96 ++++++++ server/vb/components/home.py | 316 +++++++++++++++++++++++-- server/vb/components/utils.py | 51 ++-- server/vb/templates/home.dhtml | 10 - server/vb/templates/includes/faq.dhtml | 2 +- server/vb/views.py | 3 +- 11 files changed, 567 insertions(+), 59 deletions(-) create mode 100644 server/vb/components/faq.py create mode 100644 server/vb/components/footer.py diff --git a/README.md b/README.md index a8ff207..d3023d1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ For code cleanliness, we also use: 1. Make sure you have python 3.12 installed 1. Create and enable a python virtualenv with `python -m venv .venv; source .venv/bin/activate` -1. Install the python dependencies with `pip install -r requirements.txt` or `pip install ".[dev]"` +1. Install the python dependencies with `pip install -r requirements.txt` 1. Get postgres set up. If you've got docker installed, `./scripts/dockerpg.sh up` 1. Configure your environment variables. (See `.env.sample` and `settings.py`) 1. Run the app. `./manage.py runserver` and visit http://localhost:8000/ diff --git a/pyproject.toml b/pyproject.toml index 85c1f55..1bcb2c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ django_settings_module = "server.settings" [tool.ruff] exclude = [".venv", "**/migrations/**"] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "**/components/*.py" = ["E501"] [tool.ruff.lint] diff --git a/requirements.txt b/requirements.txt index ad29704..649bb5e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,10 +8,12 @@ djlint>=1.34.1 gunicorn>=21,<22 htpy>=24.4.0 httpx>=0.20.0 +markdown>=3.6.0 mypy>=1.9.0 pillow>=10.2.0 psycopg[binary]>=3,<4 pydantic>=2.7.0 ruff>=0.3.4 +types-markdown>=3.6.0 uvicorn[standard]>=0.29.0 whitenoise>=6,<7 diff --git a/server/vb/components/base.py b/server/vb/components/base.py index 8ec0688..c2a3e59 100644 --- a/server/vb/components/base.py +++ b/server/vb/components/base.py @@ -2,6 +2,10 @@ from django.templatetags.static import static from markupsafe import Markup +from .faq import faq +from .footer import footer +from .utils import with_children + def _gtag_scripts() -> h.Node: """Render the Google Analytics scripts.""" @@ -24,11 +28,31 @@ def _gtag_scripts() -> h.Node: ] +_STYLE = """ +html { + background-color: {bg_color}; +} + +.faq { + width: 100%; + color: white; + padding: 2rem 0; + background-color: black; +} +""" + + +def _style(bg_color: str) -> h.Element: + return h.style[_STYLE.replace("{bg_color}", bg_color)] + + +@with_children def base_page( - content: h.Node | None = None, + children: h.Node = None, *, extra_head: h.Node | None = None, title: str = "VoterBowl", + bg_color: str = "#cdff64", ) -> h.Element: """Render the generic structure for all pages on voterbowl.org.""" return h.html(lang="en")[ @@ -46,7 +70,12 @@ def base_page( h.script(src=static("js/htmx.min.js")), h.script(src=static("js/css-scope-inline.js")), h.script(src=static("/js/surreal.js")), + _style(bg_color), extra_head, ], - h.body[content], + h.body[ + children, + h.div(".faq")[h.div(".container")[faq(school=None)]], + footer(), + ], ] diff --git a/server/vb/components/faq.py b/server/vb/components/faq.py new file mode 100644 index 0000000..0a514ea --- /dev/null +++ b/server/vb/components/faq.py @@ -0,0 +1,109 @@ +import typing as t + +import htpy as h +import markdown +from markupsafe import Markup + +from ..models import School + +_STYLE = """ + me { + display: flex; + flex-direction: column; + } + + me h2 { + font-size: 36px; + font-weight: 440; + line-height: 130%; + margin-bottom: 1rem; + } + + me h3 { + font-weight: 600; + font-size: 18px; + line-height: 28px; + margin-top: 1rem; + } + + me p { + font-weight: 378; + font-size: 18px; + line-height: 28px; + opacity: 0.7; + } + + me a { + color: white; + cursor: pointer; + text-decoration: underline; + transition: opacity 0.2s; + } + + me a:hover { + opacity: 0.7; + transition: opacity 0.2s; + } +""" + + +def qa(q: str, a: t.Iterable[h.Node]) -> h.Element: + """Render a question and answer.""" + return h.div(".qa")[ + h.h3[q], + (h.p[aa] for aa in a), + ] + + +FAQ = markdown.markdown(""" +## F.A.Q. + +### Why should I check my voter registration status now? + +Check now to avoid any last-minute issues before the election. + +### What is the Voter Bowl? + +The Voter Bowl is a contest where college students win prizes by checking if they are registered to vote. + +The Voter Bowl is a nonprofit, nonpartisan project of [VoteAmerica](https://www.voteamerica.com/), a national leader in voter registration and participation. + +### How do I claim my gift card? + +If you win, we'll send an Amazon gift card to your student email address. + +You can redeem your gift card by typing the claim code into [Amazon.com](https://www.amazon.com/gc/redeem). + +[Read the full contest rules here](/rules). + +### What is the goal of the Voter Bowl? + +In the 2020 presidential election, 33% of college students didn’t vote. We believe a healthy democracy depends on more students voting. + +### Who's behind the Voter Bowl? + +[VoteAmerica](https://www.voteamerica.com/) runs the Voter Bowl with the generous support of donors who are passionate about boosting student voter participation. + +[Donate to VoteAmerica](https://donorbox.org/voteamerica-website?utm_medium=website&utm_source=voterbowl&utm_campaign=voterbowl&source=voterbowl) to support projects like this. + +### I have another question. + +[Contact us](mailto:info@voterbowl.org) and we'll be happy to answer it. +""") + + +def faq(school: School | None) -> h.Element: + """Render the frequently asked questions.""" + # check_now: list[h.Node] = [ + # "Check now to avoid any last minute issues before the election." + # ] + # if school is not None: + # check_now = [ + # h.a(href=reverse("vb:check", args=[school.slug]))["Check now"], + # " to avoid any last minute issues before the election.", + # ] + + return h.div[ + h.style[_STYLE], + Markup(FAQ), + ] diff --git a/server/vb/components/footer.py b/server/vb/components/footer.py new file mode 100644 index 0000000..6dc8f48 --- /dev/null +++ b/server/vb/components/footer.py @@ -0,0 +1,96 @@ +import htpy as h +from django.urls import reverse + +from .logo import VOTER_BOWL_LOGO + +_STYLE = """ +me { + background-color: black; + color: #aaa; + padding-top: 4rem; + padding-bottom: 2rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + width: 100%; +} + +@media screen and (min-width: 768px) { + me { + padding-left: 2em; + padding-right: 2rem; + } +} + +me div.center { + margin-bottom: 2em; + display: flex; + justify-content: center; + color: #fff; +} + +me div.center svg { + width: 120px !important; +} + +me div.outer { + display: flex; + flex-direction: column-reverse; + justify-content: space-between; + align-items: center; +} + +@media screen and (min-width: 768px) { + me div.outer { + flex-direction: row; + } +} + +me div.inner { + display: flex; + flex-direction: row; + gap: 1em; +} + +me a { + color: #aaa; + text-decoration: underline; +} + +me a:hover { + color: white; +} + +me .colophon { + text-align: center; + color: #888; + font-size: 0.8em; + padding-top: 1em; + padding-bottom: 3em; +} +""" + + +def footer() -> h.Element: + """Render the site-wide footer.""" + return h.footer[ + h.style[_STYLE], + h.div(".center")[VOTER_BOWL_LOGO], + h.div(".outer")[ + h.p(".copyright")["© 2024 The Voter Bowl"], + h.div(".inner")[ + h.a(href=reverse("vb:rules"), target="_blank")["Rules"], + h.a(href="https://about.voteamerica.com/privacy", target="_blank")[ + "Privacy" + ], + h.a(href="https://about.voteamerica.com/terms", target="_blank")[ + "Terms" + ], + h.a(href="mailto:info@voterbowl.org")["Contact Us"], + ], + ], + h.div(".colophon container")[ + h.p[ + "The Voter Bowl is a project of VoteAmerica, a 501(c)3 registered non-profit organization, and does not support or oppose any political candidate or party. Our EIN is 84-3442002. Donations are tax-deductible." + ] + ], + ] diff --git a/server/vb/components/home.py b/server/vb/components/home.py index 8fdea36..765ccbe 100644 --- a/server/vb/components/home.py +++ b/server/vb/components/home.py @@ -1,25 +1,298 @@ import typing as t import htpy as h +from markupsafe import Markup -from ..models import Contest +from ..models import Contest, School from .base import base_page -from .logo import voter_bowl_logo +from .logo import VOTER_BOWL_LOGO -def _ongoing_contest(ongoing_contest: Contest) -> h.Node: - pass +def _logo_img(school: School) -> h.Element: + return h.div(".logo")[ + h.img( + src=school.logo.url, + alt=f"{school.short_name} {school.mascot} logo", + ) + ] + + +_COUNTDOWN_JS = Markup(""" +(function(self) { + function countdown(self) { + // compute the deadline + const deadline = new Date(self.dataset.endAt); + const deadlineTime = deadline.getTime(); + + /** Update the countdown. */ + function updateCountdown() { + const now = new Date().getTime(); + const diff = deadlineTime - now; + + if (diff <= 0) { + clearInterval(interval); + self.innerText = "Just ended!"; + return; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + const h0digit = Math.floor(hours / 10); + const h1digit = hours % 10; + const m0digit = Math.floor(minutes / 10); + const m1digit = minutes % 10; + const s0digit = Math.floor(seconds / 10); + const s1digit = seconds % 10; + + const endsIn = `Ends in ${h0digit}${h1digit}:${m0digit}${m1digit}:${s0digit}${s1digit}`; + self.innerText = endsIn; + } + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + } + + onloadAdd(() => countdown(self)); +})(me()); +""") + + +def _ongoing_style(logo_bg_color: str) -> str: + return """ +me { + border: 3px solid black; + color: black; + font-weight: 400; + font-size: 18px; + line-height: 140%; + padding-left: 1em; + padding-right: 1em; + position: relative; +} + +me .content { + display: flex; + flex-direction: column; +} + +me .logo { + border-radius: 100%; + border: 2px solid black; + background-color: {logo_bg_color}; + overflow: hidden; + width: 60px; + height: 60px; + margin: 1.5em auto 1em auto; +} + +me .logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +me .school { + margin: 0; + font-weight: 500; + font-size: 24px; + line-height: 100%; + display: flex; + justify-content: center; +} + +me .description { + margin-bottom: 0; +} + +me .button-holder { + width: 100%; +} + +me .button-holder a { + width: 100%; +} + +/* A centered box at the top of the card */ +me .box { + position: absolute; + top: -1em; + left: 50%; + transform: translateX(-50%); + border: 3px solid black; + background-color: #cdff64; + font-weight: 600; + line-height: 100%; + letter-spacing: 4%; + min-width: 30%; + padding: 0.25rem; + text-transform: uppercase; +} +""".replace("{logo_bg_color}", logo_bg_color) + + +def _ongoing_contest(contest: Contest) -> h.Element: + return h.div[ + h.style[_ongoing_style(contest.school.logo.bg_color)], + h.div(".content")[ + _logo_img(contest.school), + h.p(".school")[contest.school.name], + h.p(".description")[ + "Check your voter registration status", + None if contest.is_giveaway else f" for a 1 in {contest.in_n} chance", + f" to win a ${contest.amount} Amazon gift card.", + ], + h.div(".button-holder")["TODO"], + ], + h.div(".box", data_end_at=contest.end_at.isoformat())[ + h.script[_COUNTDOWN_JS], "Ends in ..." + ], + ] def _ongoing_contests(contests: list[Contest]) -> h.Node: """Render a list of ongoing contests.""" if contests: - return h.div[(_ongoing_contest(contest) for contest in contests)] + return h.div(".ongoing")[(_ongoing_contest(contest) for contest in contests)] return None -def _upcoming_contests(upcoming_contests: t.Iterable[Contest]) -> h.Node: - pass +def _upcoming_style(logo_bg_color: str) -> str: + return """ +me { + border: 3px solid black; + padding: 1rem; + color: black; + font-size: 18px; + font-weight: 440; + font-variation-settings: "wght" 440; + line-height: 1; +} + +me .content { + display: flex; + align-items: center; + gap: 1em; +} + +me .logo { + border-radius: 100%; + border: 2px solid black; + background-color: {logo_bg_color}; + overflow: hidden; + width: 36px; + height: 36px; +} + +me .logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +me p { + margin: 0; +} +""".replace("{logo_bg_color}", logo_bg_color) + + +def _upcoming_contest(contest: Contest) -> h.Element: + return h.div[ + h.style[_upcoming_style(contest.school.logo.bg_color)], + h.div(".content")[ + _logo_img(contest.school), + h.p(".school")[contest.school.name], + ], + ] + + +def _upcoming_contests(contests: list[Contest]) -> h.Node: + if contests: + return [ + h.p(".coming-soon")["Coming Soon"], + h.div(".upcoming")[(_upcoming_contest(contest) for contest in contests)], + ] + return None + + +_STYLE = """ +me { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + background-color: #cdff64; + color: black; +} + +me main { + width: 100%; + text-align: center; + padding: 2rem 0; +} + +me main svg { + width: 104px; + margin: 1.5rem 0; +} + +@media screen and (min-width: 768px) { + me main svg { + width: 112px; + } +} + +me main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +me main h2 { + font-weight: 500; + font-size: 28px; + line-height: 140%; +} + +@media screen and (min-width: 768px) { + me main h2 { + font-size: 32px; + } +} + +me .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +me .ongoing { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2rem; + margin: 2rem 0; +} + +me .upcoming { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5rem; + margin: 0.5rem 0; +} + +me .coming-soon { + text-transform: uppercase; + font-weight: bold; + font-size: 20px; + line-height: 130%; + display: flex; + justify-content: center; + margin: 1.5rem 0; +} +""" def home_page( @@ -29,17 +302,22 @@ def home_page( """Render the home page for voterbowl.org.""" ongoing_contests = list(ongoing_contests) upcoming_contests = list(upcoming_contests) - return base_page( - [ - h.div[ - h.main[ - h.div("container")[ - h.div("center")[voter_bowl_logo()], - h.h2[ - "College students win prizes by checking if they are registered to vote." - ], - ] + + return base_page[ + h.div[ + h.style[_STYLE], + h.main[ + h.div(".container")[ + h.div(".center")[VOTER_BOWL_LOGO], + h.h2[ + "College students win prizes by checking if they are registered to vote." + ], + _ongoing_contests(ongoing_contests), + _upcoming_contests(upcoming_contests), + h.p["There are no contests at this time. Check back later!"] + if not ongoing_contests and not upcoming_contests + else None, ] - ] + ], ] - ) + ] diff --git a/server/vb/components/utils.py b/server/vb/components/utils.py index 72b46f4..dd2a28d 100644 --- a/server/vb/components/utils.py +++ b/server/vb/components/utils.py @@ -3,9 +3,14 @@ import htpy as h +# FUTURE use PEP 695 syntax when mypy supports it +P = t.ParamSpec("P") +C = t.TypeVar("C") +R = t.TypeVar("R", h.Element, h.Node) + @dataclass(frozen=True) -class with_children[C, **P, R]: +class with_children(t.Generic[C, P, R]): """Wrap a function to make it look more like an htpy.Element.""" _f: t.Callable[t.Concatenate[C, P], R] @@ -40,33 +45,33 @@ def list_items(children: t.Iterable[str]) -> h.Node: return [h.li[child] for child in children] -# Wrapped func that returns Element; children only -#

paragraph content

-print(card[h.p["paragraph content"]]) +# # Wrapped func that returns Element; children only +# #

paragraph content

+# print(card[h.p["paragraph content"]]) -# Wrapped func that returns Element; children + kwargs -#
content
-print(card(data_foo="bar")["content"]) +# # Wrapped func that returns Element; children + kwargs +# #
content
+# print(card(data_foo="bar")["content"]) -# Wrapped func that returns Node; children only -#
  • Neato
  • Burrito
-print(h.ul[list_items["Neato", "Burrito"]]) +# # Wrapped func that returns Node; children only +# #
  • Neato
  • Burrito
+# print(h.ul[list_items["Neato", "Burrito"]]) -# The odd duck that doesn't behave like an h.Element: -# with_children[card] -print(card) +# # The odd duck that doesn't behave like an h.Element: +# # with_children[card] +# print(card) -# Another odd duck: -# with_children[card] -print(card()) +# # Another odd duck: +# # with_children[card] +# print(card()) -if t.TYPE_CHECKING: - # h.Element - t.reveal_type(card["content"]) +# if t.TYPE_CHECKING: +# # h.Element +# t.reveal_type(card["content"]) - # h.Node - t.reveal_type(list_items["Neato", "Burrito"]) +# # h.Node +# t.reveal_type(list_items["Neato", "Burrito"]) - # with_children[...] - t.reveal_type(card) +# # with_children[...] +# t.reveal_type(card) diff --git a/server/vb/templates/home.dhtml b/server/vb/templates/home.dhtml index 00de9de..68a2567 100644 --- a/server/vb/templates/home.dhtml +++ b/server/vb/templates/home.dhtml @@ -57,22 +57,12 @@ } } - me .faq { - width: 100%; - color: white; - padding: 2rem 0; - } - me .button-holder { display: flex; justify-content: center; margin: 1.5rem 0; } - me .faq { - background-color: black; - } - me .ongoing { display: flex; flex-direction: column; diff --git a/server/vb/templates/includes/faq.dhtml b/server/vb/templates/includes/faq.dhtml index 9e3aa46..939bd7d 100644 --- a/server/vb/templates/includes/faq.dhtml +++ b/server/vb/templates/includes/faq.dhtml @@ -58,7 +58,7 @@

The Voter Bowl is a nonprofit, nonpartisan project of VoteAmerica, a - national leader in voter registration and participation. + national leader xpin voter registration and participation.

diff --git a/server/vb/views.py b/server/vb/views.py index 66e5a96..8465878 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -9,7 +9,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST -from .components import home_page +from .components.home import home_page from .models import Contest, EmailValidationLink, School from .ops import ( enter_contest, @@ -26,7 +26,6 @@ def home(request: HttpRequest) -> HttpResponse: """Render the voterbowl homepage.""" ongoing_contests = Contest.objects.ongoing().order_by("end_at") upcoming_contests = Contest.objects.upcoming().order_by("start_at") - print(home_page(ongoing_contests, upcoming_contests)) return HttpResponse(home_page(ongoing_contests, upcoming_contests)) From 85bce98aaaa861d50d25eabd1f13b2ed8042732a Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 2 May 2024 13:30:46 -0700 Subject: [PATCH 05/28] Wrap up home page conversion --- server/vb/components/button.py | 36 ++++++++++++++++++++++++++++++++++ server/vb/components/home.py | 7 ++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 server/vb/components/button.py diff --git a/server/vb/components/button.py b/server/vb/components/button.py new file mode 100644 index 0000000..059eb13 --- /dev/null +++ b/server/vb/components/button.py @@ -0,0 +1,36 @@ +import htpy as h + +from .utils import with_children + +_STYLE = """ +me { + cursor: pointer; + transition: opacity 0.2s ease-in-out; + text-transform: uppercase; + text-decoration: none; + font-weight: 600; + font-size: 18px; + line-height: 100%; + border: none; + text-align: center; + letter-spacing: 0.05em; + padding: 20px 24px; + background-color: {bg_color}; + color: {color}; +} + +me:hover { + opacity: 0.7; + transition: opacity 0.2s ease-in-out; +} +""" + + +def _style(bg_color: str, color: str) -> h.Element: + return h.style[_STYLE.replace("{bg_color}", bg_color).replace("{color}", color)] + + +@with_children +def button(children: h.Node, href: str, bg_color: str, color: str) -> h.Element: + """Render a button with the given background and text color.""" + return h.a(href=href)[_style(bg_color, color), children] diff --git a/server/vb/components/home.py b/server/vb/components/home.py index 765ccbe..a3ccac0 100644 --- a/server/vb/components/home.py +++ b/server/vb/components/home.py @@ -5,6 +5,7 @@ from ..models import Contest, School from .base import base_page +from .button import button from .logo import VOTER_BOWL_LOGO @@ -143,7 +144,11 @@ def _ongoing_contest(contest: Contest) -> h.Element: None if contest.is_giveaway else f" for a 1 in {contest.in_n} chance", f" to win a ${contest.amount} Amazon gift card.", ], - h.div(".button-holder")["TODO"], + h.div(".button-holder")[ + button( + href=contest.school.relative_url, bg_color="black", color="white" + )["Visit event"] + ], ], h.div(".box", data_end_at=contest.end_at.isoformat())[ h.script[_COUNTDOWN_JS], "Ends in ..." From 7dd9393846786bff76afdee987c15b8015c5601e Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 2 May 2024 15:00:51 -0700 Subject: [PATCH 06/28] WIP --- server/vb/components/school.py | 10 ++++++++++ server/vb/views.py | 11 ++--------- 2 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 server/vb/components/school.py diff --git a/server/vb/components/school.py b/server/vb/components/school.py new file mode 100644 index 0000000..0b379ad --- /dev/null +++ b/server/vb/components/school.py @@ -0,0 +1,10 @@ +import htpy as h + +from ..models import Contest, School + + +def school_page( + school: School, current_contest: Contest | None, past_contest: Contest | None +) -> h.Element: + """Render a school landing page.""" + return h.div diff --git a/server/vb/views.py b/server/vb/views.py index 376cb84..1a68d8e 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -10,6 +10,7 @@ from django.views.decorators.http import require_GET, require_POST from .components.home import home_page +from .components.school import school_page from .models import Contest, EmailValidationLink, School from .ops import ( enter_contest, @@ -53,15 +54,7 @@ def school(request: HttpRequest, slug: str) -> HttpResponse: return redirect("vb:home", permanent=False) current_contest = school.contests.current() past_contest = school.contests.most_recent_past() - return render( - request, - "school.dhtml", - { - "school": school, - "current_contest": current_contest, - "past_contest": past_contest, - }, - ) + return HttpResponse(school_page(school, current_contest, past_contest)) @require_GET From 879b3705f57005e74692f20756e6772402a4c257 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 2 May 2024 16:15:23 -0700 Subject: [PATCH 07/28] The school page is done. --- server/vb/components/countdown.py | 8 +++ server/vb/components/home.py | 18 ++--- server/vb/components/logo.py | 13 ++++ server/vb/components/school.py | 113 +++++++++++++++++++++++++++++- 4 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 server/vb/components/countdown.py diff --git a/server/vb/components/countdown.py b/server/vb/components/countdown.py new file mode 100644 index 0000000..7b627a4 --- /dev/null +++ b/server/vb/components/countdown.py @@ -0,0 +1,8 @@ +import htpy as h + +from ..models import Contest + + +def countdown(contest: Contest) -> h.Element: + """Render a countdown timer for the given contest.""" + return h.div["TODO"] diff --git a/server/vb/components/home.py b/server/vb/components/home.py index a3ccac0..c7319bb 100644 --- a/server/vb/components/home.py +++ b/server/vb/components/home.py @@ -3,20 +3,10 @@ import htpy as h from markupsafe import Markup -from ..models import Contest, School +from ..models import Contest from .base import base_page from .button import button -from .logo import VOTER_BOWL_LOGO - - -def _logo_img(school: School) -> h.Element: - return h.div(".logo")[ - h.img( - src=school.logo.url, - alt=f"{school.short_name} {school.mascot} logo", - ) - ] - +from .logo import VOTER_BOWL_LOGO, school_logo _COUNTDOWN_JS = Markup(""" (function(self) { @@ -137,7 +127,7 @@ def _ongoing_contest(contest: Contest) -> h.Element: return h.div[ h.style[_ongoing_style(contest.school.logo.bg_color)], h.div(".content")[ - _logo_img(contest.school), + school_logo(contest.school), h.p(".school")[contest.school.name], h.p(".description")[ "Check your voter registration status", @@ -206,7 +196,7 @@ def _upcoming_contest(contest: Contest) -> h.Element: return h.div[ h.style[_upcoming_style(contest.school.logo.bg_color)], h.div(".content")[ - _logo_img(contest.school), + school_logo(contest.school), h.p(".school")[contest.school.name], ], ] diff --git a/server/vb/components/logo.py b/server/vb/components/logo.py index 2e0bc31..b7a0717 100644 --- a/server/vb/components/logo.py +++ b/server/vb/components/logo.py @@ -1,5 +1,8 @@ +import htpy as h from markupsafe import Markup +from ..models import School + VOTER_BOWL_LOGO = Markup(""" @@ -22,3 +25,13 @@ """) + + +def school_logo(school: School) -> h.Element: + """Render a school's logo as an image element.""" + return h.div(".logo")[ + h.img( + src=school.logo.url, + alt=f"{school.short_name} {school.mascot} logo", + ) + ] diff --git a/server/vb/components/school.py b/server/vb/components/school.py index 0b379ad..2977c22 100644 --- a/server/vb/components/school.py +++ b/server/vb/components/school.py @@ -1,10 +1,121 @@ import htpy as h from ..models import Contest, School +from .base import base_page +from .button import button +from .countdown import countdown +from .logo import school_logo + +_STYLE = """ + me { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + me main { + width: 100%; + text-align: center; + padding-bottom: 2rem; + color: {color}; + background-color: {bg_color}; + } + + @media screen and (min-width: 768px) { + me main { + padding: 2rem 0; + } + } + + me main img { + height: 150px; + margin: 1.5rem 0; + } + + me main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; + } + + me main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; + } + + me .faq { + width: 100%; + color: white; + padding: 2rem 0; + } + + me .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; + } + + me .faq { + background-color: black; + } +""" + + +def _style(color: str, bg_color: str) -> h.Element: + return h.style[_STYLE.replace("{color}", color).replace("{bg_color}", bg_color)] + + +def _contest_info( + school: School, current_contest: Contest | None, past_contest: Contest | None +) -> h.Node: + if current_contest: + return h.p[ + school.short_name, + " students: check your registration status", + f"for a 1 in { current_contest.in_n } chance", + f"to win a ${current_contest.amount} Amazon gift card.", + ] + elif past_contest: + return [ + h.p[ + school.short_name, + f" students: the ${past_contest.amount} ", + "giveaway" if past_contest.is_giveaway else "contest", + " has ended.", + ], + h.p["But: it's always a good time to make sure you're ready to vote."], + ] + else: + return [ + h.p[school.short_name, " students: there's no contest right now."], + h.p["But: it's always a good time to make sure you're ready to vote."], + ] def school_page( school: School, current_contest: Contest | None, past_contest: Contest | None ) -> h.Element: """Render a school landing page.""" - return h.div + return base_page(title=f"Voter Bowl x {school.name}")[ + h.div[ + _style(bg_color=school.logo.bg_color, color=school.logo.bg_text_color), + h.main[ + h.div(".container")[ + countdown(current_contest) if current_contest else None, + school_logo(school), + h.h2["Welcome to the Voter Bowl"], + _contest_info(school, current_contest, past_contest), + h.div(".button-holder")[ + button( + href="./check/", + bg_color=school.logo.action_color, + color=school.logo.action_text_color, + )["Check my voter status"] + ], + ] + ], + ] + ] From a6363363f95a87754d323af5e7d58dddf2c0182f Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 3 May 2024 13:18:06 -0700 Subject: [PATCH 08/28] Implement large countdown timer. --- server/vb/components/countdown.py | 146 +++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/server/vb/components/countdown.py b/server/vb/components/countdown.py index 7b627a4..04b3995 100644 --- a/server/vb/components/countdown.py +++ b/server/vb/components/countdown.py @@ -1,8 +1,152 @@ import htpy as h +from markupsafe import Markup from ..models import Contest +_STYLE = """ +me { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-bottom: 0.5rem; +} + +me p { + text-transform: uppercase; +} + +me .countdown { + display: flex; + justify-content: center; + align-items: center; + font-size: 24px; + font-weight: 500; + font-family: var(--font-mono); + gap: 4px; + height: 34px !important; +} + +me .countdown span { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 27px; +} + +me .countdown span.number { + color: {number_color}; + background-color: {number_bg_color}; +} + +me .countdown span.colon { + color: {colon_color}; + background-color: transparent; +} +""" + + +_SCRIPT = h.script[ + Markup(""" +(function() { + /** + * Countdown to a deadline. + * + * @param {HTMLElement} self element containing the countdown. + * @returns {void} + */ + function countdown(self) { + // compute the deadline + const deadline = new Date(self.dataset.endAt); + const deadlineTime = deadline.getTime(); + + /** Update the countdown. */ + function updateCountdown() { + const now = new Date().getTime(); + const diff = deadlineTime - now; + + if (diff <= 0) { + clearInterval(interval); + numbers.forEach(number => number.textContent = '0'); + return; + } + + // get the number elements + const h0 = self.querySelector('[data-number=h0]'); + const h1 = self.querySelector('[data-number=h1]'); + const m0 = self.querySelector('[data-number=m0]'); + const m1 = self.querySelector('[data-number=m1]'); + const s0 = self.querySelector('[data-number=s0]'); + const s1 = self.querySelector('[data-number=s1]'); + const numbers = [h0, h1, m0, m1, s0, s1]; + + if (numbers.some(number => !number)) { + return; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + const h0digit = Math.floor(hours / 10); + const h1digit = hours % 10; + const m0digit = Math.floor(minutes / 10); + const m1digit = minutes % 10; + const s0digit = Math.floor(seconds / 10); + const s1digit = seconds % 10; + + numbers[0].innerText = h0digit.toString(); + numbers[1].innerText = h1digit.toString(); + numbers[2].innerText = m0digit.toString(); + numbers[3].innerText = m1digit.toString(); + numbers[4].innerText = s0digit.toString(); + numbers[5].innerText = s1digit.toString(); + } + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + } + + const self = me(); + onloadAdd(() => countdown(self)); +})(); +""") +] + + +def _style(number_color: str, number_bg_color: str, colon_color: str) -> h.Element: + return h.style[ + _STYLE.replace("{number_color}", number_color) + .replace("{number_bg_color}", number_bg_color) + .replace("{colon_color}", colon_color) + ] + def countdown(contest: Contest) -> h.Element: """Render a countdown timer for the given contest.""" - return h.div["TODO"] + logo = contest.school.logo + return h.div[ + _style( + number_color=logo.action_text_color, + number_bg_color=logo.action_color, + colon_color=logo.bg_text_color, + ), + h.p[ + f"${contest.amount} Amazon gift card", + h.br, + "giveaway " if contest.is_giveaway else "contest ", + "ends in:", + ], + h.div(".countdown", data_end_at=contest.end_at.isoformat())[ + _SCRIPT, + h.span(".number", data_number="h0"), + h.span(".number", data_number="h1"), + h.span(".colon")[":"], + h.span(".number", data_number="m0"), + h.span(".number", data_number="m1"), + h.span(".colon")[":"], + h.span(".number", data_number="s0"), + h.span(".number", data_number="s1"), + ], + ] From dc968a78e58ddb182beddac74d440beb734e0e53 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 3 May 2024 13:27:50 -0700 Subject: [PATCH 09/28] Fix school page bgcolor --- server/vb/components/{base.py => base_page.py} | 0 server/vb/components/{home.py => home_page.py} | 2 +- server/vb/components/{school.py => school_page.py} | 6 ++++-- server/vb/views.py | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) rename server/vb/components/{base.py => base_page.py} (100%) rename server/vb/components/{home.py => home_page.py} (99%) rename server/vb/components/{school.py => school_page.py} (95%) diff --git a/server/vb/components/base.py b/server/vb/components/base_page.py similarity index 100% rename from server/vb/components/base.py rename to server/vb/components/base_page.py diff --git a/server/vb/components/home.py b/server/vb/components/home_page.py similarity index 99% rename from server/vb/components/home.py rename to server/vb/components/home_page.py index c7319bb..ddc86da 100644 --- a/server/vb/components/home.py +++ b/server/vb/components/home_page.py @@ -4,7 +4,7 @@ from markupsafe import Markup from ..models import Contest -from .base import base_page +from .base_page import base_page from .button import button from .logo import VOTER_BOWL_LOGO, school_logo diff --git a/server/vb/components/school.py b/server/vb/components/school_page.py similarity index 95% rename from server/vb/components/school.py rename to server/vb/components/school_page.py index 2977c22..18a7758 100644 --- a/server/vb/components/school.py +++ b/server/vb/components/school_page.py @@ -1,7 +1,7 @@ import htpy as h from ..models import Contest, School -from .base import base_page +from .base_page import base_page from .button import button from .countdown import countdown from .logo import school_logo @@ -99,7 +99,9 @@ def school_page( school: School, current_contest: Contest | None, past_contest: Contest | None ) -> h.Element: """Render a school landing page.""" - return base_page(title=f"Voter Bowl x {school.name}")[ + return base_page( + title=f"Voter Bowl x {school.name}", bg_color=school.logo.bg_color + )[ h.div[ _style(bg_color=school.logo.bg_color, color=school.logo.bg_text_color), h.main[ diff --git a/server/vb/views.py b/server/vb/views.py index 1a68d8e..bddd906 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -9,8 +9,8 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST -from .components.home import home_page -from .components.school import school_page +from .components.home_page import home_page +from .components.school_page import school_page from .models import Contest, EmailValidationLink, School from .ops import ( enter_contest, From 9cccf5902f3a46a7eadaf51faab0cae6df23d8b9 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 3 May 2024 13:52:19 -0700 Subject: [PATCH 10/28] Implement check_page --- server/vb/components/base_page.py | 6 +- server/vb/components/check_page.py | 212 +++++++++++++++++++++++++++++ server/vb/views.py | 5 +- 3 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 server/vb/components/check_page.py diff --git a/server/vb/components/base_page.py b/server/vb/components/base_page.py index c2a3e59..3bdf524 100644 --- a/server/vb/components/base_page.py +++ b/server/vb/components/base_page.py @@ -53,6 +53,8 @@ def base_page( extra_head: h.Node | None = None, title: str = "VoterBowl", bg_color: str = "#cdff64", + show_faq: bool = True, + show_footer: bool = True, ) -> h.Element: """Render the generic structure for all pages on voterbowl.org.""" return h.html(lang="en")[ @@ -75,7 +77,7 @@ def base_page( ], h.body[ children, - h.div(".faq")[h.div(".container")[faq(school=None)]], - footer(), + h.div(".faq")[h.div(".container")[faq(school=None)]] if show_faq else None, + footer() if show_footer else None, ], ] diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py new file mode 100644 index 0000000..fe7c9ff --- /dev/null +++ b/server/vb/components/check_page.py @@ -0,0 +1,212 @@ +import htpy as h +from django.templatetags.static import static +from markupsafe import Markup + +from ..models import Contest, School +from .base_page import base_page +from .countdown import countdown +from .logo import school_logo + +_STYLE = """ +me { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +me main { + width: 100%; + text-align: center; + padding: 0.5rem 0; +} + +me main img { + height: 150px; + margin-bottom: -1.75rem; +} + +me main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +me main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; +} + +me .faq { + width: 100%; + color: white; + padding: 2rem 0; +} + +me .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +me .form { + width: 100%; + background-color: white; + padding: 2rem 0; +} + +me .urgency { + flex-direction: column; + gap: 1rem; +} + +@media screen and (min-width: 768px) { + me main { + padding: 2rem 0; + } + + me main img { + height: 150px; + margin: 1.5rem 0; + } + + me .urgency { + flex-direction: row; + gap: 2rem; + } +} + +me main { + position: relative; + color: {main_color}; + background-color: {main_bg_color}; +} + +me main a { + color: {main_color}; + transition: opacity 0.2s; +} + +me main a:hover { + opacity: 0.7; + transition: opacity 0.2s; +} + +me main .urgency { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +me main .fireworks { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: hidden; +} + +me main .separate { + padding-left: 1rem; +} + +me main img { + display: block; +} + +@media screen and (min-width: 768px) { + me main .urgency { + flex-direction: row; + } +} +""" + +_SCRIPT = h.script[ + Markup(""" +(function(self) { + /** + * Finalize a verify and, possibly, mint a new gift card if all is well. + * + * @param {string} firstName + * @param {string} lastName + * @param {string} email + */ + const finishVerify = (firstName, lastName, email) => { + htmx.ajax("POST", "./finish/", { + target: self.querySelector(".urgency"), + values: { + first_name: firstName, + last_name: lastName, + email: email + } + }); + }; + + window.addEventListener('VoteAmericaEvent', (event) => { + const { + data + } = event.detail; + if (data?.tool === "verify" && data?.event === "action-finish") { + setTimeout(() => { + finishVerify(data.first_name, data.last_name, data.email); + }, 500); + } + }); +})(me());""") +] + + +def _style(main_color: str, main_bg_color: str) -> h.Element: + return h.style[ + _STYLE.replace("{main_color}", main_color).replace( + "{main_bg_color}", main_bg_color + ) + ] + + +def check_page(school: School, current_contest: Contest | None) -> h.Element: + """Render a school-specific 'check voter registration' form page.""" + extra_head = [ + h.script(src=static("js/fireworks.js")), + h.script(src="https://cdn.voteamerica.com/embed/tools.js", _async=True), + ] + return base_page( + title=f"Voter Bowl x {school.name}", + bg_color=school.logo.bg_color, + extra_head=extra_head, + show_faq=False, + show_footer=False, + )[ + h.div[ + _style( + main_color=school.logo.bg_text_color, main_bg_color=school.logo.bg_color + ), + h.main[ + h.div(".container")[ + h.div(".urgency")[ + school_logo(school), + countdown(current_contest) + if current_contest + else h.div(".separate")[ + h.p["Check your voter registraiton status below."] + ], + ] + ], + h.div(".fireworks"), + ], + h.div(".form")[ + h.div(".container")[ + h.div( + ".voteamerica-embed", + data_subscriber="voterbowl", + data_tool="verify", + data_edition="college", + ) + ] + ], + ] + ] diff --git a/server/vb/views.py b/server/vb/views.py index bddd906..82e20ba 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -9,6 +9,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST +from .components.check_page import check_page from .components.home_page import home_page from .components.school_page import school_page from .models import Contest, EmailValidationLink, School @@ -66,9 +67,7 @@ def check(request: HttpRequest, slug: str) -> HttpResponse: """ school = get_object_or_404(School, slug=slug) current_contest = school.contests.current() - return render( - request, "check.dhtml", {"school": school, "current_contest": current_contest} - ) + return HttpResponse(check_page(school, current_contest)) class FinishCheckForm(forms.Form): From 21732fdfb634db38afffc500479e04edb905aa2f Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 3 May 2024 15:46:03 -0700 Subject: [PATCH 11/28] WIP --- .editorconfig | 1 + server/vb/components/check_page.py | 143 +++++++++++++++++- server/vb/components/utils.py | 41 +++-- server/vb/templates/base.dhtml | 65 -------- server/vb/templates/check.dhtml | 201 ------------------------- server/vb/templates/example.dhtml | 7 - server/vb/templates/fail_check.dhtml | 28 ---- server/vb/templates/finish_check.dhtml | 60 -------- server/vb/templates/home.dhtml | 121 --------------- server/vb/templates/school.dhtml | 120 --------------- server/vb/views.py | 37 ++--- 11 files changed, 176 insertions(+), 648 deletions(-) delete mode 100644 server/vb/templates/base.dhtml delete mode 100644 server/vb/templates/check.dhtml delete mode 100644 server/vb/templates/example.dhtml delete mode 100644 server/vb/templates/fail_check.dhtml delete mode 100644 server/vb/templates/finish_check.dhtml delete mode 100644 server/vb/templates/home.dhtml delete mode 100644 server/vb/templates/school.dhtml diff --git a/.editorconfig b/.editorconfig index d538387..f4e2f48 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,4 @@ indent_size = 2 [*.py] indent_size = 4 + diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index fe7c9ff..19d5489 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -1,11 +1,15 @@ import htpy as h +from django.conf import settings +from django.contrib.humanize.templatetags.humanize import naturaltime from django.templatetags.static import static +from django.urls import reverse from markupsafe import Markup -from ..models import Contest, School +from ..models import Contest, ContestEntry, School from .base_page import base_page from .countdown import countdown from .logo import school_logo +from .utils import Fragment, fragment _STYLE = """ me { @@ -210,3 +214,140 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: ], ] ] + + +_FAIL_CHECK_SCRIPT = h.script[ + Markup(""" + (function (self) { + const schoolName = "{{ school.short_name }}"; + const firstName = "{{ first_name }}"; + const lastName = "{{ last_name }}"; + let email = null; + let count = 0; // give up after 3 tries + while (email === null && count < 3) { + email = prompt("Sorry, but we need your {{ school.short_name }} student email to continue. Please enter it below:"); + count++; + } + if (email) { + htmx.ajax("POST", "./finish/", { + target: document.querySelector(".urgency"), + values: { + email: email, + first_name: firstName, + last_name: lastName, + school: schoolName + } + }); + } + })(me()); +""") +] + + +def fail_check_partial( + school: School, first_name: str, last_name: str, current_contest: Contest | None +) -> Fragment: + """Render a partial page for when the user's email is invalid.""" + return fragment[ + school_logo(school), + h.p[ + _FAIL_CHECK_SCRIPT, + h.b["We could not use your email"], + f". Please use your { school.short_name } student email.", + ], + ] + + +_FINISH_CHECK_STYLE = """ + me { + padding-top: 1rem; + margin-left: 0; + } + + @media screen and (min-width: 768px) { + me { + padding-top: 0; + margin-left: 1rem; + } + } +""" + +_FIREWORKS_SCRIPT = h.script[ + Markup(""" + (function(self) { + const fireworks = new Fireworks.default(document.querySelector('.fireworks')); + fireworks.start(); + setTimeout(() => fireworks.stop(), 10_000); + })(me()); +""") +] + +_SCROLL_SCRIPT = h.script[ + Markup(""" + (function(self) { + setTimeout(() => { + // scroll entire window back to top, smoothly + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }, 100); + })(me()); +""") +] + + +def finish_check_partial( + school: School, + current_contest: Contest | None, + contest_entry: ContestEntry | None, + most_recent_winner: ContestEntry | None, +) -> Fragment: + """Render a partial page for when the user has finished the check.""" + share_link: h.Node = [ + "Share this link: ", + h.a(href=reverse("vb:school", args=[school.slug]))[ + settings.BASE_HOST, "/", school.slug + ], + ] + + description: h.Node + if contest_entry and contest_entry.is_winner: + description = [ + h.b["You win!"], + f" We sent a ${contest_entry.amount_won} gift card to your school email. ", + "(Check your spam folder.)", + h.br, + h.br, + "Your friends can also win. ", + share_link, + ] + elif contest_entry: + description = [ + "Please register to vote if you haven't yet.", + h.br, + h.br, + "You didn't win a gift card.", + f" The last winner was {most_recent_winner.student.anonymized_name} {naturaltime(most_recent_winner.created_at)}" + if most_recent_winner + else None, + "Your friends can still win! ", + share_link, + ] + else: + description = [ + "Thanks for checking your voter registraiton.", + h.br, + h.br, + "Please register to vote if you haven't yet.", + ] + + return fragment[ + school_logo(school), + h.p[ + h.style[_FINISH_CHECK_STYLE], + _FIREWORKS_SCRIPT if contest_entry and contest_entry.is_winner else None, + _SCROLL_SCRIPT, + description, + ], + ] diff --git a/server/vb/components/utils.py b/server/vb/components/utils.py index dd2a28d..b5abc05 100644 --- a/server/vb/components/utils.py +++ b/server/vb/components/utils.py @@ -2,6 +2,8 @@ from dataclasses import dataclass, field, replace import htpy as h +from htpy import _iter_children as _h_iter_children +from markupsafe import Markup # FUTURE use PEP 695 syntax when mypy supports it P = t.ParamSpec("P") @@ -45,33 +47,26 @@ def list_items(children: t.Iterable[str]) -> h.Node: return [h.li[child] for child in children] -# # Wrapped func that returns Element; children only -# #

paragraph content

-# print(card[h.p["paragraph content"]]) +class Fragment: + """A fragment of HTML that can be rendered as a string.""" -# # Wrapped func that returns Element; children + kwargs -# #
content
-# print(card(data_foo="bar")["content"]) + __slots__ = ("_children",) -# # Wrapped func that returns Node; children only -# #
  • Neato
  • Burrito
-# print(h.ul[list_items["Neato", "Burrito"]]) + def __init__(self, children: h.Node) -> None: + """Initialize the fragment with the given children.""" + self._children = children -# # The odd duck that doesn't behave like an h.Element: -# # with_children[card] -# print(card) + def __getitem__(self, children: h.Node) -> t.Self: + """Return a new fragment with the given children.""" + return self.__class__(children) -# # Another odd duck: -# # with_children[card] -# print(card()) + def __str__(self) -> Markup: + """Return the fragment as a string.""" + return Markup("".join(str(x) for x in self)) + def __iter__(self): + """Iterate over the children of the fragment.""" + yield from _h_iter_children(self._children) -# if t.TYPE_CHECKING: -# # h.Element -# t.reveal_type(card["content"]) -# # h.Node -# t.reveal_type(list_items["Neato", "Burrito"]) - -# # with_children[...] -# t.reveal_type(card) +fragment = Fragment(None) diff --git a/server/vb/templates/base.dhtml b/server/vb/templates/base.dhtml deleted file mode 100644 index 0c81b01..0000000 --- a/server/vb/templates/base.dhtml +++ /dev/null @@ -1,65 +0,0 @@ -{% load django_htmx %} -{% load static %} - - - - - - - - - {% comment %} {% endcomment %} - - - - - - - {% block title %} - VoterBowl - {% endblock title %} - - - - - - - - {% django_htmx_script %} - - {% block head_extras %} - {% endblock head_extras %} - - {% if school %} - {# djlint:off #} - - {# djlint:on #} - {% endif %} - - - - {% block body %} - VoterBowl, coming soon. -
- - TODO: Add content here. -
- {% endblock body %} - - - diff --git a/server/vb/templates/check.dhtml b/server/vb/templates/check.dhtml deleted file mode 100644 index e8e67a0..0000000 --- a/server/vb/templates/check.dhtml +++ /dev/null @@ -1,201 +0,0 @@ -{% extends "base.dhtml" %} -{% load static %} - -{% block title %} - Voter Bowl x {{ school.short_name }} -{% endblock title %} - -{% block head_extras %} - - -{% endblock head_extras %} - -{% block body %} -
- -
- - -
-
- {{ school.short_name }} {{ school.mascot }} logo - {% if current_contest %} - {% include "components/countdown.dhtml" with contest=current_contest %} - {% else %} -
-

Check your voter registration status below.

-
- {% endif %} -
-
-
-
-
-
- -
-
-
-
-{% endblock body %} diff --git a/server/vb/templates/example.dhtml b/server/vb/templates/example.dhtml deleted file mode 100644 index 07e010e..0000000 --- a/server/vb/templates/example.dhtml +++ /dev/null @@ -1,7 +0,0 @@ -
- {# djlint:off #} - - {# djlint:on #} -

Hello, world!

-
diff --git a/server/vb/templates/fail_check.dhtml b/server/vb/templates/fail_check.dhtml deleted file mode 100644 index 7f02c30..0000000 --- a/server/vb/templates/fail_check.dhtml +++ /dev/null @@ -1,28 +0,0 @@ -{{ school.short_name }} {{ school.mascot }} logo -

- - We could not use your email. Please use your {{ school.short_name }} student email. -

\ No newline at end of file diff --git a/server/vb/templates/finish_check.dhtml b/server/vb/templates/finish_check.dhtml deleted file mode 100644 index 92cddcd..0000000 --- a/server/vb/templates/finish_check.dhtml +++ /dev/null @@ -1,60 +0,0 @@ -{% load humanize %} -{{ school.short_name }} {{ school.mascot }} logo -

- - {# djlint:off #} - - {# djlint:on #} - {% if contest_entry %} - {% if contest_entry.is_winner %} - You win! We sent a ${{ contest_entry.amount_won }} gift card to your school email. (Check your spam folder.) -
-
- Your friends can also win. Share this link: {{ BASE_HOST }}/{{ school.slug }} - {% else %} - Please register to vote if you haven't yet. -
-
- You didn't win a gift card. - {% if most_recent_winner %} - The last winner was {{ most_recent_winner.student.anonymized_name }} {{ most_recent_winner.created_at|naturaltime }}. - {% endif %} - Your friends can still win! Share this link: {{ BASE_HOST }}/{{ school.slug }} - {% endif %} - {% else %} - Thanks for checking your voter registration. -
-
- Please register to vote if you haven't yet. - {% endif %} -

diff --git a/server/vb/templates/home.dhtml b/server/vb/templates/home.dhtml deleted file mode 100644 index 68a2567..0000000 --- a/server/vb/templates/home.dhtml +++ /dev/null @@ -1,121 +0,0 @@ -{% extends "base.dhtml" %} -{% load static %} - -{% block title %} - Voter Bowl -{% endblock title %} - -{% block body %} - -
- -
-
-
{% include "voter_bowl_logo.svg" %}
-

College students win prizes by checking if they are registered to vote.

- {% if ongoing_contests %} -
- {% for contest in ongoing_contests %} - {% include "components/ongoing_contest.dhtml" with contest=contest %} - {% endfor %} -
- {% endif %} - {% if upcoming_contests %} -

Coming Soon

-
- {% for contest in upcoming_contests %} - {% include "components/upcoming_contest.dhtml" with contest=contest %} - {% endfor %} -
- {% endif %} - {% if not ongoing_contests and not upcoming_contests %} -

There are no contests at this time. Check back later!

- {% endif %} -
-
-
-
{% include "includes/faq.dhtml" %}
-
- {% include "includes/footer.dhtml" %} -
-{% endblock body %} diff --git a/server/vb/templates/school.dhtml b/server/vb/templates/school.dhtml deleted file mode 100644 index 31b9744..0000000 --- a/server/vb/templates/school.dhtml +++ /dev/null @@ -1,120 +0,0 @@ -{% extends "base.dhtml" %} - -{% block title %} - Voter Bowl x {{ school.short_name }} -{% endblock title %} - -{% block body %} -
- -
- - -
- {% if current_contest %} - {% include "components/countdown.dhtml" with contest=current_contest %} - {% endif %} - {{ school.short_name }} {{ school.mascot }} logo -

Welcome to the Voter Bowl

- {% if current_contest %} -

- {{ school.short_name }} students: check your registration status - {% if current_contest.in_n > 1 %}for a 1 in {{ current_contest.in_n }} chance{% endif %} - to win a ${{ current_contest.amount }} Amazon gift card. -

- {% elif past_contest %} -

- {{ school.short_name }} students: the ${{ past_contest.amount }} - {% if past_contest.is_giveaway %} - giveaway - {% else %} - contest - {% endif %} - has ended. -

-

But: it's always a good time to make sure you're ready to vote.

- {% else %} -

{{ school.short_name }} students: there's no contest right now.

-

But: it's always a good time to make sure you're ready to vote.

- {% endif %} -
- {% include "components/button.dhtml" with text="Check my voter status" href="./check/" bg_color=school.logo.action_color color=school.logo.action_text_color %} -
-
-
-
-
{% include "includes/faq.dhtml" %}
-
- {% include "includes/footer.dhtml" %} -
-{% endblock body %} diff --git a/server/vb/views.py b/server/vb/views.py index 82e20ba..2d58258 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -9,7 +9,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST -from .components.check_page import check_page +from .components.check_page import check_page, fail_check_partial, finish_check_partial from .components.home_page import home_page from .components.school_page import school_page from .models import Contest, EmailValidationLink, School @@ -115,15 +115,13 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: if not form.is_valid(): if not form.has_only_email_error(): raise PermissionDenied("Invalid") - return render( - request, - "fail_check.dhtml", - { - "school": school, - "first_name": form.cleaned_data["first_name"], - "last_name": form.cleaned_data["last_name"], - "current_contest": current_contest, - }, + return HttpResponse( + fail_check_partial( + school, + form.cleaned_data["first_name"], + form.cleaned_data["last_name"], + current_contest, + ) ) email = form.cleaned_data["email"] @@ -151,18 +149,13 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: if current_contest is not None: most_recent_winner = current_contest.most_recent_winner() - return render( - request, - "finish_check.dhtml", - { - "BASE_URL": settings.BASE_URL, - "BASE_HOST": settings.BASE_HOST, - "school": school, - "current_contest": current_contest, - "contest_entry": contest_entry, - "most_recent_winner": most_recent_winner, - "email": email, - }, + return HttpResponse( + finish_check_partial( + school, + current_contest, + contest_entry, + most_recent_winner, + ) ) From 9fa434a67e308577d3bea8cb734e96fc5466a010 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 3 May 2024 15:47:28 -0700 Subject: [PATCH 12/28] Remove more stuff --- server/vb/templates/components/button.dhtml | 25 --- .../vb/templates/components/countdown.dhtml | 144 ------------------ .../components/ongoing_contest.dhtml | 139 ----------------- .../components/upcoming_contest.dhtml | 54 ------- server/vb/templates/includes/faq.dhtml | 100 ------------ server/vb/templates/includes/footer.dhtml | 83 ---------- 6 files changed, 545 deletions(-) delete mode 100644 server/vb/templates/components/button.dhtml delete mode 100644 server/vb/templates/components/countdown.dhtml delete mode 100644 server/vb/templates/components/ongoing_contest.dhtml delete mode 100644 server/vb/templates/components/upcoming_contest.dhtml delete mode 100644 server/vb/templates/includes/faq.dhtml delete mode 100644 server/vb/templates/includes/footer.dhtml diff --git a/server/vb/templates/components/button.dhtml b/server/vb/templates/components/button.dhtml deleted file mode 100644 index 5c6f3a2..0000000 --- a/server/vb/templates/components/button.dhtml +++ /dev/null @@ -1,25 +0,0 @@ - - - {{ text }} - diff --git a/server/vb/templates/components/countdown.dhtml b/server/vb/templates/components/countdown.dhtml deleted file mode 100644 index 48f9873..0000000 --- a/server/vb/templates/components/countdown.dhtml +++ /dev/null @@ -1,144 +0,0 @@ -
- -

- ${{ contest.amount }} Amazon gift card -
- {% if current_contest.is_giveaway %} - Giveaway - {% else %} - Contest - {% endif %} - ends in: -

-
- - -   -   - : -   -   - : -   -   -
-
diff --git a/server/vb/templates/components/ongoing_contest.dhtml b/server/vb/templates/components/ongoing_contest.dhtml deleted file mode 100644 index 3c37b0f..0000000 --- a/server/vb/templates/components/ongoing_contest.dhtml +++ /dev/null @@ -1,139 +0,0 @@ -
- - -
- -

{{ contest.school.name }}

-

- Check your voter registration status - {% if not contest.is_giveaway %}for a 1 in {{ contest.in_n }} chance{% endif %} - to win a ${{ contest.amount }} Amazon gift card. -

-
- {% include "components/button.dhtml" with text="Visit event" href=contest.school.relative_url bg_color="black" color="white" %} -
-
-
- - Ends in ... -
-
diff --git a/server/vb/templates/components/upcoming_contest.dhtml b/server/vb/templates/components/upcoming_contest.dhtml deleted file mode 100644 index a81e280..0000000 --- a/server/vb/templates/components/upcoming_contest.dhtml +++ /dev/null @@ -1,54 +0,0 @@ -
- - -
- -

{{ contest.school.name }}

-
-
diff --git a/server/vb/templates/includes/faq.dhtml b/server/vb/templates/includes/faq.dhtml deleted file mode 100644 index 939bd7d..0000000 --- a/server/vb/templates/includes/faq.dhtml +++ /dev/null @@ -1,100 +0,0 @@ -
- -

F.A.Q.

-
-

Why should I check my voter registration status now?

-

- {# djlint:off #} - {% if school %}{% endif %}Check now{% if school %}{% endif %} to avoid any last-minute issues before the election. - {# djlint:on #} -

-
-
-

What is the Voter Bowl?

-

The Voter Bowl is a contest where college students win prizes by checking if they are registered to vote.

-

- The Voter Bowl is a nonprofit, nonpartisan project of - VoteAmerica, a - national leader xpin voter registration and participation. -

-
-
-

How do I claim my gift card?

-

If you win, we'll send an Amazon gift card to your student email address.

-

- You can redeem your gift card by typing the claim code into - Amazon.com. -

-

- Read the full contest rules here. -

-
-
-

What is the goal of the Voter Bowl?

-

- In the 2020 presidential election, 33% of college students didn’t vote. We believe a - healthy democracy depends on more students voting. -

-
-
-

Who's behind the Voter Bowl?

-

- VoteAmerica runs - the Voter Bowl with the generous support of donors who are passionate about - boosting student voter participation. -

-

- Donate to VoteAmerica to support projects like this. -

-
-
-

I have another question.

-

- Contact us and we'd be happy to answer it. -

-
-
diff --git a/server/vb/templates/includes/footer.dhtml b/server/vb/templates/includes/footer.dhtml deleted file mode 100644 index d440f4c..0000000 --- a/server/vb/templates/includes/footer.dhtml +++ /dev/null @@ -1,83 +0,0 @@ -{% load static %} -
- -
{% include "voter_bowl_logo.svg" %}
-
- - -
-
-

- The Voter Bowl is a project of VoteAmerica, a 501(c)3 registered non-profit organization, and does not support or oppose any political candidate or party. Our EIN is 84-3442002. Donations are tax-deductible. -

-
-
From 8bb696e73be7e77f489587f32a625ccebe3bc26b Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 3 May 2024 16:33:40 -0700 Subject: [PATCH 13/28] Minor fixes --- server/vb/components/check_page.py | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index 19d5489..51ab93f 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -189,6 +189,7 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: _style( main_color=school.logo.bg_text_color, main_bg_color=school.logo.bg_color ), + _SCRIPT, h.main[ h.div(".container")[ h.div(".urgency")[ @@ -216,16 +217,15 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: ] -_FAIL_CHECK_SCRIPT = h.script[ - Markup(""" +_FAIL_CHECK_SCRIPT = """ (function (self) { - const schoolName = "{{ school.short_name }}"; - const firstName = "{{ first_name }}"; - const lastName = "{{ last_name }}"; + const schoolName = "{school.short_name}"; + const firstName = "{first_name}"; + const lastName = "{last_name}"; let email = null; let count = 0; // give up after 3 tries while (email === null && count < 3) { - email = prompt("Sorry, but we need your {{ school.short_name }} student email to continue. Please enter it below:"); + email = prompt(`Sorry, but we need your ${schoolName} student email to continue. Please enter it below:`); count++; } if (email) { @@ -240,8 +240,17 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: }); } })(me()); -""") -] +""" + + +def _fail_check_script(school: School, first_name: str, last_name: str) -> h.Element: + return h.script[ + Markup( + _FAIL_CHECK_SCRIPT.replace("{school.short_name}", school.short_name) + .replace("{first_name}", first_name) + .replace("{last_name}", last_name) + ) + ] def fail_check_partial( @@ -251,7 +260,7 @@ def fail_check_partial( return fragment[ school_logo(school), h.p[ - _FAIL_CHECK_SCRIPT, + _fail_check_script(school, first_name, last_name), h.b["We could not use your email"], f". Please use your { school.short_name } student email.", ], @@ -316,7 +325,7 @@ def finish_check_partial( description = [ h.b["You win!"], f" We sent a ${contest_entry.amount_won} gift card to your school email. ", - "(Check your spam folder.)", + "(Check your spam folder.) ", h.br, h.br, "Your friends can also win. ", @@ -327,8 +336,8 @@ def finish_check_partial( "Please register to vote if you haven't yet.", h.br, h.br, - "You didn't win a gift card.", - f" The last winner was {most_recent_winner.student.anonymized_name} {naturaltime(most_recent_winner.created_at)}" + "You didn't win a gift card. ", + f"The last winner was {most_recent_winner.student.anonymized_name} {naturaltime(most_recent_winner.created_at)} ago." if most_recent_winner else None, "Your friends can still win! ", From 39a531470352d5e6eb414b739787cf24da871a9c Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 3 May 2024 17:20:27 -0700 Subject: [PATCH 14/28] Wrap it up, basically. --- server/vb/admin.py | 2 +- server/vb/components/validate_email_page.py | 228 ++++++++++++++++++ server/vb/templates/clipboard.svg | 3 - server/vb/templates/clipboard_check.svg | 3 - .../{components => }/logo_specimen.dhtml | 0 server/vb/templates/verify_email.dhtml | 191 --------------- server/vb/templates/voter_bowl_logo.svg | 20 -- server/vb/views.py | 16 +- 8 files changed, 231 insertions(+), 232 deletions(-) create mode 100644 server/vb/components/validate_email_page.py delete mode 100644 server/vb/templates/clipboard.svg delete mode 100644 server/vb/templates/clipboard_check.svg rename server/vb/templates/{components => }/logo_specimen.dhtml (100%) delete mode 100644 server/vb/templates/verify_email.dhtml delete mode 100644 server/vb/templates/voter_bowl_logo.svg diff --git a/server/vb/admin.py b/server/vb/admin.py index ee2e422..3180fc4 100644 --- a/server/vb/admin.py +++ b/server/vb/admin.py @@ -66,7 +66,7 @@ def render_logo_specimen(self, obj: Logo): "width": "48px", "height": "48px", } - return mark_safe(render_to_string("components/logo_specimen.dhtml", context)) + return mark_safe(render_to_string("logo_specimen.dhtml", context)) class LogoAdmin(admin.TabularInline, RenderLogoSpecimenMixin): diff --git a/server/vb/components/validate_email_page.py b/server/vb/components/validate_email_page.py new file mode 100644 index 0000000..701ad5c --- /dev/null +++ b/server/vb/components/validate_email_page.py @@ -0,0 +1,228 @@ +import htpy as h +from django.conf import settings +from django.urls import reverse +from markupsafe import Markup + +from ..models import ContestEntry, School +from .base_page import base_page +from .logo import school_logo + +_STYLE = """ +me { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +me a { + color: {main_color}; + text-decoration: underline; + transition: opacity 0.2s; +} + +me a:hover { + opacity: 0.7; + transition: opacity 0.2s; +} + +me main { + width: 100%; + text-align: center; + padding: 2rem 0; +} + +me main img { + height: 150px; + margin: 1.5rem 0; +} + +me main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +me main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; +} + +me .faq { + width: 100%; + color: white; + background-color: black; + padding: 2rem 0; +} + +me .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +me main { + color: {main_color}; + background-color: {main_bg_color}; +} + +me main h2 { + display: flex; + justify-content: center; + align-items: center; +} + +me main .hidden { + display: none; +} + +me main .code { + font-size: 0.75em; +} + +me main .clipboard, +me main .copied { + margin-left: 0.2em; + margin-top: 0.05em; + width: 0.75em; +} + +me main .clipboard { + opacity: 0.5; + cursor: pointer; + transition: opacity 0.2s; +} + +me main .clipboard:hover { + opacity: 1; + transition: opacity 0.2s; +} + +me main .copied { + opacity: 0.5; +} + +@media screen and (min-width: 768px) { + + me main .clipboard, + me main .copied { + margin-left: 0.2em; + margin-top: 0.2em; + width: 1em; + } + + + me main .code { + font-size: 1em; + } +} +""" + + +_CLIPBOARD_SVG = Markup(""" + + + +""") + +_CLIPBOARD_CHECK_SVG = Markup(""" + + + +""") + + +def _style(main_color: str, main_bg_color: str) -> h.Element: + return h.style[ + _STYLE.replace("{main_color}", main_color).replace( + "{main_bg_color}", main_bg_color + ) + ] + + +_SCRIPT = h.script[ + Markup(""" +(function(self) { + onloadAdd(() => { + const clipboard = self.querySelector(".clipboard"); + const copied = self.querySelector(".copied"); + const code = self.querySelector(".code"); + if (!clipboard || !copied || !code) { + return; + } + clipboard.addEventListener("click", () => { + navigator.clipboard.writeText(code.innerText); + // hide the `clipboard` span; show the `copied` span + // do this by adding `hidden` class to `clipboard` and + // removing it from `copied` + clipboard.classList.add("hidden"); + copied.classList.remove("hidden"); + }); + }); +})(me()); +""") +] + + +def _congrats(contest_entry: ContestEntry, claim_code: str) -> h.Node: + return [ + h.p[f"Congrats! You won a ${contest_entry.amount_won} gift card!"], + h.h2[ + h.span(".code")[claim_code], + h.span(".clipboard", title="Copy to clipboard")[_CLIPBOARD_SVG], + h.span(".copied hidden", title="Copied!")[_CLIPBOARD_CHECK_SVG], + ], + h.p[ + "To use your gift card, copy the code above and paste it into ", + h.a(href="https://www.amazon.com/gc/redeem", target="_blank")["Amazon.com"], + ".", + ], + h.p[ + "Tell your friends so they can also win! Share this link: ", + h.a( + href=reverse( + "vb:school", kwargs={"slug": contest_entry.contest.school.slug} + ) + )[settings.BASE_HOST, "/", contest_entry.contest.school.slug], + ], + ] + + +def _sorry() -> h.Node: + return [ + h.p["Sorry, ", h.b["there was an error"], ". Please try again later."], + h.p[ + "If you continue to have issues, please contact us at ", + h.a(href="mailto:info@voterbowl.org")["info@voterbowl.org"], + ".", + ], + ] + + +def validate_email_page( + school: School, + contest_entry: ContestEntry | None, + claim_code: str | None, + error: bool, +) -> h.Element: + """Render the page that a user sees after clicking on a validation link in their email.""" + return base_page( + title=f"Voter Bowl x {school.short_name}", bg_color=school.logo.bg_color + )[ + h.div[ + _style( + main_color=school.logo.bg_text_color, main_bg_color=school.logo.bg_color + ), + h.main[ + _SCRIPT, + h.div(".container")[ + school_logo(school), + _congrats(contest_entry, claim_code) + if contest_entry and claim_code + else _sorry(), + ], + ], + ], + ] diff --git a/server/vb/templates/clipboard.svg b/server/vb/templates/clipboard.svg deleted file mode 100644 index 3bea6d8..0000000 --- a/server/vb/templates/clipboard.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/server/vb/templates/clipboard_check.svg b/server/vb/templates/clipboard_check.svg deleted file mode 100644 index 66c1bc3..0000000 --- a/server/vb/templates/clipboard_check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/server/vb/templates/components/logo_specimen.dhtml b/server/vb/templates/logo_specimen.dhtml similarity index 100% rename from server/vb/templates/components/logo_specimen.dhtml rename to server/vb/templates/logo_specimen.dhtml diff --git a/server/vb/templates/verify_email.dhtml b/server/vb/templates/verify_email.dhtml deleted file mode 100644 index 6650eae..0000000 --- a/server/vb/templates/verify_email.dhtml +++ /dev/null @@ -1,191 +0,0 @@ -{% extends "base.dhtml" %} -{% load static %} - -{% block title %} - Voter Bowl x {{ school.short_name }} -{% endblock title %} - -{% block body %} - -
- - -
- - -
- {{ school.short_name }} {{ school.mascot }} logo - {% if contest_entry and claim_code %} -

Congrats! You won a ${{ contest_entry.amount_won }} gift card!

-

- {{ claim_code }} - {% include "clipboard.svg" %} - -

-

- To use your gift card, copy the code above and paste it into Amazon.com. -

-

- Tell your friends so they can also win! Share this link: {{ BASE_HOST }}/{{ school.slug }} -

- {% else %} -

- Sorry, there was an error. Please try again later. -

-

- If you continue to have issues, please contact us at info@voterbowl.org. -

- {% endif %} -
-
-
-
{% include "includes/faq.dhtml" %}
-
- {% include "includes/footer.dhtml" %} -
-{% endblock body %} diff --git a/server/vb/templates/voter_bowl_logo.svg b/server/vb/templates/voter_bowl_logo.svg deleted file mode 100644 index 2b6adce..0000000 --- a/server/vb/templates/voter_bowl_logo.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/server/vb/views.py b/server/vb/views.py index 2d58258..1b363dd 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -1,7 +1,6 @@ import logging from django import forms -from django.conf import settings from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -12,6 +11,7 @@ from .components.check_page import check_page, fail_check_partial, finish_check_partial from .components.home_page import home_page from .components.school_page import school_page +from .components.validate_email_page import validate_email_page from .models import Contest, EmailValidationLink, School from .ops import ( enter_contest, @@ -196,16 +196,4 @@ def validate_email(request: HttpRequest, slug: str, token: str) -> HttpResponse: except Exception: contest_entry, claim_code, error = None, None, True - return render( - request, - "verify_email.dhtml", - { - "BASE_URL": settings.BASE_URL, - "BASE_HOST": settings.BASE_HOST, - "school": school, - "student": link.student, - "contest_entry": contest_entry, - "claim_code": claim_code, - "error": error, - }, - ) + return HttpResponse(validate_email_page(school, contest_entry, claim_code, error)) From 197755006e728ee9e5cd72dbb718b03ab468ebd3 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 6 May 2024 16:32:12 -0700 Subject: [PATCH 15/28] That's it for Django templates, except for email. --- server/utils/email.py | 4 +- server/vb/admin.py | 12 +- server/vb/components/faq.py | 1 + server/vb/components/logo.py | 74 +++++++++- server/vb/components/rules_page.py | 107 ++++++++++++++ .../email/base/{body.dhtml => body.html} | 0 .../email/code/{body.dhtml => body.html} | 2 +- .../email/validate/{body.dhtml => body.html} | 2 +- server/vb/templates/logo_specimen.dhtml | 60 -------- server/vb/templates/rules.dhtml | 132 ------------------ server/vb/views.py | 5 +- 11 files changed, 192 insertions(+), 207 deletions(-) create mode 100644 server/vb/components/rules_page.py rename server/vb/templates/email/base/{body.dhtml => body.html} (100%) rename server/vb/templates/email/code/{body.dhtml => body.html} (94%) rename server/vb/templates/email/validate/{body.dhtml => body.html} (92%) delete mode 100644 server/vb/templates/logo_specimen.dhtml delete mode 100644 server/vb/templates/rules.dhtml diff --git a/server/utils/email.py b/server/utils/email.py index e993dd9..a88bc2b 100644 --- a/server/utils/email.py +++ b/server/utils/email.py @@ -69,7 +69,7 @@ def send_template_email( - `subject.txt`: renders the subject line - `body.txt`: renders the plain-text body - - `body.dhtml`: renders the HTML body + - `body.html`: renders the HTML body Django's template system is flexible and can load templates from just about anywhere, provided you write a plugin. But! By default, we're going to load @@ -108,7 +108,7 @@ def create_message( subject = render_to_string(f"{template_base}/subject.txt", context).strip() text = render_to_string(f"{template_base}/body.txt", context) - html = render_to_string(f"{template_base}/body.dhtml", context) + html = render_to_string(f"{template_base}/body.html", context) final_to = list(to) if settings.DEBUG_EMAIL_TO: diff --git a/server/vb/admin.py b/server/vb/admin.py index 3180fc4..a9bf6e6 100644 --- a/server/vb/admin.py +++ b/server/vb/admin.py @@ -5,13 +5,13 @@ from django.contrib import admin from django.core.files.uploadedfile import UploadedFile from django.db import models -from django.template.loader import render_to_string from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.timezone import now as django_now from server.admin import admin_site +from .components.logo import logo_specimen from .models import ( Contest, ContestEntry, @@ -60,13 +60,9 @@ def render_logo_specimen(self, obj: Logo): """Return the logo as an image.""" if obj is None: return None - - context = { - "logo": obj, - "width": "48px", - "height": "48px", - } - return mark_safe(render_to_string("logo_specimen.dhtml", context)) + # XXX str(logo_specimen) returns markupsafe.Markup, lame. + as_str = str(str(logo_specimen(obj))) + return mark_safe(as_str) class LogoAdmin(admin.TabularInline, RenderLogoSpecimenMixin): diff --git a/server/vb/components/faq.py b/server/vb/components/faq.py index 0a514ea..0ab74fa 100644 --- a/server/vb/components/faq.py +++ b/server/vb/components/faq.py @@ -94,6 +94,7 @@ def qa(q: str, a: t.Iterable[h.Node]) -> h.Element: def faq(school: School | None) -> h.Element: """Render the frequently asked questions.""" + # TODO HTPY # check_now: list[h.Node] = [ # "Check now to avoid any last minute issues before the election." # ] diff --git a/server/vb/components/logo.py b/server/vb/components/logo.py index b7a0717..6210791 100644 --- a/server/vb/components/logo.py +++ b/server/vb/components/logo.py @@ -1,7 +1,7 @@ import htpy as h from markupsafe import Markup -from ..models import School +from ..models import Logo, School VOTER_BOWL_LOGO = Markup(""" @@ -35,3 +35,75 @@ def school_logo(school: School) -> h.Element: alt=f"{school.short_name} {school.mascot} logo", ) ] + + +_LOGO_STYLE = """ +me { + display: flex; + gap: 0.5rem; +} + +me .bubble { + display: flex; + align-items: center; + overflow: hidden; + width: 48px; + height: 48px; + background-color: {logo_bg_color}; +} + +me .bubble img { + display: block; + margin: 0 auto; + max-width: 80%; + max-height: 80%; +} + +me .bg { + display: flex; + font-weight: 600; + align-items: center; + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + background-color: {logo_bg_color}; + color: {logo_bg_text_color}; +} + +me .action { + cursor: pointer; + display: flex; + font-weight: 600; + align-items: center; + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + transition: opacity 0.2s; + background-color: {logo_action_color}; + color: {logo_action_text_color}; +} + +me .action:hover { + opacity: 0.7; + transition: opacity 0.2s ease-in-out; +} +""" + + +def _style(logo: Logo) -> h.Element: + return h.style[ + _LOGO_STYLE.replace("{logo_bg_color}", logo.bg_color) + .replace("{logo_bg_text_color}", logo.bg_text_color) + .replace("{logo_action_color}", logo.action_color) + .replace("{logo_action_text_color}", logo.action_text_color) + ] + + +def logo_specimen(logo: Logo) -> h.Element: + """Render a school's logo as a specimen for our admin views.""" + return h.div[ + _style(logo), + h.div(".bubble")[h.img(src=logo.url, alt="logo")], + h.div(".bg")["text"], + h.div(".action")["action"], + ] diff --git a/server/vb/components/rules_page.py b/server/vb/components/rules_page.py new file mode 100644 index 0000000..e40a57d --- /dev/null +++ b/server/vb/components/rules_page.py @@ -0,0 +1,107 @@ +import htpy as h +import markdown +from markupsafe import Markup + +from .base_page import base_page + +_STYLE = Markup(""" +me { + font-size: 1.25em; + line-height: 150%; + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} +""") + +_RULES = markdown.markdown(""" +**OFFICIAL SWEEPSTAKES RULES: The Voter Bowl, a project of www.voteamerica.com** + +BY ENTERING THIS SWEEPSTAKES, YOU AGREE TO THESE RULES AND REGULATIONS. + +NO PURCHASE OR PAYMENT OF ANY KIND IS NECESSARY TO ENTER OR WIN. + +OPEN TO ELIGIBLE LEGAL RESIDENTS OF THE 50 UNITED STATES AND DISTRICT OF COLUMBIA EXCEPT FOR THOSE RESIDING IN MISSISSIPPI OR OREGON, WHO, AS OF THE TIME OF ENTRY, ARE AT LEAST 18 YEARS OLD. + +VOID IN PUERTO RICO, OREGON, MISSISSIPPI AND ALL JURISDICTIONS OTHER THAN THOSE STATED ABOVE AND WHERE PROHIBITED OR RESTRICTED BY LAW. + +**Introduction.** Prizes of different values will be awarded to college students with verifiable email addresses at the school where they are currently enrolled who check their voter registration status using the VoteAmerica “Check your registration status” tool during the timeframe of the contest specified on the www.voterbowl.org. Methods of entry and Official Rules are described below. + +**Sponsor.** VoteAmerica. 530 Divisadero Street PMB 126 San Francisco CA 94117 (“Sponsor”) + +Any questions, comments or complaints regarding the Sweepstakes is to be directed to Sponsor, at the address above, and not any other party. + +**Timing.** The Sweepstakes timing will be posted on the www.voterbowl.org website between April 4th, 2024 and November 6th 2024 (the “Entry Period”). Sponsor’s computer is the official time keeping device for the Sweepstakes. Mail-in entries must be postmarked on the day that a contest is running for a specific college and must include a verifiable .edu student email address. Postcards not received by the Mail-in Entry Deadline will be disqualified. Proof of mailing does not constitute proof of delivery. + +**Eligibility.** The Sweepstakes is open only to legal residents of the fifty (50) United States and the District of Columbia except for those residing in Mississippi and Oregon, who, as of the time of entry, are at least eighteen (18) years of age. Void in Puerto Rico, Mississippi, Oregon, and all other jurisdictions other than those stated above and where prohibited or restricted by law. Employees, officers, directors, contractors, and representatives of Sponsor and their respective corporate parents, subsidiaries, affiliates, advertising and promotion agencies, agents, and any other entity involved in the development or administration of the Sweepstakes (collectively, with Sponsor “Sweepstakes Entities”) as well as the immediate family (defined as spouse, parents, children, siblings, grandparents, and “steps” of each) and household members of each, whether or not related, are not eligible to enter or win the Sweepstakes. By participating, you agree to abide by these official rules (the “Official Rules”) and the decisions of Sponsor in all matters relating to the Sweepstakes, which are final and binding in all respects. Notwithstanding the foregoing, Sponsor’s volunteers are eligible to enter the Sweepstakes. + +**How to Enter.** NO PURCHASE NECESSARY AND NO ENTRY FEE, PAYMENT OR PROOF OF PURCHASE IS NECESSARY TO PARTICIPATE. + +Taking civic actions, such as verifying your voter registration status, registering to vote, or requesting a mail in ballot, is NOT required for entry. Having a valid voter registration status or being eligible to register to vote is NOT required for entry. + +There are two (2) ways to enter the Sweepstakes: + +1. **INTERNET:** Visit the Sweepstakes Website on a web browser. Complete the form provided on the Sweepstakes Website to enter. +1. **MAIL:** Mail a 3 ½” x 5” card with your name, complete valid postal address (including zip code), date of birth, telephone number, and valid .edu verifiable school e-mail address legibly, hand printed in a #10 envelope with proper postage affixed to: 530 Divisadero Street PMB 126 San Francisco CA 94117, ATTN: Voter Bowl. Maximum one (1) entry card will be accepted per stamped outer mailing envelope. The Sweepstakes Entities assume no responsibility for lost, late, incomplete, stolen, misdirected, mutilated, illegible or postage-due entries or mail, all of which will be void. No mechanically reproduced entries permitted. Illegible/incomplete entries are void. All entries become the property of Sponsor, and none will be acknowledged or returned. + +Maximum of one (1) entry per person by Internet or Mail methods, or by any combination of these methods. + +The submission of an entry is solely the responsibility of the entrant. Only eligible entries actually postmarked/received by the deadlines specified in these Official Rules will be included in the Prize drawing. Any automated receipt does not constitute proof of actual receipt by Sponsor of an entry for purposes of these Official Rules. + +Compliance with the entry requirements will be determined by Sponsor in its sole discretion. Entries that violate these entry requirements will be disqualified from the Sweepstakes. + +**Odds of Winning:** Odds of winning depend on the number of eligible entries received. + +The total ARV of all Prizes offered in this Sweepstakes is $250,000 (USD). + +Winners are subject to verification, including verification of age and residency. The Prize is neither transferable nor redeemable in cash and it must be accepted as awarded. No substitutions will be available, except at the sole discretion of Sponsor, who reserves the right to award a prize of equal or greater financial value if any advertised Prize (or any component thereof) becomes unavailable. Prize does not include any other item or expense not specifically described in these Official Rules. + +Sponsor has no responsibility for the winner’s inability or failure to accept or use any part of the Prize as described herein. + +WINNERS AGREE TO ACCEPT THE PRIZE “AS IS”, AND YOU HEREBY ACKNOWLEDGE THAT SWEEPSTAKES ENTITIES HAVE NEITHER MADE NOR ARE IN ANY MANNER RESPONSIBLE OR LIABLE FOR ANY WARRANTY, REPRESENTATION, OR GUARANTEE, EXPRESS OR IMPLIED, IN FACT OR IN LAW, RELATIVE TO THE PRIZE, INCLUDING BUT NOT LIMITED TO (A) ANY EXPRESS WARRANTIES PROVIDED EXCLUSIVELY BY A PRIZE SUPPLIER THAT ARE SENT ALONG WITH THE PRIZE OR (B) THEIR QUALITY OR MECHANIC CONDITIONS. SPONSOR HEREBY DISCLAIMS ALL IMPLIED WARRANTIES, INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NONINFRINGEMENT. + +Winner is solely responsible for all federal, state, local, or other applicable taxes associated with the acceptance and use of a Prize. All costs and expenses associated with Prize acceptance and use not specifically provided herein are the responsibility of each winner. + +**Winner Selection and Notification.** Winners will be required to verify their .edu school email address and will be notified of winning via this email address. Prizes are administered electronically using Sponsor’s computer. + +Winner is subject to verification of eligibility, including verification of age and residency. Winners under the age of 18 must receive permission from their parents or legal guardian to participate, and a parent or legal guardian must accompany them on the trip and to the concerts as the second person. + +If a Winner (i) is determined to be ineligible or otherwise disqualified by Sponsor or (ii) fails to respond to Sponsor’s selection email or text within forty-eight (48) hours of such email or text, the Winner forfeits the Prize in its entirety and a substitute Winner will be selected based upon a random drawing from among all other eligible entries received. + +Winner may be required to complete, sign, notarize and return an affidavit of eligibility/liability release and a publicity release, all of which must be properly executed and returned within three (3) days of issuance of Prize notification. If these documents are not returned properly executed, or are returned to Sponsor as undeliverable, or if any given Winner does not otherwise comply with the Officials Rules, the Prize will be forfeited and awarded to an alternate winner. + +**LIMITATIONS OF LIABILITY.** + +YOU ACKNOWLEDGE AND AGREE THAT YOU ACCESS AND USE THE SWEEPSTAKES WEBSITE AT YOUR OWN RISK. THE SWEEPSTAKES WEBSITE IS MADE AVAILABLE ON AN “AS IS” AND “WITH ALL FAULTS” BASIS, AND THE SWEEPSTAKES ENTITIES EXPRESSLY DISCLAIM ANY AND ALL WARRANTIES AND CONDITIONS OF ANY KIND, INCLUDING WITHOUT LIMITATION ALL WARRANTIES OR CONDITIONS OF MERCHANTABILITY, TITLE, QUIET ENJOYMENT, ACCURACY, NON-INFRINGEMENT, AND/OR FITNESS FOR A PARTICULAR PURPOSE. THE SWEEPSTAKES ENTITIES MAKE NO WARRANTY THAT THE SWEEPSTAKES WEBSITE WILL MEET YOUR REQUIREMENTS, WILL BE AVAILABLE ON AN UNINTERRUPTED, TIMELY, SECURE, OR ERROR-FREE BASIS, OR WILL BE ACCURATE, RELIABLE, FREE OF VIRUSES OR OTHER HARMFUL CODE, COMPLETE, LEGAL, OR SAFE. IF APPLICABLE LAW REQUIRES ANY WARRANTIES WITH RESPECT TO THE SWEEPSTAKES WEBSITE, ALL SUCH WARRANTIES ARE LIMITED IN DURATION TO NINETY (90) DAYS FROM THE DATE OF FIRST USE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO THE FOREGOING EXCLUSION MAY NOT APPLY TO YOU. SOME JURISDICTIONS DO NOT ALLOW LIMITATIONS ON HOW LONG AN IMPLIED WARRANTY LASTS, SO THE FOREGOING LIMITATION MAY NOT APPLY TO YOU. + +TO THE MAXIMUM EXTENT PERMITTED BY LAW AND NOT WITHSTANDING ANYTHING TO THE CONTRARY CONTAINED HEREIN, YOU AGREE THAT (I) SPONSOR SHALL NOT BE LIABLE TO YOU OR ANY THIRD PARTY FOR ANY INDIRECT, INCIDENTAL, CONSEQUENTIAL, EXEMPLARY, PUNITIVE OR SPECIAL DAMAGES ARISING FROM OR RELATED TO: (A) THE SWEEPSTAKES, (B) ANY PRIZE AWARDED, OR (C) YOUR USE OF INABILITY TO USE THE SWEEPSTAKES WEBSITE (IN EACH CASE EVEN IF SPONSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES) AND (II) SPONSOR’S LIABILITY TO YOU FOR ANY DAMAGES ARISING FROM OR RELATED TO THESE OFFICIAL RULES, THE SWEEPSTAKES, THE SWEEPSTAKES WEBSITE, OR ANY PRIZE WILL AT ALL TIMES BE LIMITED TO YOUR ACTUAL OUT-OF-POCKET EXPENSES OF PARTICIPATION IN THE SWEEPSTAKES (IF ANY). THE EXISTENCE OF MORE THAN ONE CLAIM WILL NOT ENLARGE THIS LIMIT. YOU AGREE THAT OUR SUPPLIERS WILL HAVE NO LIABILITY OF ANY KIND ARISING FROM OR RELATING TO THESE OFFICIAL RULES. + +By participating in the Sweepstakes, you agree to release and hold harmless Sweepstakes Entities from any liability, claims, costs, injuries, losses or damages of any kind, directly or indirectly, whether caused by negligence or not, from (i) your participation in the Sweepstakes, including, without limitation, the unauthorized or illegal access to personally identifiable or sensitive information or acceptance, possession, use, misuse, or nonuse of the Prize or any portion thereof; (ii) technical failures of any kind, including but not limited to the malfunctioning of any computer, mobile device, cable, network, hardware or software; (iii) the unavailability or inaccessibility of any telephone or Internet service; (iv) unauthorized human intervention in any part of the entry process or the Sweepstakes, or (v) electronic or human error which may occur in the administration of the Sweepstakes or the processing of entries, including, without limitation, incorrect, delayed or inaccurate transmission of winner notifications, prize claims or other information or communications relating to the Sweepstakes. In the event of any ambiguity or error(s) in these Official Rules, Sponsor reserves the right to clarify or modify these Official Rules however it deems appropriate to correct any such ambiguity or error(s). If due to an error or for any other reason, more legitimate prize claims are received than the number of prizes stated in these Official Rules, Sponsor reserves the right to award only one (1) Prize from a random drawing from among all eligible entrants. In no event will more than the stated number of prizes (i.e. one (1) Prize) be awarded. + +Sponsor reserves the right in its sole discretion to disqualify any entry or entrant from the Sweepstakes or from winning the Prize if it determines that said entrant is attempting to undermine the legitimate operation of the promotion by cheating, deception, or other unfair playing practices (including the use of automated quick entry programs) or intending to annoy, abuse, threaten or harass any other entrants or any Sweepstakes Entities. + +ANY ATTEMPT BY AN ENTRANT OR ANY OTHER INDIVIDUAL TO DELIBERATELY DAMAGE THE SWEEPSTAKES WEBSITE, TAMPER WITH THE ENTRY PROCESS, OR OTHERWISE UNDERMINE THE LEGITIMATE OPERATION OF THE SWEEPSTAKES MAY BE A VIOLATION OF LAW AND, SHOULD SUCH AN ATTEMPT BE MADE, SPONSOR RESERVES THE RIGHT TO PURSUE ALL REMEDIES AGAINST ANY SUCH INDIVIDUAL TO THE FULLEST EXTENT PERMITTED BY LAW. + +**Sponsor’s Reservation of Rights.** Sponsor’s failure to enforce any term of these Official Rules shall not constitute a waiver of that provision. If any provision of these Official Rules is held to be invalid or unenforceable, such provision shall be struck, and the remaining provisions shall be enforced. If for any reason the Sweepstakes is not capable of being safely executed as planned, including, without limitation, as a result of war, natural disasters or weather events, labor strikes, acts of terrorism, pandemic infection (including without limitation, events related to the COVID-19 virus), or other force majeure event, or any infection by computer virus, bugs, tampering, unauthorized intervention, fraud, technical failures or any other causes which in the opinion of and/or Sweepstakes Entities, corrupt or affect the administration, security, fairness, integrity, or proper conduct and fulfillment of this Sweepstakes, Sponsor reserves the right to cancel, terminate, modify or suspend the Sweepstakes. In the event of any cancellation, termination or suspension, Sponsor reserves the right to select a winner from a random drawing from among all eligible, non-suspect entries received as of the date of the termination, cancellation or suspension. + +**DISPUTES AND JURISDICTION:** THE SWEEPSTAKES IS GOVERNED BY, AND WILL BE CONSTRUED IN ACCORDANCE WITH, THE LAWS OF THE STATE OF CALIFORNIA, WITHOUT REGARD TO ITS CONFLICTS OF LAW PRINCIPLES, AND THE FORUM AND VENUE FOR ANY DISPUTE RELATING TO THE SWEEPSTAKES SHALL BE IN A FEDERAL OR STATE COURT OF COMPETENT JURISDICTION IN CALIFORNIA, CALIFORNIA. EXCEPT WHERE PROHIBITED, ENTRANTS AGREE THAT ANY AND ALL DISPUTES, CLAIMS AND CAUSES OF ACTION ARISING OUT OF OR CONNECTED WITH THIS SWEEPSTAKES OR ANY PRIZE AWARDED SHALL BE RESOLVED INDIVIDUALLY, WITHOUT RESORT TO ANY FORM OF CLASS ACTION. + +**Official Rules.** For a copy of the Official Rules, mail a self-addressed stamped envelope by first class mail to Voter Bowl, 530 Divisadero Street PMB 126 San Francisco CA 94117, Attention: Rules Department. Sweepstakes entrants are hereby authorized to copy these Official Rules on the condition that it will be for their personal use and not for any commercial purpose whatsoever. + +**Privacy.** Any personally identifiable information collected during an entrant’s participation in the Sweepstakes will be collected and used by Sponsor and its designees for the administration and fulfillment of the Sweepstakes and as otherwise described in these Official Rules and Sponsor’s privacy policy available at https://about.voteamerica.com/privacy. Should entrant register to vote, their information will be shared, as noted on the user interface, with the individual state election board in entrant’s home state, and also be added to databases of registered voters used exclusively for non-profit purposes and the purpose of encouraging voter turnout. + +**Winners List.** A winners list is available only within sixty (60) days after the end of the Entry Period. To receive a copy of the winners list, mail a request to Voter Bowl Sweepstakes Winners List, 530 Divisadero Street PMB 126 San Francisco CA 94117, Attention: Winners List. In order to obtain a winner’s list, where permitted by law, send your written request and a self-addressed, stamped envelope (residents of VT and WA may omit return postage). + +**Intellectual Property Ownership; Access to Sweepstakes Website.** You acknowledge and agree that the Sweepstakes Website and all content thereof, and all the intellectual property rights, including copyrights, patents, trademarks, and trade secrets therein, are owned by Sponsor or its licensors. Neither these Official Rules (nor your access to the Sweepstakes Website) transfers to you or any third party any rights, title or interest in or to such intellectual property rights, except for the limited, non-exclusive right to access the Sweepstakes Website for your own personal, noncommercial use. Sponsor and its suppliers reserve all rights not granted in these Official Rules. There are no implied licenses granted under these Official Rules. + +All trademarks, logos and service marks (“Marks”) displayed on the Sweepstakes Website are our property or the property of other third parties. You are not permitted to use these Marks without our prior written consent or the consent of such third party which may own the Marks. + +""") + + +def rules_page() -> h.Element: + """Render the rules page.""" + return base_page(title="Voter Bowl Rules", bg_color="white", show_faq=False)[ + h.div(".container")[ + h.style[_STYLE], + Markup(_RULES), + ] + ] diff --git a/server/vb/templates/email/base/body.dhtml b/server/vb/templates/email/base/body.html similarity index 100% rename from server/vb/templates/email/base/body.dhtml rename to server/vb/templates/email/base/body.html diff --git a/server/vb/templates/email/code/body.dhtml b/server/vb/templates/email/code/body.html similarity index 94% rename from server/vb/templates/email/code/body.dhtml rename to server/vb/templates/email/code/body.html index 89f615c..a351590 100644 --- a/server/vb/templates/email/code/body.dhtml +++ b/server/vb/templates/email/code/body.html @@ -1,4 +1,4 @@ -{% extends "email/base/body.dhtml" %} +{% extends "email/base/body.html" %} {% block message %} {% include "email/base/p.html" %} diff --git a/server/vb/templates/email/validate/body.dhtml b/server/vb/templates/email/validate/body.html similarity index 92% rename from server/vb/templates/email/validate/body.dhtml rename to server/vb/templates/email/validate/body.html index cfa34dc..9de352c 100644 --- a/server/vb/templates/email/validate/body.dhtml +++ b/server/vb/templates/email/validate/body.html @@ -1,4 +1,4 @@ -{% extends "email/base/body.dhtml" %} +{% extends "email/base/body.html" %} {% block message %} {% include "email/base/p.html" %} diff --git a/server/vb/templates/logo_specimen.dhtml b/server/vb/templates/logo_specimen.dhtml deleted file mode 100644 index f41e154..0000000 --- a/server/vb/templates/logo_specimen.dhtml +++ /dev/null @@ -1,60 +0,0 @@ -{% with width=width|default:"32px" height=height|default:"32px" %} -
- -
- TODO alt -
-
text
-
action
-
-{% endwith %} diff --git a/server/vb/templates/rules.dhtml b/server/vb/templates/rules.dhtml deleted file mode 100644 index e797f81..0000000 --- a/server/vb/templates/rules.dhtml +++ /dev/null @@ -1,132 +0,0 @@ -{% extends "base.dhtml" %} - -{% block title %} - Voter Bowl: Rules -{% endblock title %} - -{% block body %} -
- -

- OFFICIAL SWEEPSTAKES RULES: The Voter Bowl, a project of www.voteamerica.com -

-

BY ENTERING THIS SWEEPSTAKES, YOU AGREE TO THESE RULES AND REGULATIONS.

-

NO PURCHASE OR PAYMENT OF ANY KIND IS NECESSARY TO ENTER OR WIN.

-

- OPEN TO ELIGIBLE LEGAL RESIDENTS OF THE 50 UNITED STATES AND DISTRICT OF COLUMBIA EXCEPT FOR THOSE RESIDING IN MISSISSIPPI OR OREGON, WHO, AS OF THE TIME OF ENTRY, ARE AT LEAST 18 YEARS OLD. -

-

- VOID IN PUERTO RICO, OREGON, MISSISSIPPI AND ALL JURISDICTIONS OTHER THAN THOSE STATED ABOVE AND WHERE PROHIBITED OR RESTRICTED BY LAW. -

-

- Introduction. Prizes of different values will be awarded to college students with verifiable email addresses at the school where they are currently enrolled who check their voter registration status using the VoteAmerica “Check your registration status” tool during the timeframe of the contest specified on the www.voterbowl.org. Methods of entry and Official Rules are described below. -

-

- Sponsor. VoteAmerica. 530 Divisadero Street PMB 126 San Francisco CA 94117 (“Sponsor”) -

-

- Any questions, comments or complaints regarding the Sweepstakes is to be directed to Sponsor, at the address above, and not any other party. -

-

- Timing. The Sweepstakes timing will be posted on the www.voterbowl.org website between April 4th, 2024 and November 6th 2024 (the “Entry Period”). Sponsor’s computer is the official time keeping device for the Sweepstakes. Mail-in entries must be postmarked on the day that a contest is running for a specific college and must include a verifiable .edu student email address. Postcards not received by the Mail-in Entry Deadline will be disqualified. Proof of mailing does not constitute proof of delivery. -

-

- Eligibility. The Sweepstakes is open only to legal residents of the fifty (50) United States and the District of Columbia except for those residing in Mississippi and Oregon, who, as of the time of entry, are at least eighteen (18) years of age. Void in Puerto Rico, Mississippi, Oregon, and all other jurisdictions other than those stated above and where prohibited or restricted by law. Employees, officers, directors, contractors, and representatives of Sponsor and their respective corporate parents, subsidiaries, affiliates, advertising and promotion agencies, agents, and any other entity involved in the development or administration of the Sweepstakes (collectively, with Sponsor “Sweepstakes Entities”) as well as the immediate family (defined as spouse, parents, children, siblings, grandparents, and “steps” of each) and household members of each, whether or not related, are not eligible to enter or win the Sweepstakes. By participating, you agree to abide by these official rules (the “Official Rules”) and the decisions of Sponsor in all matters relating to the Sweepstakes, which are final and binding in all respects. Notwithstanding the foregoing, Sponsor’s volunteers are eligible to enter the Sweepstakes. -

-

- How to Enter. NO PURCHASE NECESSARY AND NO ENTRY FEE, PAYMENT OR PROOF OF PURCHASE IS NECESSARY TO PARTICIPATE. -

-

- Taking civic actions, such as verifying your voter registration status, registering to vote, or requesting a mail in ballot, is NOT required for entry. Having a valid voter registration status or being eligible to register to vote is NOT required for entry. -

-

There are two (2) ways to enter the Sweepstakes:

-
    -
  1. - INTERNET: Visit the Sweepstakes Website on a web browser. Complete the form provided on the Sweepstakes Website to enter. -
  2. -
  3. - MAIL: Mail a 3 ½” x 5” card with your name, complete valid postal address (including zip code), date of birth, telephone number, and valid .edu verifiable school e-mail address legibly, hand printed in a #10 envelope with proper postage affixed to: 530 Divisadero Street PMB 126 San Francisco CA 94117, ATTN: Voter Bowl. Maximum one (1) entry card will be accepted per stamped outer mailing envelope. The Sweepstakes Entities assume no responsibility for lost, late, incomplete, stolen, misdirected, mutilated, illegible or postage-due entries or mail, all of which will be void. No mechanically reproduced entries permitted. Illegible/incomplete entries are void. All entries become the property of Sponsor, and none will be acknowledged or returned. -
  4. -
-

Maximum of one (1) entry per person by Internet or Mail methods, or by any combination of these methods.

-

- The submission of an entry is solely the responsibility of the entrant. Only eligible entries actually postmarked/received by the deadlines specified in these Official Rules will be included in the Prize drawing. Any automated receipt does not constitute proof of actual receipt by Sponsor of an entry for purposes of these Official Rules. -

-

- Compliance with the entry requirements will be determined by Sponsor in its sole discretion. Entries that violate these entry requirements will be disqualified from the Sweepstakes. -

-

- Odds of Winning: Odds of winning depend on the number of eligible entries received. -

-

The total ARV of all Prizes offered in this Sweepstakes is $250,000 (USD).

-

- Winners are subject to verification, including verification of age and residency. The Prize is neither transferable nor redeemable in cash and it must be accepted as awarded. No substitutions will be available, except at the sole discretion of Sponsor, who reserves the right to award a prize of equal or greater financial value if any advertised Prize (or any component thereof) becomes unavailable. Prize does not include any other item or expense not specifically described in these Official Rules. -

-

- Sponsor has no responsibility for the winner’s inability or failure to accept or use any part of the Prize as described herein. -

-

- WINNERS AGREE TO ACCEPT THE PRIZE “AS IS”, AND YOU HEREBY ACKNOWLEDGE THAT SWEEPSTAKES ENTITIES HAVE NEITHER MADE NOR ARE IN ANY MANNER RESPONSIBLE OR LIABLE FOR ANY WARRANTY, REPRESENTATION, OR GUARANTEE, EXPRESS OR IMPLIED, IN FACT OR IN LAW, RELATIVE TO THE PRIZE, INCLUDING BUT NOT LIMITED TO (A) ANY EXPRESS WARRANTIES PROVIDED EXCLUSIVELY BY A PRIZE SUPPLIER THAT ARE SENT ALONG WITH THE PRIZE OR (B) THEIR QUALITY OR MECHANIC CONDITIONS. SPONSOR HEREBY DISCLAIMS ALL IMPLIED WARRANTIES, INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NONINFRINGEMENT. -

-

- Winner is solely responsible for all federal, state, local, or other applicable taxes associated with the acceptance and use of a Prize. All costs and expenses associated with Prize acceptance and use not specifically provided herein are the responsibility of each winner. -

-

- Winner Selection and Notification. Winners will be required to verify their .edu school email address and will be notified of winning via this email address. Prizes are administered electronically using Sponsor’s computer. -

-

- Winner is subject to verification of eligibility, including verification of age and residency. Winners under the age of 18 must receive permission from their parents or legal guardian to participate, and a parent or legal guardian must accompany them on the trip and to the concerts as the second person. -

-

- If a Winner (i) is determined to be ineligible or otherwise disqualified by Sponsor or (ii) fails to respond to Sponsor’s selection email or text within forty-eight (48) hours of such email or text, the Winner forfeits the Prize in its entirety and a substitute Winner will be selected based upon a random drawing from among all other eligible entries received. -

-

- Winner may be required to complete, sign, notarize and return an affidavit of eligibility/liability release and a publicity release, all of which must be properly executed and returned within three (3) days of issuance of Prize notification. If these documents are not returned properly executed, or are returned to Sponsor as undeliverable, or if any given Winner does not otherwise comply with the Officials Rules, the Prize will be forfeited and awarded to an alternate winner. -

-

- LIMITATIONS OF LIABILITY. -

-

- YOU ACKNOWLEDGE AND AGREE THAT YOU ACCESS AND USE THE SWEEPSTAKES WEBSITE AT YOUR OWN RISK. THE SWEEPSTAKES WEBSITE IS MADE AVAILABLE ON AN “AS IS” AND “WITH ALL FAULTS” BASIS, AND THE SWEEPSTAKES ENTITIES EXPRESSLY DISCLAIM ANY AND ALL WARRANTIES AND CONDITIONS OF ANY KIND, INCLUDING WITHOUT LIMITATION ALL WARRANTIES OR CONDITIONS OF MERCHANTABILITY, TITLE, QUIET ENJOYMENT, ACCURACY, NON-INFRINGEMENT, AND/OR FITNESS FOR A PARTICULAR PURPOSE. THE SWEEPSTAKES ENTITIES MAKE NO WARRANTY THAT THE SWEEPSTAKES WEBSITE WILL MEET YOUR REQUIREMENTS, WILL BE AVAILABLE ON AN UNINTERRUPTED, TIMELY, SECURE, OR ERROR-FREE BASIS, OR WILL BE ACCURATE, RELIABLE, FREE OF VIRUSES OR OTHER HARMFUL CODE, COMPLETE, LEGAL, OR SAFE. IF APPLICABLE LAW REQUIRES ANY WARRANTIES WITH RESPECT TO THE SWEEPSTAKES WEBSITE, ALL SUCH WARRANTIES ARE LIMITED IN DURATION TO NINETY (90) DAYS FROM THE DATE OF FIRST USE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO THE FOREGOING EXCLUSION MAY NOT APPLY TO YOU. SOME JURISDICTIONS DO NOT ALLOW LIMITATIONS ON HOW LONG AN IMPLIED WARRANTY LASTS, SO THE FOREGOING LIMITATION MAY NOT APPLY TO YOU. -

-

- TO THE MAXIMUM EXTENT PERMITTED BY LAW AND NOT WITHSTANDING ANYTHING TO THE CONTRARY CONTAINED HEREIN, YOU AGREE THAT (I) SPONSOR SHALL NOT BE LIABLE TO YOU OR ANY THIRD PARTY FOR ANY INDIRECT, INCIDENTAL, CONSEQUENTIAL, EXEMPLARY, PUNITIVE OR SPECIAL DAMAGES ARISING FROM OR RELATED TO: (A) THE SWEEPSTAKES, (B) ANY PRIZE AWARDED, OR (C) YOUR USE OF INABILITY TO USE THE SWEEPSTAKES WEBSITE (IN EACH CASE EVEN IF SPONSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES) AND (II) SPONSOR’S LIABILITY TO YOU FOR ANY DAMAGES ARISING FROM OR RELATED TO THESE OFFICIAL RULES, THE SWEEPSTAKES, THE SWEEPSTAKES WEBSITE, OR ANY PRIZE WILL AT ALL TIMES BE LIMITED TO YOUR ACTUAL OUT-OF-POCKET EXPENSES OF PARTICIPATION IN THE SWEEPSTAKES (IF ANY). THE EXISTENCE OF MORE THAN ONE CLAIM WILL NOT ENLARGE THIS LIMIT. YOU AGREE THAT OUR SUPPLIERS WILL HAVE NO LIABILITY OF ANY KIND ARISING FROM OR RELATING TO THESE OFFICIAL RULES. -

-

- By participating in the Sweepstakes, you agree to release and hold harmless Sweepstakes Entities from any liability, claims, costs, injuries, losses or damages of any kind, directly or indirectly, whether caused by negligence or not, from (i) your participation in the Sweepstakes, including, without limitation, the unauthorized or illegal access to personally identifiable or sensitive information or acceptance, possession, use, misuse, or nonuse of the Prize or any portion thereof; (ii) technical failures of any kind, including but not limited to the malfunctioning of any computer, mobile device, cable, network, hardware or software; (iii) the unavailability or inaccessibility of any telephone or Internet service; (iv) unauthorized human intervention in any part of the entry process or the Sweepstakes, or (v) electronic or human error which may occur in the administration of the Sweepstakes or the processing of entries, including, without limitation, incorrect, delayed or inaccurate transmission of winner notifications, prize claims or other information or communications relating to the Sweepstakes. In the event of any ambiguity or error(s) in these Official Rules, Sponsor reserves the right to clarify or modify these Official Rules however it deems appropriate to correct any such ambiguity or error(s). If due to an error or for any other reason, more legitimate prize claims are received than the number of prizes stated in these Official Rules, Sponsor reserves the right to award only one (1) Prize from a random drawing from among all eligible entrants. In no event will more than the stated number of prizes (i.e. one (1) Prize) be awarded. -

-

- Sponsor reserves the right in its sole discretion to disqualify any entry or entrant from the Sweepstakes or from winning the Prize if it determines that said entrant is attempting to undermine the legitimate operation of the promotion by cheating, deception, or other unfair playing practices (including the use of automated quick entry programs) or intending to annoy, abuse, threaten or harass any other entrants or any Sweepstakes Entities. -

-

- ANY ATTEMPT BY AN ENTRANT OR ANY OTHER INDIVIDUAL TO DELIBERATELY DAMAGE THE SWEEPSTAKES WEBSITE, TAMPER WITH THE ENTRY PROCESS, OR OTHERWISE UNDERMINE THE LEGITIMATE OPERATION OF THE SWEEPSTAKES MAY BE A VIOLATION OF LAW AND, SHOULD SUCH AN ATTEMPT BE MADE, SPONSOR RESERVES THE RIGHT TO PURSUE ALL REMEDIES AGAINST ANY SUCH INDIVIDUAL TO THE FULLEST EXTENT PERMITTED BY LAW. -

-

- Sponsor’s Reservation of Rights. Sponsor’s failure to enforce any term of these Official Rules shall not constitute a waiver of that provision. If any provision of these Official Rules is held to be invalid or unenforceable, such provision shall be struck, and the remaining provisions shall be enforced. If for any reason the Sweepstakes is not capable of being safely executed as planned, including, without limitation, as a result of war, natural disasters or weather events, labor strikes, acts of terrorism, pandemic infection (including without limitation, events related to the COVID-19 virus), or other force majeure event, or any infection by computer virus, bugs, tampering, unauthorized intervention, fraud, technical failures or any other causes which in the opinion of and/or Sweepstakes Entities, corrupt or affect the administration, security, fairness, integrity, or proper conduct and fulfillment of this Sweepstakes, Sponsor reserves the right to cancel, terminate, modify or suspend the Sweepstakes. In the event of any cancellation, termination or suspension, Sponsor reserves the right to select a winner from a random drawing from among all eligible, non-suspect entries received as of the date of the termination, cancellation or suspension. -

-

- DISPUTES AND JURISDICTION: THE SWEEPSTAKES IS GOVERNED BY, AND WILL BE CONSTRUED IN ACCORDANCE WITH, THE LAWS OF THE STATE OF CALIFORNIA, WITHOUT REGARD TO ITS CONFLICTS OF LAW PRINCIPLES, AND THE FORUM AND VENUE FOR ANY DISPUTE RELATING TO THE SWEEPSTAKES SHALL BE IN A FEDERAL OR STATE COURT OF COMPETENT JURISDICTION IN CALIFORNIA, CALIFORNIA. EXCEPT WHERE PROHIBITED, ENTRANTS AGREE THAT ANY AND ALL DISPUTES, CLAIMS AND CAUSES OF ACTION ARISING OUT OF OR CONNECTED WITH THIS SWEEPSTAKES OR ANY PRIZE AWARDED SHALL BE RESOLVED INDIVIDUALLY, WITHOUT RESORT TO ANY FORM OF CLASS ACTION. -

-

- Official Rules. For a copy of the Official Rules, mail a self-addressed stamped envelope by first class mail to Voter Bowl, 530 Divisadero Street PMB 126 San Francisco CA 94117, Attention: Rules Department. Sweepstakes entrants are hereby authorized to copy these Official Rules on the condition that it will be for their personal use and not for any commercial purpose whatsoever. -

-

- Privacy. Any personally identifiable information collected during an entrant’s participation in the Sweepstakes will be collected and used by Sponsor and its designees for the administration and fulfillment of the Sweepstakes and as otherwise described in these Official Rules and Sponsor’s privacy policy available at https://about.voteamerica.com/privacy. Should entrant register to vote, their information will be shared, as noted on the user interface, with the individual state election board in entrant’s home state, and also be added to databases of registered voters used exclusively for non-profit purposes and the purpose of encouraging voter turnout. -

-

- Winners List. A winners list is available only within sixty (60) days after the end of the Entry Period. To receive a copy of the winners list, mail a request to Voter Bowl Sweepstakes Winners List, 530 Divisadero Street PMB 126 San Francisco CA 94117, Attention: Winners List. In order to obtain a winner’s list, where permitted by law, send your written request and a self-addressed, stamped envelope (residents of VT and WA may omit return postage). -

-

- Intellectual Property Ownership; Access to Sweepstakes Website. You acknowledge and agree that the Sweepstakes Website and all content thereof, and all the intellectual property rights, including copyrights, patents, trademarks, and trade secrets therein, are owned by Sponsor or its licensors. Neither these Official Rules (nor your access to the Sweepstakes Website) transfers to you or any third party any rights, title or interest in or to such intellectual property rights, except for the limited, non-exclusive right to access the Sweepstakes Website for your own personal, noncommercial use. Sponsor and its suppliers reserve all rights not granted in these Official Rules. There are no implied licenses granted under these Official Rules. -

-

- All trademarks, logos and service marks (“Marks”) displayed on the Sweepstakes Website are our property or the property of other third parties. You are not permitted to use these Marks without our prior written consent or the consent of such third party which may own the Marks. -

-
-{% endblock body %} diff --git a/server/vb/views.py b/server/vb/views.py index 1b363dd..d10ffbf 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -3,13 +3,14 @@ from django import forms from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import get_object_or_404, redirect from django.utils.timezone import now as dj_now from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST from .components.check_page import check_page, fail_check_partial, finish_check_partial from .components.home_page import home_page +from .components.rules_page import rules_page from .components.school_page import school_page from .components.validate_email_page import validate_email_page from .models import Contest, EmailValidationLink, School @@ -34,7 +35,7 @@ def home(request: HttpRequest) -> HttpResponse: @require_GET def rules(request: HttpRequest) -> HttpResponse: """Render the voterbowl rules page.""" - return render(request, "rules.dhtml") + return HttpResponse(rules_page()) @require_GET From a97459a699741669e6efe2060621794b71fc37e2 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 7 May 2024 13:43:59 -0700 Subject: [PATCH 16/28] Clean up CSS, Javascript, and Markdown. --- jsconfig.json | 9 +- server/utils/components.py | 82 +++ server/vb/components/check_page.css | 115 ++++ server/vb/components/check_page.js | 40 ++ server/vb/components/check_page.py | 301 ++-------- server/vb/components/countdown.css | 40 ++ server/vb/components/countdown.js | 78 +++ server/vb/components/countdown.py | 130 +---- server/vb/components/fail_check_partial.js | 22 + server/vb/components/faq.css | 37 ++ server/vb/components/faq.md | 33 ++ server/vb/components/faq.py | 85 +-- server/vb/components/finish_check_partial.css | 11 + server/vb/components/finish_check_partial.js | 12 + server/vb/components/rules.md | 80 +++ server/vb/components/rules_page.css | 5 + server/vb/components/rules_page.py | 100 +--- server/vb/views.py | 1 - types/htmx.d.ts | 525 ++++++++++++++++++ 19 files changed, 1140 insertions(+), 566 deletions(-) create mode 100644 server/utils/components.py create mode 100644 server/vb/components/check_page.css create mode 100644 server/vb/components/check_page.js create mode 100644 server/vb/components/countdown.css create mode 100644 server/vb/components/countdown.js create mode 100644 server/vb/components/fail_check_partial.js create mode 100644 server/vb/components/faq.css create mode 100644 server/vb/components/faq.md create mode 100644 server/vb/components/finish_check_partial.css create mode 100644 server/vb/components/finish_check_partial.js create mode 100644 server/vb/components/rules.md create mode 100644 server/vb/components/rules_page.css create mode 100644 types/htmx.d.ts diff --git a/jsconfig.json b/jsconfig.json index f67ec8e..a817f43 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,13 +1,10 @@ { "compilerOptions": { "target": "ES6", - "module": "CommonJS", + "module": "None", "checkJs": true, "allowJs": true, - "noEmit": true + "lib": ["dom", "es6"] }, - "include": [ - "server/static/js/surreal.js", - "server/static/js/css-scope-inline.js" - ] + "include": ["server/**/*.js", "types/htmx.d.ts"] } diff --git a/server/utils/components.py b/server/utils/components.py new file mode 100644 index 0000000..d95ecdc --- /dev/null +++ b/server/utils/components.py @@ -0,0 +1,82 @@ +"""Utilities for working with HTML-in-python components.""" + +import json +import pathlib +import typing as t + +import htpy as h +import markdown +from markupsafe import Markup +from pydantic.alias_generators import to_camel + + +def load_file(file_name: str | pathlib.Path, markup: bool) -> str: + """Load a text file and return its contents.""" + with open(file_name, "r") as f: + text = f.read() + return Markup(text) if markup else text + + +def load_sibling_file( + base_file_name: str | pathlib.Path, file_name: str, markup: bool +) -> str: + """Load a file in the same directory as the base file.""" + return load_file(pathlib.Path(base_file_name).resolve().parent / file_name, markup) + + +def _css_vars(selector: str, /, **vars: str) -> str: + """Generate CSS variables to inject into a stylesheet.""" + as_css = "\n".join(" --{k.replace('_', '-')}: {v};" for k, v in vars.items()) + return f"{selector} {{\n{as_css}\n}}\n" + + +def style(base_file_name: str | pathlib.Path, file_name: str, **vars: str) -> h.Element: + """ + Load a CSS file in the same directory as the base file. + + In addition to the file, you can pass in CSS variables to inject into the + stylesheet. + """ + text = load_sibling_file(base_file_name, file_name, markup=True) + if vars: + text = _css_vars("me", **vars) + text + return h.style[text] + + +def js( + base_file_name: str | pathlib.Path, + file_name: str, + *, + surreal: bool = True, + **props: t.Any, +) -> h.Element: + """ + Load a JS file in the same directory as the base file. + + Return a script element. + + If `props` are provided, they are added to the script element + as data-props JSON. + + The `surreal` flag, if True, causes us to wrap the provided javascript + in an invocation wrapper. The javascript is expected to take + two arguments: `self` and `props`. + + CONSIDER: this still feels awkward to me, and I bet there's a cleaner + pattern -- our CSS pattern feels very clean to me, for instance. + """ + text = load_sibling_file(base_file_name, file_name, markup=True) + element = h.script + if props: + as_camel = {to_camel(k): v for k, v in props.items()} + as_json = json.dumps(as_camel) + element = element(data_props=as_json) + if surreal: + text = f"({text})(me(), me('script').dataset.props && JSON.parse(me('script').dataset.props))" # noqa: E501 + return element[text] + + +def markdown_html(base_file_name: str | pathlib.Path, file_name: str) -> Markup: + """Load a markdown file in the same directory as the base file.""" + text = load_sibling_file(base_file_name, file_name, markup=False) + return Markup(markdown.markdown(text)) diff --git a/server/vb/components/check_page.css b/server/vb/components/check_page.css new file mode 100644 index 0000000..18416ca --- /dev/null +++ b/server/vb/components/check_page.css @@ -0,0 +1,115 @@ +me { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +me main { + width: 100%; + text-align: center; + padding: 0.5rem 0; +} + +me main img { + height: 150px; + margin-bottom: -1.75rem; +} + +me main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +me main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; +} + +me .faq { + width: 100%; + color: white; + padding: 2rem 0; +} + +me .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +me .form { + width: 100%; + background-color: white; + padding: 2rem 0; +} + +me .urgency { + flex-direction: column; + gap: 1rem; +} + +@media screen and (min-width: 768px) { + me main { + padding: 2rem 0; + } + + me main img { + height: 150px; + margin: 1.5rem 0; + } + + me .urgency { + flex-direction: row; + gap: 2rem; + } +} + +me main { + position: relative; + color: var(--main-color); + background-color: var(--main-bg-color); +} + +me main a { + color: var(--main-color); + transition: opacity 0.2s; +} + +me main a:hover { + opacity: 0.7; + transition: opacity 0.2s; +} + +me main .urgency { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +me main .fireworks { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: hidden; +} + +me main .separate { + padding-left: 1rem; +} + +me main img { + display: block; +} + +@media screen and (min-width: 768px) { + me main .urgency { + flex-direction: row; + } +} \ No newline at end of file diff --git a/server/vb/components/check_page.js b/server/vb/components/check_page.js new file mode 100644 index 0000000..0da8ba5 --- /dev/null +++ b/server/vb/components/check_page.js @@ -0,0 +1,40 @@ +/** + * Implement the check page component. + * + * @param {HTMLElement} self + */ + +function checkPage(self) { + /** + * Finalize a verify and, possibly, mint a new gift card if all is well. + * + * @param {string} firstName + * @param {string} lastName + * @param {string} email + */ + const finishVerify = (firstName, lastName, email) => { + /** @type {HTMLElement} */ + const urgency = self.querySelector(".urgency"); + htmx.ajax("POST", "./finish/", { + target: urgency, + values: { + first_name: firstName, + last_name: lastName, + email: email + } + }); + }; + + window.addEventListener('VoteAmericaEvent', (event) => { + /** @type {object} + * @property {object} detail + */ + const objEvent = event; + const { data } = objEvent; + if (data?.tool === "verify" && data?.event === "action-finish") { + setTimeout(() => { + finishVerify(data.first_name, data.last_name, data.email); + }, 500); + } + }); +} diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index 51ab93f..335ea2a 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -3,7 +3,8 @@ from django.contrib.humanize.templatetags.humanize import naturaltime from django.templatetags.static import static from django.urls import reverse -from markupsafe import Markup + +from server.utils.components import js, style from ..models import Contest, ContestEntry, School from .base_page import base_page @@ -11,166 +12,6 @@ from .logo import school_logo from .utils import Fragment, fragment -_STYLE = """ -me { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; -} - -me main { - width: 100%; - text-align: center; - padding: 0.5rem 0; -} - -me main img { - height: 150px; - margin-bottom: -1.75rem; -} - -me main p { - font-weight: 378; - font-size: 20px; - line-height: 130%; -} - -me main h2 { - font-weight: 500; - font-size: 36px; - line-height: 120%; - text-transform: uppercase; -} - -me .faq { - width: 100%; - color: white; - padding: 2rem 0; -} - -me .button-holder { - display: flex; - justify-content: center; - margin: 1.5rem 0; -} - -me .form { - width: 100%; - background-color: white; - padding: 2rem 0; -} - -me .urgency { - flex-direction: column; - gap: 1rem; -} - -@media screen and (min-width: 768px) { - me main { - padding: 2rem 0; - } - - me main img { - height: 150px; - margin: 1.5rem 0; - } - - me .urgency { - flex-direction: row; - gap: 2rem; - } -} - -me main { - position: relative; - color: {main_color}; - background-color: {main_bg_color}; -} - -me main a { - color: {main_color}; - transition: opacity 0.2s; -} - -me main a:hover { - opacity: 0.7; - transition: opacity 0.2s; -} - -me main .urgency { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -me main .fireworks { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - overflow: hidden; -} - -me main .separate { - padding-left: 1rem; -} - -me main img { - display: block; -} - -@media screen and (min-width: 768px) { - me main .urgency { - flex-direction: row; - } -} -""" - -_SCRIPT = h.script[ - Markup(""" -(function(self) { - /** - * Finalize a verify and, possibly, mint a new gift card if all is well. - * - * @param {string} firstName - * @param {string} lastName - * @param {string} email - */ - const finishVerify = (firstName, lastName, email) => { - htmx.ajax("POST", "./finish/", { - target: self.querySelector(".urgency"), - values: { - first_name: firstName, - last_name: lastName, - email: email - } - }); - }; - - window.addEventListener('VoteAmericaEvent', (event) => { - const { - data - } = event.detail; - if (data?.tool === "verify" && data?.event === "action-finish") { - setTimeout(() => { - finishVerify(data.first_name, data.last_name, data.email); - }, 500); - } - }); -})(me());""") -] - - -def _style(main_color: str, main_bg_color: str) -> h.Element: - return h.style[ - _STYLE.replace("{main_color}", main_color).replace( - "{main_bg_color}", main_bg_color - ) - ] - def check_page(school: School, current_contest: Contest | None) -> h.Element: """Render a school-specific 'check voter registration' form page.""" @@ -186,10 +27,13 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: show_footer=False, )[ h.div[ - _style( - main_color=school.logo.bg_text_color, main_bg_color=school.logo.bg_color + style( + __file__, + "check_page.css", + main_color=school.logo.bg_text_color, + main_bg_color=school.logo.bg_color, ), - _SCRIPT, + js(__file__, "check_page.js"), h.main[ h.div(".container")[ h.div(".urgency")[ @@ -217,42 +61,6 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: ] -_FAIL_CHECK_SCRIPT = """ - (function (self) { - const schoolName = "{school.short_name}"; - const firstName = "{first_name}"; - const lastName = "{last_name}"; - let email = null; - let count = 0; // give up after 3 tries - while (email === null && count < 3) { - email = prompt(`Sorry, but we need your ${schoolName} student email to continue. Please enter it below:`); - count++; - } - if (email) { - htmx.ajax("POST", "./finish/", { - target: document.querySelector(".urgency"), - values: { - email: email, - first_name: firstName, - last_name: lastName, - school: schoolName - } - }); - } - })(me()); -""" - - -def _fail_check_script(school: School, first_name: str, last_name: str) -> h.Element: - return h.script[ - Markup( - _FAIL_CHECK_SCRIPT.replace("{school.short_name}", school.short_name) - .replace("{first_name}", first_name) - .replace("{last_name}", last_name) - ) - ] - - def fail_check_partial( school: School, first_name: str, last_name: str, current_contest: Contest | None ) -> Fragment: @@ -260,59 +68,24 @@ def fail_check_partial( return fragment[ school_logo(school), h.p[ - _fail_check_script(school, first_name, last_name), + js( + __file__, + "fail_check_partial.js", + school_name=school.short_name, + first_name=first_name, + last_name=last_name, + ), h.b["We could not use your email"], f". Please use your { school.short_name } student email.", ], ] -_FINISH_CHECK_STYLE = """ - me { - padding-top: 1rem; - margin-left: 0; - } - - @media screen and (min-width: 768px) { - me { - padding-top: 0; - margin-left: 1rem; - } - } -""" - -_FIREWORKS_SCRIPT = h.script[ - Markup(""" - (function(self) { - const fireworks = new Fireworks.default(document.querySelector('.fireworks')); - fireworks.start(); - setTimeout(() => fireworks.stop(), 10_000); - })(me()); -""") -] - -_SCROLL_SCRIPT = h.script[ - Markup(""" - (function(self) { - setTimeout(() => { - // scroll entire window back to top, smoothly - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); - }, 100); - })(me()); -""") -] - - -def finish_check_partial( +def _finish_check_description( school: School, - current_contest: Contest | None, contest_entry: ContestEntry | None, most_recent_winner: ContestEntry | None, -) -> Fragment: - """Render a partial page for when the user has finished the check.""" +) -> h.Node: share_link: h.Node = [ "Share this link: ", h.a(href=reverse("vb:school", args=[school.slug]))[ @@ -320,9 +93,8 @@ def finish_check_partial( ], ] - description: h.Node if contest_entry and contest_entry.is_winner: - description = [ + return [ h.b["You win!"], f" We sent a ${contest_entry.amount_won} gift card to your school email. ", "(Check your spam folder.) ", @@ -331,8 +103,9 @@ def finish_check_partial( "Your friends can also win. ", share_link, ] - elif contest_entry: - description = [ + + if contest_entry: + return [ "Please register to vote if you haven't yet.", h.br, h.br, @@ -343,20 +116,30 @@ def finish_check_partial( "Your friends can still win! ", share_link, ] - else: - description = [ - "Thanks for checking your voter registraiton.", - h.br, - h.br, - "Please register to vote if you haven't yet.", - ] + return [ + "Thanks for checking your voter registraiton.", + h.br, + h.br, + "Please register to vote if you haven't yet.", + ] + + +def finish_check_partial( + school: School, + contest_entry: ContestEntry | None, + most_recent_winner: ContestEntry | None, +) -> Fragment: + """Render a partial page for when the user has finished the check.""" return fragment[ school_logo(school), h.p[ - h.style[_FINISH_CHECK_STYLE], - _FIREWORKS_SCRIPT if contest_entry and contest_entry.is_winner else None, - _SCROLL_SCRIPT, - description, + style(__file__, "finish_check_partial.css"), + js( + __file__, + "finish_check_partial.js", + is_winner=contest_entry and contest_entry.is_winner, + ), + _finish_check_description(school, contest_entry, most_recent_winner), ], ] diff --git a/server/vb/components/countdown.css b/server/vb/components/countdown.css new file mode 100644 index 0000000..e90eae8 --- /dev/null +++ b/server/vb/components/countdown.css @@ -0,0 +1,40 @@ +me { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-bottom: 0.5rem; +} + +me p { + text-transform: uppercase; +} + +me .countdown { + display: flex; + justify-content: center; + align-items: center; + font-size: 24px; + font-weight: 500; + font-family: var(--font-mono); + gap: 4px; + height: 34px !important; +} + +me .countdown span { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 27px; +} + +me .countdown span.number { + color: var(--number-color); + background-color: var(--number-bg-color); +} + +me .countdown span.colon { + color: var(--colon-color); + background-color: transparent; +} \ No newline at end of file diff --git a/server/vb/components/countdown.js b/server/vb/components/countdown.js new file mode 100644 index 0000000..19f66a2 --- /dev/null +++ b/server/vb/components/countdown.js @@ -0,0 +1,78 @@ +/** + * Outer wrapper for countdown code. + * + * @param {HTMLElement} self + * @param {object} props + * @param {string} props.endAt + */ + +function countdownOuter(self, props) { + /** + * Countdown to a deadline. + * + * @param {HTMLElement} self element containing the countdown. + * @param {object} props properties of the countdown. + * @param {string} props.endAt deadline of the countdown. + * @returns {void} + */ + function countdown(self, props) { + // compute the deadline + const { endAt } = props; + const deadline = new Date(endAt); + const deadlineTime = deadline.getTime(); + + /** Update the countdown. */ + function updateCountdown() { + const now = new Date().getTime(); + const diff = deadlineTime - now; + + // get the number elements + /** @type {HTMLElement} */ + const h0 = self.querySelector('[data-number=h0]'); + /** @type {HTMLElement} */ + const h1 = self.querySelector('[data-number=h1]'); + /** @type {HTMLElement} */ + const m0 = self.querySelector('[data-number=m0]'); + /** @type {HTMLElement} */ + const m1 = self.querySelector('[data-number=m1]'); + /** @type {HTMLElement} */ + const s0 = self.querySelector('[data-number=s0]'); + /** @type {HTMLElement} */ + const s1 = self.querySelector('[data-number=s1]'); + const numbers = [h0, h1, m0, m1, s0, s1]; + + if (numbers.some(number => !number)) { + return; + } + + if (diff <= 0) { + clearInterval(interval); + numbers.forEach(number => number.textContent = '0'); + return; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + const h0digit = Math.floor(hours / 10); + const h1digit = hours % 10; + const m0digit = Math.floor(minutes / 10); + const m1digit = minutes % 10; + const s0digit = Math.floor(seconds / 10); + const s1digit = seconds % 10; + + numbers[0].innerText = h0digit.toString(); + numbers[1].innerText = h1digit.toString(); + numbers[2].innerText = m0digit.toString(); + numbers[3].innerText = m1digit.toString(); + numbers[4].innerText = s0digit.toString(); + numbers[5].innerText = s1digit.toString(); + } + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + } + + onloadAdd(() => countdown(self, props)); +} diff --git a/server/vb/components/countdown.py b/server/vb/components/countdown.py index 04b3995..34a0f58 100644 --- a/server/vb/components/countdown.py +++ b/server/vb/components/countdown.py @@ -1,133 +1,17 @@ import htpy as h -from markupsafe import Markup -from ..models import Contest - -_STYLE = """ -me { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding-bottom: 0.5rem; -} - -me p { - text-transform: uppercase; -} - -me .countdown { - display: flex; - justify-content: center; - align-items: center; - font-size: 24px; - font-weight: 500; - font-family: var(--font-mono); - gap: 4px; - height: 34px !important; -} - -me .countdown span { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - width: 27px; -} - -me .countdown span.number { - color: {number_color}; - background-color: {number_bg_color}; -} - -me .countdown span.colon { - color: {colon_color}; - background-color: transparent; -} -""" - - -_SCRIPT = h.script[ - Markup(""" -(function() { - /** - * Countdown to a deadline. - * - * @param {HTMLElement} self element containing the countdown. - * @returns {void} - */ - function countdown(self) { - // compute the deadline - const deadline = new Date(self.dataset.endAt); - const deadlineTime = deadline.getTime(); - - /** Update the countdown. */ - function updateCountdown() { - const now = new Date().getTime(); - const diff = deadlineTime - now; - - if (diff <= 0) { - clearInterval(interval); - numbers.forEach(number => number.textContent = '0'); - return; - } +from server.utils.components import js, style - // get the number elements - const h0 = self.querySelector('[data-number=h0]'); - const h1 = self.querySelector('[data-number=h1]'); - const m0 = self.querySelector('[data-number=m0]'); - const m1 = self.querySelector('[data-number=m1]'); - const s0 = self.querySelector('[data-number=s0]'); - const s1 = self.querySelector('[data-number=s1]'); - const numbers = [h0, h1, m0, m1, s0, s1]; - - if (numbers.some(number => !number)) { - return; - } - - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - - const h0digit = Math.floor(hours / 10); - const h1digit = hours % 10; - const m0digit = Math.floor(minutes / 10); - const m1digit = minutes % 10; - const s0digit = Math.floor(seconds / 10); - const s1digit = seconds % 10; - - numbers[0].innerText = h0digit.toString(); - numbers[1].innerText = h1digit.toString(); - numbers[2].innerText = m0digit.toString(); - numbers[3].innerText = m1digit.toString(); - numbers[4].innerText = s0digit.toString(); - numbers[5].innerText = s1digit.toString(); - } - - updateCountdown(); - const interval = setInterval(updateCountdown, 1000); - } - - const self = me(); - onloadAdd(() => countdown(self)); -})(); -""") -] - - -def _style(number_color: str, number_bg_color: str, colon_color: str) -> h.Element: - return h.style[ - _STYLE.replace("{number_color}", number_color) - .replace("{number_bg_color}", number_bg_color) - .replace("{colon_color}", colon_color) - ] +from ..models import Contest def countdown(contest: Contest) -> h.Element: """Render a countdown timer for the given contest.""" logo = contest.school.logo return h.div[ - _style( + style( + __file__, + "countdown.css", number_color=logo.action_text_color, number_bg_color=logo.action_color, colon_color=logo.bg_text_color, @@ -138,8 +22,8 @@ def countdown(contest: Contest) -> h.Element: "giveaway " if contest.is_giveaway else "contest ", "ends in:", ], - h.div(".countdown", data_end_at=contest.end_at.isoformat())[ - _SCRIPT, + h.div(".countdown")[ + js(__file__, "countdown.js", end_at=contest.end_at.isoformat()), h.span(".number", data_number="h0"), h.span(".number", data_number="h1"), h.span(".colon")[":"], diff --git a/server/vb/components/fail_check_partial.js b/server/vb/components/fail_check_partial.js new file mode 100644 index 0000000..0a5ae54 --- /dev/null +++ b/server/vb/components/fail_check_partial.js @@ -0,0 +1,22 @@ +function failCheckPartial(self, props) { + const { schoolName, firstName, lastName } = props; + + let email = null; + let count = 0; // give up after 3 tries + while (email === null && count < 3) { + email = prompt(`Sorry, but we need your ${schoolName} student email to continue. Please enter it below:`); + count++; + } + + if (email) { + htmx.ajax("POST", "./finish/", { + target: document.querySelector(".urgency"), + values: { + email: email, + first_name: firstName, + last_name: lastName, + school: schoolName + } + }); + } +} diff --git a/server/vb/components/faq.css b/server/vb/components/faq.css new file mode 100644 index 0000000..d3131a9 --- /dev/null +++ b/server/vb/components/faq.css @@ -0,0 +1,37 @@ +me { + display: flex; + flex-direction: column; +} + +me h2 { + font-size: 36px; + font-weight: 440; + line-height: 130%; + margin-bottom: 1rem; +} + +me h3 { + font-weight: 600; + font-size: 18px; + line-height: 28px; + margin-top: 1rem; +} + +me p { + font-weight: 378; + font-size: 18px; + line-height: 28px; + opacity: 0.7; +} + +me a { + color: white; + cursor: pointer; + text-decoration: underline; + transition: opacity 0.2s; +} + +me a:hover { + opacity: 0.7; + transition: opacity 0.2s; +} diff --git a/server/vb/components/faq.md b/server/vb/components/faq.md new file mode 100644 index 0000000..6381c8f --- /dev/null +++ b/server/vb/components/faq.md @@ -0,0 +1,33 @@ +## F.A.Q. + +### Why should I check my voter registration status now? + +Check now to avoid any last-minute issues before the election. + +### What is the Voter Bowl? + +The Voter Bowl is a contest where college students win prizes by checking if they are registered to vote. + +The Voter Bowl is a nonprofit, nonpartisan project of [VoteAmerica](https://www.voteamerica.com/), a national leader in voter registration and participation. + +### How do I claim my gift card? + +If you win, we'll send an Amazon gift card to your student email address. + +You can redeem your gift card by typing the claim code into [Amazon.com](https://www.amazon.com/gc/redeem). + +[Read the full contest rules here](/rules). + +### What is the goal of the Voter Bowl? + +In the 2020 presidential election, 33% of college students didn’t vote. We believe a healthy democracy depends on more students voting. + +### Who's behind the Voter Bowl? + +[VoteAmerica](https://www.voteamerica.com/) runs the Voter Bowl with the generous support of donors who are passionate about boosting student voter participation. + +[Donate to VoteAmerica](https://donorbox.org/voteamerica-website?utm_medium=website&utm_source=voterbowl&utm_campaign=voterbowl&source=voterbowl) to support projects like this. + +### I have another question. + +[Contact us](mailto:info@voterbowl.org) and we'll be happy to answer it. diff --git a/server/vb/components/faq.py b/server/vb/components/faq.py index 0ab74fa..81f7f32 100644 --- a/server/vb/components/faq.py +++ b/server/vb/components/faq.py @@ -1,50 +1,10 @@ import typing as t import htpy as h -import markdown -from markupsafe import Markup -from ..models import School - -_STYLE = """ - me { - display: flex; - flex-direction: column; - } - - me h2 { - font-size: 36px; - font-weight: 440; - line-height: 130%; - margin-bottom: 1rem; - } - - me h3 { - font-weight: 600; - font-size: 18px; - line-height: 28px; - margin-top: 1rem; - } - - me p { - font-weight: 378; - font-size: 18px; - line-height: 28px; - opacity: 0.7; - } +from server.utils.components import markdown_html, style - me a { - color: white; - cursor: pointer; - text-decoration: underline; - transition: opacity 0.2s; - } - - me a:hover { - opacity: 0.7; - transition: opacity 0.2s; - } -""" +from ..models import School def qa(q: str, a: t.Iterable[h.Node]) -> h.Element: @@ -55,43 +15,6 @@ def qa(q: str, a: t.Iterable[h.Node]) -> h.Element: ] -FAQ = markdown.markdown(""" -## F.A.Q. - -### Why should I check my voter registration status now? - -Check now to avoid any last-minute issues before the election. - -### What is the Voter Bowl? - -The Voter Bowl is a contest where college students win prizes by checking if they are registered to vote. - -The Voter Bowl is a nonprofit, nonpartisan project of [VoteAmerica](https://www.voteamerica.com/), a national leader in voter registration and participation. - -### How do I claim my gift card? - -If you win, we'll send an Amazon gift card to your student email address. - -You can redeem your gift card by typing the claim code into [Amazon.com](https://www.amazon.com/gc/redeem). - -[Read the full contest rules here](/rules). - -### What is the goal of the Voter Bowl? - -In the 2020 presidential election, 33% of college students didn’t vote. We believe a healthy democracy depends on more students voting. - -### Who's behind the Voter Bowl? - -[VoteAmerica](https://www.voteamerica.com/) runs the Voter Bowl with the generous support of donors who are passionate about boosting student voter participation. - -[Donate to VoteAmerica](https://donorbox.org/voteamerica-website?utm_medium=website&utm_source=voterbowl&utm_campaign=voterbowl&source=voterbowl) to support projects like this. - -### I have another question. - -[Contact us](mailto:info@voterbowl.org) and we'll be happy to answer it. -""") - - def faq(school: School | None) -> h.Element: """Render the frequently asked questions.""" # TODO HTPY @@ -105,6 +28,6 @@ def faq(school: School | None) -> h.Element: # ] return h.div[ - h.style[_STYLE], - Markup(FAQ), + style(__file__, "faq.css"), + markdown_html(__file__, "faq.md"), ] diff --git a/server/vb/components/finish_check_partial.css b/server/vb/components/finish_check_partial.css new file mode 100644 index 0000000..e957293 --- /dev/null +++ b/server/vb/components/finish_check_partial.css @@ -0,0 +1,11 @@ +me { + padding-top: 1rem; + margin-left: 0; +} + +@media screen and (min-width: 768px) { + me { + padding-top: 0; + margin-left: 1rem; + } +} diff --git a/server/vb/components/finish_check_partial.js b/server/vb/components/finish_check_partial.js new file mode 100644 index 0000000..7392ab2 --- /dev/null +++ b/server/vb/components/finish_check_partial.js @@ -0,0 +1,12 @@ +function finishCheckPartial(self, props) { + const { isWinner } = props; + + if (isWinner) { + const fireworks = new Fireworks.default(document.querySelector(".fireworks")); + fireworks.start(); + setTimeout(() => fireworks.stop(), 10_0000); + } + + // smoothly scroll to the top of the page after a slight delay + setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 100); +} diff --git a/server/vb/components/rules.md b/server/vb/components/rules.md new file mode 100644 index 0000000..dcc4d06 --- /dev/null +++ b/server/vb/components/rules.md @@ -0,0 +1,80 @@ +**OFFICIAL SWEEPSTAKES RULES: The Voter Bowl, a project of www.voteamerica.com** + +BY ENTERING THIS SWEEPSTAKES, YOU AGREE TO THESE RULES AND REGULATIONS. + +NO PURCHASE OR PAYMENT OF ANY KIND IS NECESSARY TO ENTER OR WIN. + +OPEN TO ELIGIBLE LEGAL RESIDENTS OF THE 50 UNITED STATES AND DISTRICT OF COLUMBIA EXCEPT FOR THOSE RESIDING IN MISSISSIPPI OR OREGON, WHO, AS OF THE TIME OF ENTRY, ARE AT LEAST 18 YEARS OLD. + +VOID IN PUERTO RICO, OREGON, MISSISSIPPI AND ALL JURISDICTIONS OTHER THAN THOSE STATED ABOVE AND WHERE PROHIBITED OR RESTRICTED BY LAW. + +**Introduction.** Prizes of different values will be awarded to college students with verifiable email addresses at the school where they are currently enrolled who check their voter registration status using the VoteAmerica “Check your registration status” tool during the timeframe of the contest specified on the www.voterbowl.org. Methods of entry and Official Rules are described below. + +**Sponsor.** VoteAmerica. 530 Divisadero Street PMB 126 San Francisco CA 94117 (“Sponsor”) + +Any questions, comments or complaints regarding the Sweepstakes is to be directed to Sponsor, at the address above, and not any other party. + +**Timing.** The Sweepstakes timing will be posted on the www.voterbowl.org website between April 4th, 2024 and November 6th 2024 (the “Entry Period”). Sponsor’s computer is the official time keeping device for the Sweepstakes. Mail-in entries must be postmarked on the day that a contest is running for a specific college and must include a verifiable .edu student email address. Postcards not received by the Mail-in Entry Deadline will be disqualified. Proof of mailing does not constitute proof of delivery. + +**Eligibility.** The Sweepstakes is open only to legal residents of the fifty (50) United States and the District of Columbia except for those residing in Mississippi and Oregon, who, as of the time of entry, are at least eighteen (18) years of age. Void in Puerto Rico, Mississippi, Oregon, and all other jurisdictions other than those stated above and where prohibited or restricted by law. Employees, officers, directors, contractors, and representatives of Sponsor and their respective corporate parents, subsidiaries, affiliates, advertising and promotion agencies, agents, and any other entity involved in the development or administration of the Sweepstakes (collectively, with Sponsor “Sweepstakes Entities”) as well as the immediate family (defined as spouse, parents, children, siblings, grandparents, and “steps” of each) and household members of each, whether or not related, are not eligible to enter or win the Sweepstakes. By participating, you agree to abide by these official rules (the “Official Rules”) and the decisions of Sponsor in all matters relating to the Sweepstakes, which are final and binding in all respects. Notwithstanding the foregoing, Sponsor’s volunteers are eligible to enter the Sweepstakes. + +**How to Enter.** NO PURCHASE NECESSARY AND NO ENTRY FEE, PAYMENT OR PROOF OF PURCHASE IS NECESSARY TO PARTICIPATE. + +Taking civic actions, such as verifying your voter registration status, registering to vote, or requesting a mail in ballot, is NOT required for entry. Having a valid voter registration status or being eligible to register to vote is NOT required for entry. + +There are two (2) ways to enter the Sweepstakes: + +1. **INTERNET:** Visit the Sweepstakes Website on a web browser. Complete the form provided on the Sweepstakes Website to enter. +1. **MAIL:** Mail a 3 ½” x 5” card with your name, complete valid postal address (including zip code), date of birth, telephone number, and valid .edu verifiable school e-mail address legibly, hand printed in a #10 envelope with proper postage affixed to: 530 Divisadero Street PMB 126 San Francisco CA 94117, ATTN: Voter Bowl. Maximum one (1) entry card will be accepted per stamped outer mailing envelope. The Sweepstakes Entities assume no responsibility for lost, late, incomplete, stolen, misdirected, mutilated, illegible or postage-due entries or mail, all of which will be void. No mechanically reproduced entries permitted. Illegible/incomplete entries are void. All entries become the property of Sponsor, and none will be acknowledged or returned. + +Maximum of one (1) entry per person by Internet or Mail methods, or by any combination of these methods. + +The submission of an entry is solely the responsibility of the entrant. Only eligible entries actually postmarked/received by the deadlines specified in these Official Rules will be included in the Prize drawing. Any automated receipt does not constitute proof of actual receipt by Sponsor of an entry for purposes of these Official Rules. + +Compliance with the entry requirements will be determined by Sponsor in its sole discretion. Entries that violate these entry requirements will be disqualified from the Sweepstakes. + +**Odds of Winning:** Odds of winning depend on the number of eligible entries received. + +The total ARV of all Prizes offered in this Sweepstakes is $250,000 (USD). + +Winners are subject to verification, including verification of age and residency. The Prize is neither transferable nor redeemable in cash and it must be accepted as awarded. No substitutions will be available, except at the sole discretion of Sponsor, who reserves the right to award a prize of equal or greater financial value if any advertised Prize (or any component thereof) becomes unavailable. Prize does not include any other item or expense not specifically described in these Official Rules. + +Sponsor has no responsibility for the winner’s inability or failure to accept or use any part of the Prize as described herein. + +WINNERS AGREE TO ACCEPT THE PRIZE “AS IS”, AND YOU HEREBY ACKNOWLEDGE THAT SWEEPSTAKES ENTITIES HAVE NEITHER MADE NOR ARE IN ANY MANNER RESPONSIBLE OR LIABLE FOR ANY WARRANTY, REPRESENTATION, OR GUARANTEE, EXPRESS OR IMPLIED, IN FACT OR IN LAW, RELATIVE TO THE PRIZE, INCLUDING BUT NOT LIMITED TO (A) ANY EXPRESS WARRANTIES PROVIDED EXCLUSIVELY BY A PRIZE SUPPLIER THAT ARE SENT ALONG WITH THE PRIZE OR (B) THEIR QUALITY OR MECHANIC CONDITIONS. SPONSOR HEREBY DISCLAIMS ALL IMPLIED WARRANTIES, INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NONINFRINGEMENT. + +Winner is solely responsible for all federal, state, local, or other applicable taxes associated with the acceptance and use of a Prize. All costs and expenses associated with Prize acceptance and use not specifically provided herein are the responsibility of each winner. + +**Winner Selection and Notification.** Winners will be required to verify their .edu school email address and will be notified of winning via this email address. Prizes are administered electronically using Sponsor’s computer. + +Winner is subject to verification of eligibility, including verification of age and residency. Winners under the age of 18 must receive permission from their parents or legal guardian to participate, and a parent or legal guardian must accompany them on the trip and to the concerts as the second person. + +If a Winner (i) is determined to be ineligible or otherwise disqualified by Sponsor or (ii) fails to respond to Sponsor’s selection email or text within forty-eight (48) hours of such email or text, the Winner forfeits the Prize in its entirety and a substitute Winner will be selected based upon a random drawing from among all other eligible entries received. + +Winner may be required to complete, sign, notarize and return an affidavit of eligibility/liability release and a publicity release, all of which must be properly executed and returned within three (3) days of issuance of Prize notification. If these documents are not returned properly executed, or are returned to Sponsor as undeliverable, or if any given Winner does not otherwise comply with the Officials Rules, the Prize will be forfeited and awarded to an alternate winner. + +**LIMITATIONS OF LIABILITY.** + +YOU ACKNOWLEDGE AND AGREE THAT YOU ACCESS AND USE THE SWEEPSTAKES WEBSITE AT YOUR OWN RISK. THE SWEEPSTAKES WEBSITE IS MADE AVAILABLE ON AN “AS IS” AND “WITH ALL FAULTS” BASIS, AND THE SWEEPSTAKES ENTITIES EXPRESSLY DISCLAIM ANY AND ALL WARRANTIES AND CONDITIONS OF ANY KIND, INCLUDING WITHOUT LIMITATION ALL WARRANTIES OR CONDITIONS OF MERCHANTABILITY, TITLE, QUIET ENJOYMENT, ACCURACY, NON-INFRINGEMENT, AND/OR FITNESS FOR A PARTICULAR PURPOSE. THE SWEEPSTAKES ENTITIES MAKE NO WARRANTY THAT THE SWEEPSTAKES WEBSITE WILL MEET YOUR REQUIREMENTS, WILL BE AVAILABLE ON AN UNINTERRUPTED, TIMELY, SECURE, OR ERROR-FREE BASIS, OR WILL BE ACCURATE, RELIABLE, FREE OF VIRUSES OR OTHER HARMFUL CODE, COMPLETE, LEGAL, OR SAFE. IF APPLICABLE LAW REQUIRES ANY WARRANTIES WITH RESPECT TO THE SWEEPSTAKES WEBSITE, ALL SUCH WARRANTIES ARE LIMITED IN DURATION TO NINETY (90) DAYS FROM THE DATE OF FIRST USE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO THE FOREGOING EXCLUSION MAY NOT APPLY TO YOU. SOME JURISDICTIONS DO NOT ALLOW LIMITATIONS ON HOW LONG AN IMPLIED WARRANTY LASTS, SO THE FOREGOING LIMITATION MAY NOT APPLY TO YOU. + +TO THE MAXIMUM EXTENT PERMITTED BY LAW AND NOT WITHSTANDING ANYTHING TO THE CONTRARY CONTAINED HEREIN, YOU AGREE THAT (I) SPONSOR SHALL NOT BE LIABLE TO YOU OR ANY THIRD PARTY FOR ANY INDIRECT, INCIDENTAL, CONSEQUENTIAL, EXEMPLARY, PUNITIVE OR SPECIAL DAMAGES ARISING FROM OR RELATED TO: (A) THE SWEEPSTAKES, (B) ANY PRIZE AWARDED, OR (C) YOUR USE OF INABILITY TO USE THE SWEEPSTAKES WEBSITE (IN EACH CASE EVEN IF SPONSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES) AND (II) SPONSOR’S LIABILITY TO YOU FOR ANY DAMAGES ARISING FROM OR RELATED TO THESE OFFICIAL RULES, THE SWEEPSTAKES, THE SWEEPSTAKES WEBSITE, OR ANY PRIZE WILL AT ALL TIMES BE LIMITED TO YOUR ACTUAL OUT-OF-POCKET EXPENSES OF PARTICIPATION IN THE SWEEPSTAKES (IF ANY). THE EXISTENCE OF MORE THAN ONE CLAIM WILL NOT ENLARGE THIS LIMIT. YOU AGREE THAT OUR SUPPLIERS WILL HAVE NO LIABILITY OF ANY KIND ARISING FROM OR RELATING TO THESE OFFICIAL RULES. + +By participating in the Sweepstakes, you agree to release and hold harmless Sweepstakes Entities from any liability, claims, costs, injuries, losses or damages of any kind, directly or indirectly, whether caused by negligence or not, from (i) your participation in the Sweepstakes, including, without limitation, the unauthorized or illegal access to personally identifiable or sensitive information or acceptance, possession, use, misuse, or nonuse of the Prize or any portion thereof; (ii) technical failures of any kind, including but not limited to the malfunctioning of any computer, mobile device, cable, network, hardware or software; (iii) the unavailability or inaccessibility of any telephone or Internet service; (iv) unauthorized human intervention in any part of the entry process or the Sweepstakes, or (v) electronic or human error which may occur in the administration of the Sweepstakes or the processing of entries, including, without limitation, incorrect, delayed or inaccurate transmission of winner notifications, prize claims or other information or communications relating to the Sweepstakes. In the event of any ambiguity or error(s) in these Official Rules, Sponsor reserves the right to clarify or modify these Official Rules however it deems appropriate to correct any such ambiguity or error(s). If due to an error or for any other reason, more legitimate prize claims are received than the number of prizes stated in these Official Rules, Sponsor reserves the right to award only one (1) Prize from a random drawing from among all eligible entrants. In no event will more than the stated number of prizes (i.e. one (1) Prize) be awarded. + +Sponsor reserves the right in its sole discretion to disqualify any entry or entrant from the Sweepstakes or from winning the Prize if it determines that said entrant is attempting to undermine the legitimate operation of the promotion by cheating, deception, or other unfair playing practices (including the use of automated quick entry programs) or intending to annoy, abuse, threaten or harass any other entrants or any Sweepstakes Entities. + +ANY ATTEMPT BY AN ENTRANT OR ANY OTHER INDIVIDUAL TO DELIBERATELY DAMAGE THE SWEEPSTAKES WEBSITE, TAMPER WITH THE ENTRY PROCESS, OR OTHERWISE UNDERMINE THE LEGITIMATE OPERATION OF THE SWEEPSTAKES MAY BE A VIOLATION OF LAW AND, SHOULD SUCH AN ATTEMPT BE MADE, SPONSOR RESERVES THE RIGHT TO PURSUE ALL REMEDIES AGAINST ANY SUCH INDIVIDUAL TO THE FULLEST EXTENT PERMITTED BY LAW. + +**Sponsor’s Reservation of Rights.** Sponsor’s failure to enforce any term of these Official Rules shall not constitute a waiver of that provision. If any provision of these Official Rules is held to be invalid or unenforceable, such provision shall be struck, and the remaining provisions shall be enforced. If for any reason the Sweepstakes is not capable of being safely executed as planned, including, without limitation, as a result of war, natural disasters or weather events, labor strikes, acts of terrorism, pandemic infection (including without limitation, events related to the COVID-19 virus), or other force majeure event, or any infection by computer virus, bugs, tampering, unauthorized intervention, fraud, technical failures or any other causes which in the opinion of and/or Sweepstakes Entities, corrupt or affect the administration, security, fairness, integrity, or proper conduct and fulfillment of this Sweepstakes, Sponsor reserves the right to cancel, terminate, modify or suspend the Sweepstakes. In the event of any cancellation, termination or suspension, Sponsor reserves the right to select a winner from a random drawing from among all eligible, non-suspect entries received as of the date of the termination, cancellation or suspension. + +**DISPUTES AND JURISDICTION:** THE SWEEPSTAKES IS GOVERNED BY, AND WILL BE CONSTRUED IN ACCORDANCE WITH, THE LAWS OF THE STATE OF CALIFORNIA, WITHOUT REGARD TO ITS CONFLICTS OF LAW PRINCIPLES, AND THE FORUM AND VENUE FOR ANY DISPUTE RELATING TO THE SWEEPSTAKES SHALL BE IN A FEDERAL OR STATE COURT OF COMPETENT JURISDICTION IN CALIFORNIA, CALIFORNIA. EXCEPT WHERE PROHIBITED, ENTRANTS AGREE THAT ANY AND ALL DISPUTES, CLAIMS AND CAUSES OF ACTION ARISING OUT OF OR CONNECTED WITH THIS SWEEPSTAKES OR ANY PRIZE AWARDED SHALL BE RESOLVED INDIVIDUALLY, WITHOUT RESORT TO ANY FORM OF CLASS ACTION. + +**Official Rules.** For a copy of the Official Rules, mail a self-addressed stamped envelope by first class mail to Voter Bowl, 530 Divisadero Street PMB 126 San Francisco CA 94117, Attention: Rules Department. Sweepstakes entrants are hereby authorized to copy these Official Rules on the condition that it will be for their personal use and not for any commercial purpose whatsoever. + +**Privacy.** Any personally identifiable information collected during an entrant’s participation in the Sweepstakes will be collected and used by Sponsor and its designees for the administration and fulfillment of the Sweepstakes and as otherwise described in these Official Rules and Sponsor’s privacy policy available at https://about.voteamerica.com/privacy. Should entrant register to vote, their information will be shared, as noted on the user interface, with the individual state election board in entrant’s home state, and also be added to databases of registered voters used exclusively for non-profit purposes and the purpose of encouraging voter turnout. + +**Winners List.** A winners list is available only within sixty (60) days after the end of the Entry Period. To receive a copy of the winners list, mail a request to Voter Bowl Sweepstakes Winners List, 530 Divisadero Street PMB 126 San Francisco CA 94117, Attention: Winners List. In order to obtain a winner’s list, where permitted by law, send your written request and a self-addressed, stamped envelope (residents of VT and WA may omit return postage). + +**Intellectual Property Ownership; Access to Sweepstakes Website.** You acknowledge and agree that the Sweepstakes Website and all content thereof, and all the intellectual property rights, including copyrights, patents, trademarks, and trade secrets therein, are owned by Sponsor or its licensors. Neither these Official Rules (nor your access to the Sweepstakes Website) transfers to you or any third party any rights, title or interest in or to such intellectual property rights, except for the limited, non-exclusive right to access the Sweepstakes Website for your own personal, noncommercial use. Sponsor and its suppliers reserve all rights not granted in these Official Rules. There are no implied licenses granted under these Official Rules. + +All trademarks, logos and service marks (“Marks”) displayed on the Sweepstakes Website are our property or the property of other third parties. You are not permitted to use these Marks without our prior written consent or the consent of such third party which may own the Marks. diff --git a/server/vb/components/rules_page.css b/server/vb/components/rules_page.css new file mode 100644 index 0000000..b8c9949 --- /dev/null +++ b/server/vb/components/rules_page.css @@ -0,0 +1,5 @@ +me { + font-size: 1.25em; + line-height: 150%; + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} diff --git a/server/vb/components/rules_page.py b/server/vb/components/rules_page.py index e40a57d..394ceb0 100644 --- a/server/vb/components/rules_page.py +++ b/server/vb/components/rules_page.py @@ -1,107 +1,15 @@ import htpy as h -import markdown -from markupsafe import Markup -from .base_page import base_page - -_STYLE = Markup(""" -me { - font-size: 1.25em; - line-height: 150%; - font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; -} -""") - -_RULES = markdown.markdown(""" -**OFFICIAL SWEEPSTAKES RULES: The Voter Bowl, a project of www.voteamerica.com** - -BY ENTERING THIS SWEEPSTAKES, YOU AGREE TO THESE RULES AND REGULATIONS. - -NO PURCHASE OR PAYMENT OF ANY KIND IS NECESSARY TO ENTER OR WIN. - -OPEN TO ELIGIBLE LEGAL RESIDENTS OF THE 50 UNITED STATES AND DISTRICT OF COLUMBIA EXCEPT FOR THOSE RESIDING IN MISSISSIPPI OR OREGON, WHO, AS OF THE TIME OF ENTRY, ARE AT LEAST 18 YEARS OLD. - -VOID IN PUERTO RICO, OREGON, MISSISSIPPI AND ALL JURISDICTIONS OTHER THAN THOSE STATED ABOVE AND WHERE PROHIBITED OR RESTRICTED BY LAW. - -**Introduction.** Prizes of different values will be awarded to college students with verifiable email addresses at the school where they are currently enrolled who check their voter registration status using the VoteAmerica “Check your registration status” tool during the timeframe of the contest specified on the www.voterbowl.org. Methods of entry and Official Rules are described below. - -**Sponsor.** VoteAmerica. 530 Divisadero Street PMB 126 San Francisco CA 94117 (“Sponsor”) - -Any questions, comments or complaints regarding the Sweepstakes is to be directed to Sponsor, at the address above, and not any other party. - -**Timing.** The Sweepstakes timing will be posted on the www.voterbowl.org website between April 4th, 2024 and November 6th 2024 (the “Entry Period”). Sponsor’s computer is the official time keeping device for the Sweepstakes. Mail-in entries must be postmarked on the day that a contest is running for a specific college and must include a verifiable .edu student email address. Postcards not received by the Mail-in Entry Deadline will be disqualified. Proof of mailing does not constitute proof of delivery. - -**Eligibility.** The Sweepstakes is open only to legal residents of the fifty (50) United States and the District of Columbia except for those residing in Mississippi and Oregon, who, as of the time of entry, are at least eighteen (18) years of age. Void in Puerto Rico, Mississippi, Oregon, and all other jurisdictions other than those stated above and where prohibited or restricted by law. Employees, officers, directors, contractors, and representatives of Sponsor and their respective corporate parents, subsidiaries, affiliates, advertising and promotion agencies, agents, and any other entity involved in the development or administration of the Sweepstakes (collectively, with Sponsor “Sweepstakes Entities”) as well as the immediate family (defined as spouse, parents, children, siblings, grandparents, and “steps” of each) and household members of each, whether or not related, are not eligible to enter or win the Sweepstakes. By participating, you agree to abide by these official rules (the “Official Rules”) and the decisions of Sponsor in all matters relating to the Sweepstakes, which are final and binding in all respects. Notwithstanding the foregoing, Sponsor’s volunteers are eligible to enter the Sweepstakes. - -**How to Enter.** NO PURCHASE NECESSARY AND NO ENTRY FEE, PAYMENT OR PROOF OF PURCHASE IS NECESSARY TO PARTICIPATE. - -Taking civic actions, such as verifying your voter registration status, registering to vote, or requesting a mail in ballot, is NOT required for entry. Having a valid voter registration status or being eligible to register to vote is NOT required for entry. - -There are two (2) ways to enter the Sweepstakes: - -1. **INTERNET:** Visit the Sweepstakes Website on a web browser. Complete the form provided on the Sweepstakes Website to enter. -1. **MAIL:** Mail a 3 ½” x 5” card with your name, complete valid postal address (including zip code), date of birth, telephone number, and valid .edu verifiable school e-mail address legibly, hand printed in a #10 envelope with proper postage affixed to: 530 Divisadero Street PMB 126 San Francisco CA 94117, ATTN: Voter Bowl. Maximum one (1) entry card will be accepted per stamped outer mailing envelope. The Sweepstakes Entities assume no responsibility for lost, late, incomplete, stolen, misdirected, mutilated, illegible or postage-due entries or mail, all of which will be void. No mechanically reproduced entries permitted. Illegible/incomplete entries are void. All entries become the property of Sponsor, and none will be acknowledged or returned. - -Maximum of one (1) entry per person by Internet or Mail methods, or by any combination of these methods. - -The submission of an entry is solely the responsibility of the entrant. Only eligible entries actually postmarked/received by the deadlines specified in these Official Rules will be included in the Prize drawing. Any automated receipt does not constitute proof of actual receipt by Sponsor of an entry for purposes of these Official Rules. - -Compliance with the entry requirements will be determined by Sponsor in its sole discretion. Entries that violate these entry requirements will be disqualified from the Sweepstakes. - -**Odds of Winning:** Odds of winning depend on the number of eligible entries received. - -The total ARV of all Prizes offered in this Sweepstakes is $250,000 (USD). - -Winners are subject to verification, including verification of age and residency. The Prize is neither transferable nor redeemable in cash and it must be accepted as awarded. No substitutions will be available, except at the sole discretion of Sponsor, who reserves the right to award a prize of equal or greater financial value if any advertised Prize (or any component thereof) becomes unavailable. Prize does not include any other item or expense not specifically described in these Official Rules. +from server.utils.components import markdown_html, style -Sponsor has no responsibility for the winner’s inability or failure to accept or use any part of the Prize as described herein. - -WINNERS AGREE TO ACCEPT THE PRIZE “AS IS”, AND YOU HEREBY ACKNOWLEDGE THAT SWEEPSTAKES ENTITIES HAVE NEITHER MADE NOR ARE IN ANY MANNER RESPONSIBLE OR LIABLE FOR ANY WARRANTY, REPRESENTATION, OR GUARANTEE, EXPRESS OR IMPLIED, IN FACT OR IN LAW, RELATIVE TO THE PRIZE, INCLUDING BUT NOT LIMITED TO (A) ANY EXPRESS WARRANTIES PROVIDED EXCLUSIVELY BY A PRIZE SUPPLIER THAT ARE SENT ALONG WITH THE PRIZE OR (B) THEIR QUALITY OR MECHANIC CONDITIONS. SPONSOR HEREBY DISCLAIMS ALL IMPLIED WARRANTIES, INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NONINFRINGEMENT. - -Winner is solely responsible for all federal, state, local, or other applicable taxes associated with the acceptance and use of a Prize. All costs and expenses associated with Prize acceptance and use not specifically provided herein are the responsibility of each winner. - -**Winner Selection and Notification.** Winners will be required to verify their .edu school email address and will be notified of winning via this email address. Prizes are administered electronically using Sponsor’s computer. - -Winner is subject to verification of eligibility, including verification of age and residency. Winners under the age of 18 must receive permission from their parents or legal guardian to participate, and a parent or legal guardian must accompany them on the trip and to the concerts as the second person. - -If a Winner (i) is determined to be ineligible or otherwise disqualified by Sponsor or (ii) fails to respond to Sponsor’s selection email or text within forty-eight (48) hours of such email or text, the Winner forfeits the Prize in its entirety and a substitute Winner will be selected based upon a random drawing from among all other eligible entries received. - -Winner may be required to complete, sign, notarize and return an affidavit of eligibility/liability release and a publicity release, all of which must be properly executed and returned within three (3) days of issuance of Prize notification. If these documents are not returned properly executed, or are returned to Sponsor as undeliverable, or if any given Winner does not otherwise comply with the Officials Rules, the Prize will be forfeited and awarded to an alternate winner. - -**LIMITATIONS OF LIABILITY.** - -YOU ACKNOWLEDGE AND AGREE THAT YOU ACCESS AND USE THE SWEEPSTAKES WEBSITE AT YOUR OWN RISK. THE SWEEPSTAKES WEBSITE IS MADE AVAILABLE ON AN “AS IS” AND “WITH ALL FAULTS” BASIS, AND THE SWEEPSTAKES ENTITIES EXPRESSLY DISCLAIM ANY AND ALL WARRANTIES AND CONDITIONS OF ANY KIND, INCLUDING WITHOUT LIMITATION ALL WARRANTIES OR CONDITIONS OF MERCHANTABILITY, TITLE, QUIET ENJOYMENT, ACCURACY, NON-INFRINGEMENT, AND/OR FITNESS FOR A PARTICULAR PURPOSE. THE SWEEPSTAKES ENTITIES MAKE NO WARRANTY THAT THE SWEEPSTAKES WEBSITE WILL MEET YOUR REQUIREMENTS, WILL BE AVAILABLE ON AN UNINTERRUPTED, TIMELY, SECURE, OR ERROR-FREE BASIS, OR WILL BE ACCURATE, RELIABLE, FREE OF VIRUSES OR OTHER HARMFUL CODE, COMPLETE, LEGAL, OR SAFE. IF APPLICABLE LAW REQUIRES ANY WARRANTIES WITH RESPECT TO THE SWEEPSTAKES WEBSITE, ALL SUCH WARRANTIES ARE LIMITED IN DURATION TO NINETY (90) DAYS FROM THE DATE OF FIRST USE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO THE FOREGOING EXCLUSION MAY NOT APPLY TO YOU. SOME JURISDICTIONS DO NOT ALLOW LIMITATIONS ON HOW LONG AN IMPLIED WARRANTY LASTS, SO THE FOREGOING LIMITATION MAY NOT APPLY TO YOU. - -TO THE MAXIMUM EXTENT PERMITTED BY LAW AND NOT WITHSTANDING ANYTHING TO THE CONTRARY CONTAINED HEREIN, YOU AGREE THAT (I) SPONSOR SHALL NOT BE LIABLE TO YOU OR ANY THIRD PARTY FOR ANY INDIRECT, INCIDENTAL, CONSEQUENTIAL, EXEMPLARY, PUNITIVE OR SPECIAL DAMAGES ARISING FROM OR RELATED TO: (A) THE SWEEPSTAKES, (B) ANY PRIZE AWARDED, OR (C) YOUR USE OF INABILITY TO USE THE SWEEPSTAKES WEBSITE (IN EACH CASE EVEN IF SPONSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES) AND (II) SPONSOR’S LIABILITY TO YOU FOR ANY DAMAGES ARISING FROM OR RELATED TO THESE OFFICIAL RULES, THE SWEEPSTAKES, THE SWEEPSTAKES WEBSITE, OR ANY PRIZE WILL AT ALL TIMES BE LIMITED TO YOUR ACTUAL OUT-OF-POCKET EXPENSES OF PARTICIPATION IN THE SWEEPSTAKES (IF ANY). THE EXISTENCE OF MORE THAN ONE CLAIM WILL NOT ENLARGE THIS LIMIT. YOU AGREE THAT OUR SUPPLIERS WILL HAVE NO LIABILITY OF ANY KIND ARISING FROM OR RELATING TO THESE OFFICIAL RULES. - -By participating in the Sweepstakes, you agree to release and hold harmless Sweepstakes Entities from any liability, claims, costs, injuries, losses or damages of any kind, directly or indirectly, whether caused by negligence or not, from (i) your participation in the Sweepstakes, including, without limitation, the unauthorized or illegal access to personally identifiable or sensitive information or acceptance, possession, use, misuse, or nonuse of the Prize or any portion thereof; (ii) technical failures of any kind, including but not limited to the malfunctioning of any computer, mobile device, cable, network, hardware or software; (iii) the unavailability or inaccessibility of any telephone or Internet service; (iv) unauthorized human intervention in any part of the entry process or the Sweepstakes, or (v) electronic or human error which may occur in the administration of the Sweepstakes or the processing of entries, including, without limitation, incorrect, delayed or inaccurate transmission of winner notifications, prize claims or other information or communications relating to the Sweepstakes. In the event of any ambiguity or error(s) in these Official Rules, Sponsor reserves the right to clarify or modify these Official Rules however it deems appropriate to correct any such ambiguity or error(s). If due to an error or for any other reason, more legitimate prize claims are received than the number of prizes stated in these Official Rules, Sponsor reserves the right to award only one (1) Prize from a random drawing from among all eligible entrants. In no event will more than the stated number of prizes (i.e. one (1) Prize) be awarded. - -Sponsor reserves the right in its sole discretion to disqualify any entry or entrant from the Sweepstakes or from winning the Prize if it determines that said entrant is attempting to undermine the legitimate operation of the promotion by cheating, deception, or other unfair playing practices (including the use of automated quick entry programs) or intending to annoy, abuse, threaten or harass any other entrants or any Sweepstakes Entities. - -ANY ATTEMPT BY AN ENTRANT OR ANY OTHER INDIVIDUAL TO DELIBERATELY DAMAGE THE SWEEPSTAKES WEBSITE, TAMPER WITH THE ENTRY PROCESS, OR OTHERWISE UNDERMINE THE LEGITIMATE OPERATION OF THE SWEEPSTAKES MAY BE A VIOLATION OF LAW AND, SHOULD SUCH AN ATTEMPT BE MADE, SPONSOR RESERVES THE RIGHT TO PURSUE ALL REMEDIES AGAINST ANY SUCH INDIVIDUAL TO THE FULLEST EXTENT PERMITTED BY LAW. - -**Sponsor’s Reservation of Rights.** Sponsor’s failure to enforce any term of these Official Rules shall not constitute a waiver of that provision. If any provision of these Official Rules is held to be invalid or unenforceable, such provision shall be struck, and the remaining provisions shall be enforced. If for any reason the Sweepstakes is not capable of being safely executed as planned, including, without limitation, as a result of war, natural disasters or weather events, labor strikes, acts of terrorism, pandemic infection (including without limitation, events related to the COVID-19 virus), or other force majeure event, or any infection by computer virus, bugs, tampering, unauthorized intervention, fraud, technical failures or any other causes which in the opinion of and/or Sweepstakes Entities, corrupt or affect the administration, security, fairness, integrity, or proper conduct and fulfillment of this Sweepstakes, Sponsor reserves the right to cancel, terminate, modify or suspend the Sweepstakes. In the event of any cancellation, termination or suspension, Sponsor reserves the right to select a winner from a random drawing from among all eligible, non-suspect entries received as of the date of the termination, cancellation or suspension. - -**DISPUTES AND JURISDICTION:** THE SWEEPSTAKES IS GOVERNED BY, AND WILL BE CONSTRUED IN ACCORDANCE WITH, THE LAWS OF THE STATE OF CALIFORNIA, WITHOUT REGARD TO ITS CONFLICTS OF LAW PRINCIPLES, AND THE FORUM AND VENUE FOR ANY DISPUTE RELATING TO THE SWEEPSTAKES SHALL BE IN A FEDERAL OR STATE COURT OF COMPETENT JURISDICTION IN CALIFORNIA, CALIFORNIA. EXCEPT WHERE PROHIBITED, ENTRANTS AGREE THAT ANY AND ALL DISPUTES, CLAIMS AND CAUSES OF ACTION ARISING OUT OF OR CONNECTED WITH THIS SWEEPSTAKES OR ANY PRIZE AWARDED SHALL BE RESOLVED INDIVIDUALLY, WITHOUT RESORT TO ANY FORM OF CLASS ACTION. - -**Official Rules.** For a copy of the Official Rules, mail a self-addressed stamped envelope by first class mail to Voter Bowl, 530 Divisadero Street PMB 126 San Francisco CA 94117, Attention: Rules Department. Sweepstakes entrants are hereby authorized to copy these Official Rules on the condition that it will be for their personal use and not for any commercial purpose whatsoever. - -**Privacy.** Any personally identifiable information collected during an entrant’s participation in the Sweepstakes will be collected and used by Sponsor and its designees for the administration and fulfillment of the Sweepstakes and as otherwise described in these Official Rules and Sponsor’s privacy policy available at https://about.voteamerica.com/privacy. Should entrant register to vote, their information will be shared, as noted on the user interface, with the individual state election board in entrant’s home state, and also be added to databases of registered voters used exclusively for non-profit purposes and the purpose of encouraging voter turnout. - -**Winners List.** A winners list is available only within sixty (60) days after the end of the Entry Period. To receive a copy of the winners list, mail a request to Voter Bowl Sweepstakes Winners List, 530 Divisadero Street PMB 126 San Francisco CA 94117, Attention: Winners List. In order to obtain a winner’s list, where permitted by law, send your written request and a self-addressed, stamped envelope (residents of VT and WA may omit return postage). - -**Intellectual Property Ownership; Access to Sweepstakes Website.** You acknowledge and agree that the Sweepstakes Website and all content thereof, and all the intellectual property rights, including copyrights, patents, trademarks, and trade secrets therein, are owned by Sponsor or its licensors. Neither these Official Rules (nor your access to the Sweepstakes Website) transfers to you or any third party any rights, title or interest in or to such intellectual property rights, except for the limited, non-exclusive right to access the Sweepstakes Website for your own personal, noncommercial use. Sponsor and its suppliers reserve all rights not granted in these Official Rules. There are no implied licenses granted under these Official Rules. - -All trademarks, logos and service marks (“Marks”) displayed on the Sweepstakes Website are our property or the property of other third parties. You are not permitted to use these Marks without our prior written consent or the consent of such third party which may own the Marks. - -""") +from .base_page import base_page def rules_page() -> h.Element: """Render the rules page.""" return base_page(title="Voter Bowl Rules", bg_color="white", show_faq=False)[ h.div(".container")[ - h.style[_STYLE], - Markup(_RULES), + style(__file__, "rules_page.css"), + markdown_html(__file__, "rules.md"), ] ] diff --git a/server/vb/views.py b/server/vb/views.py index d10ffbf..3d6c279 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -153,7 +153,6 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: return HttpResponse( finish_check_partial( school, - current_contest, contest_entry, most_recent_winner, ) diff --git a/types/htmx.d.ts b/types/htmx.d.ts new file mode 100644 index 0000000..38285c1 --- /dev/null +++ b/types/htmx.d.ts @@ -0,0 +1,525 @@ +// https://htmx.org/reference/#api + +declare global { + var htmx: HTMX; +} + +interface HTMX { + /** + * This method adds a class to the given element. + * + * https://htmx.org/api/#addClass + * + * @param elt the element to add the class to + * @param clazz the class to add + * @param delay the delay (in milliseconds before class is added) + */ + addClass(elt: Element, clazz: string, delay?: number): void; + + /** + * Issues an htmx-style AJAX request + * + * https://htmx.org/api/#ajax + * + * @param verb 'GET', 'POST', etc. + * @param path the URL path to make the AJAX + * @param element the element to target (defaults to the **body**) + * @returns Promise that resolves immediately if no request is sent, or when the request is complete + */ + ajax(verb: string, path: string, element?: Element): Promise; + + /** + * Issues an htmx-style AJAX request + * + * https://htmx.org/api/#ajax + * + * @param verb 'GET', 'POST', etc. + * @param path the URL path to make the AJAX + * @param selector a selector for the target + * @returns Promise that resolves immediately if no request is sent, or when the request is complete + */ + ajax(verb: string, path: string, selector: string): Promise; + + /** + * Issues an htmx-style AJAX request + * + * https://htmx.org/api/#ajax + * + * @param verb 'GET', 'POST', etc. + * @param path the URL path to make the AJAX + * @param context a context object that contains any of the following + * @returns Promise that resolves immediately if no request is sent, or when the request is complete + */ + ajax( + verb: string, + path: string, + context: Partial<{ + source: any; + event: any; + handler: any; + target: any; + swap: any; + values: any; + headers: any; + select: any; + }> + ): Promise; + + /** + * Finds the closest matching element in the given elements parentage, inclusive of the element + * + * https://htmx.org/api/#closest + * + * @param elt the element to find the selector from + * @param selector the selector to find + */ + closest(elt: Element, selector: string): Element | null; + + /** + * A property holding the configuration htmx uses at runtime. + * + * Note that using a [meta tag](https://htmx.org/docs/#config) is the preferred mechanism for setting these properties. + * + * https://htmx.org/api/#config + */ + config: HtmxConfig; + + /** + * A property used to create new [Server Sent Event](https://htmx.org/docs/#sse) sources. This can be updated to provide custom SSE setup. + * + * https://htmx.org/api/#createEventSource + */ + createEventSource: (url: string) => EventSource; + + /** + * A property used to create new [WebSocket](https://htmx.org/docs/#websockets). This can be updated to provide custom WebSocket setup. + * + * https://htmx.org/api/#createWebSocket + */ + createWebSocket: (url: string) => WebSocket; + + /** + * Defines a new htmx [extension](https://htmx.org/extensions). + * + * https://htmx.org/api/#defineExtension + * + * @param name the extension name + * @param ext the extension definition + */ + defineExtension(name: string, ext: HtmxExtension): void; + + /** + * Finds an element matching the selector + * + * https://htmx.org/api/#find + * + * @param selector the selector to match + */ + find(selector: string): Element | null; + + /** + * Finds an element matching the selector + * + * https://htmx.org/api/#find + * + * @param elt the root element to find the matching element in, inclusive + * @param selector the selector to match + */ + find(elt: Element, selector: string): Element | null; + + /** + * Finds all elements matching the selector + * + * https://htmx.org/api/#findAll + * + * @param selector the selector to match + */ + findAll(selector: string): NodeListOf; + + /** + * Finds all elements matching the selector + * + * https://htmx.org/api/#findAll + * + * @param elt the root element to find the matching elements in, inclusive + * @param selector the selector to match + */ + findAll(elt: Element, selector: string): NodeListOf; + + /** + * Log all htmx events, useful for debugging. + * + * https://htmx.org/api/#logAll + */ + logAll(): void; + + /** + * The logger htmx uses to log with + * + * https://htmx.org/api/#logger + */ + logger: (elt: Element, eventName: string, detail: any) => void | null; + + /** + * Removes an event listener from an element + * + * https://htmx.org/api/#off + * + * @param eventName the event name to remove the listener from + * @param listener the listener to remove + */ + off(eventName: string, listener: (evt: Event) => void): (evt: Event) => void; + + /** + * Removes an event listener from an element + * + * https://htmx.org/api/#off + * + * @param target the element to remove the listener from + * @param eventName the event name to remove the listener from + * @param listener the listener to remove + */ + off( + target: string, + eventName: string, + listener: (evt: Event) => void + ): (evt: Event) => void; + + /** + * Adds an event listener to an element + * + * https://htmx.org/api/#on + * + * @param eventName the event name to add the listener for + * @param listener the listener to add + */ + on(eventName: string, listener: (evt: Event) => void): (evt: Event) => void; + + /** + * Adds an event listener to an element + * + * https://htmx.org/api/#on + * + * @param target the element to add the listener to + * @param eventName the event name to add the listener for + * @param listener the listener to add + */ + on( + target: string, + eventName: string, + listener: (evt: Event) => void + ): (evt: Event) => void; + + /** + * Adds a callback for the **htmx:load** event. This can be used to process new content, for example initializing the content with a javascript library + * + * https://htmx.org/api/#onLoad + * + * @param callback the callback to call on newly loaded content + */ + onLoad(callback: (element: Element) => void): void; + + /** + * Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes. + * + * Caution: Accepts an int followed by either **s** or **ms**. All other values use **parseFloat** + * + * https://htmx.org/api/#parseInterval + * + * @param str timing string + */ + parseInterval(str: string): number; + + /** + * Processes new content, enabling htmx behavior. This can be useful if you have content that is added to the DOM outside of the normal htmx request cycle but still want htmx attributes to work. + * + * https://htmx.org/api/#process + * + * @param element element to process + */ + process(element: Element): void; + + /** + * Removes an element from the DOM + * + * https://htmx.org/api/#remove + * + * @param elt element to remove + * @param delay the delay (in milliseconds before element is removed) + */ + remove(elt: Element, delay?: number): void; + + /** + * Removes a class from the given element + * + * https://htmx.org/api/#removeClass + * + * @param elt element to remove the class from + * @param clazz the class to remove + * @param delay the delay (in milliseconds before class is removed) + */ + removeClass(elt: Element, clazz: string, delay?: number): void; + + /** + * Removes the given extension from htmx + * + * https://htmx.org/api/#removeExtension + * + * @param name the name of the extension to remove + */ + removeExtension(name: string): void; + + /** + * Takes the given class from its siblings, so that among its siblings, only the given element will have the class. + * + * https://htmx.org/api/#takeClass + * + * @param elt the element that will take the class + * @param clazz the class to take + */ + takeClass(elt: Element, clazz: string): void; + + /** + * Toggles the given class on an element + * + * https://htmx.org/api/#toggleClass + * + * @param elt the element to toggle the class on + * @param clazz the class to toggle + */ + toggleClass(elt: Element, clazz: string): void; + + /** + * Triggers a given event on an element + * + * https://htmx.org/api/#trigger + * + * @param elt the element to trigger the event on + * @param name the name of the event to trigger + * @param detail details for the event + */ + trigger(elt: Element, name: string, detail: any): void; + + /** + * Returns the input values that would resolve for a given element via the htmx value resolution mechanism + * + * https://htmx.org/api/#values + * + * @param elt the element to resolve values on + * @param requestType the request type (e.g. **get** or **post**) non-GET's will include the enclosing form of the element. Defaults to **post** + */ + values(elt: Element, requestType?: string): any; + + version: string; +} + +interface HtmxConfig { + /** + * The attributes to settle during the settling phase. + * @default ["class", "style", "width", "height"] + */ + attributesToSettle?: ["class", "style", "width", "height"] | string[]; + /** + * If the focused element should be scrolled into view. + * @default false + */ + defaultFocusScroll?: boolean; + /** + * The default delay between completing the content swap and settling attributes. + * @default 20 + */ + defaultSettleDelay?: number; + /** + * The default delay between receiving a response from the server and doing the swap. + * @default 0 + */ + defaultSwapDelay?: number; + /** + * The default swap style to use if **[hx-swap](https://htmx.org/attributes/hx-swap)** is omitted. + * @default "innerHTML" + */ + defaultSwapStyle?: "innerHTML" | string; + /** + * The number of pages to keep in **localStorage** for history support. + * @default 10 + */ + historyCacheSize?: number; + /** + * Whether or not to use history. + * @default true + */ + historyEnabled?: boolean; + /** + * If true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the **htmx-indicator** class is present. + * @default true + */ + includeIndicatorStyles?: boolean; + /** + * The class to place on indicators when a request is in flight. + * @default "htmx-indicator" + */ + indicatorClass?: "htmx-indicator" | string; + /** + * The class to place on triggering elements when a request is in flight. + * @default "htmx-request" + */ + requestClass?: "htmx-request" | string; + /** + * The class to temporarily place on elements that htmx has added to the DOM. + * @default "htmx-added" + */ + addedClass?: "htmx-added" | string; + /** + * The class to place on target elements when htmx is in the settling phase. + * @default "htmx-settling" + */ + settlingClass?: "htmx-settling" | string; + /** + * The class to place on target elements when htmx is in the swapping phase. + * @default "htmx-swapping" + */ + swappingClass?: "htmx-swapping" | string; + /** + * Allows the use of eval-like functionality in htmx, to enable **hx-vars**, trigger conditions & script tag evaluation. Can be set to **false** for CSP compatibility. + * @default true + */ + allowEval?: boolean; + /** + * Use HTML template tags for parsing content from the server. This allows you to use Out of Band content when returning things like table rows, but it is *not* IE11 compatible. + * @default false + */ + useTemplateFragments?: boolean; + /** + * Allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates. + * @default false + */ + withCredentials?: boolean; + /** + * The default implementation of **getWebSocketReconnectDelay** for reconnecting after unexpected connection loss by the event code **Abnormal Closure**, **Service Restart** or **Try Again Later**. + * @default "full-jitter" + */ + wsReconnectDelay?: "full-jitter" | string | ((retryCount: number) => number); + // following don't appear in the docs + /** @default false */ + refreshOnHistoryMiss?: boolean; + /** @default 0 */ + timeout?: number; + /** @default "[hx-disable], [data-hx-disable]" */ + disableSelector?: "[hx-disable], [data-hx-disable]" | string; + /** @default "smooth" */ + scrollBehavior?: "smooth" | "auto"; + /** + * If set to false, disables the interpretation of script tags. + * @default true + */ + allowScriptTags?: boolean; + /** + * If set to true, disables htmx-based requests to non-origin hosts. + * @default false + */ + selfRequestsOnly?: boolean; + /** + * Whether or not the target of a boosted element is scrolled into the viewport. + * @default true + */ + scrollIntoViewOnBoost?: boolean; + /** + * If set, the nonce will be added to inline scripts. + * @default '' + */ + inlineScriptNonce?: string; + /** + * The type of binary data being received over the WebSocket connection + * @default 'blob' + */ + wsBinaryType?: "blob" | "arraybuffer"; + /** + * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser + * @default false + */ + getCacheBusterParam?: boolean; + /** + * If set to true, htmx will use the View Transition API when swapping in new content. + * @default false + */ + globalViewTransitions?: boolean; + /** + * htmx will format requests with these methods by encoding their parameters in the URL, not the request body + * @default ["get"] + */ + methodsThatUseUrlParams?: ( + | "get" + | "head" + | "post" + | "put" + | "delete" + | "connect" + | "options" + | "trace" + | "patch" + )[]; + /** + * If set to true htmx will not update the title of the document when a title tag is found in new content + * @default false + */ + ignoreTitle?: boolean; +} + +export type HtmxEvent = + | "htmx:abort" + | "htmx:afterOnLoad" + | "htmx:afterProcessNode" + | "htmx:afterRequest" + | "htmx:afterSettle" + | "htmx:afterSwap" + | "htmx:beforeCleanupElement" + | "htmx:beforeOnLoad" + | "htmx:beforeProcessNode" + | "htmx:beforeRequest" + | "htmx:beforeSwap" + | "htmx:beforeSend" + | "htmx:configRequest" + | "htmx:confirm" + | "htmx:historyCacheError" + | "htmx:historyCacheMiss" + | "htmx:historyCacheMissError" + | "htmx:historyCacheMissLoad" + | "htmx:historyRestore" + | "htmx:load" + | "htmx:noSSESourceError" + | "htmx:onLoadError" + | "htmx:oobAfterSwap" + | "htmx:oobBeforeSwap" + | "htmx:oobErrorNoTarget" + | "htmx:prompt" + | "htmx:pushedIntoHistory" + | "htmx:responseError" + | "htmx:sendError" + | "htmx:sseError" + | "htmx:sseOpen" + | "htmx:swapError" + | "htmx:targetError" + | "htmx:timeout" + | "htmx:validation:validate" + | "htmx:validation:failed" + | "htmx:validation:halted" + | "htmx:xhr:abort" + | "htmx:xhr:loadend" + | "htmx:xhr:loadstart" + | "htmx:xhr:progress"; + +/** + * https://htmx.org/extensions/#defining + */ +export interface HtmxExtension { + onEvent?: (name: HtmxEvent, evt: CustomEvent) => any; + transformResponse?: (text: any, xhr: XMLHttpRequest, elt: any) => any; + isInlineSwap?: (swapStyle: any) => any; + handleSwap?: ( + swapStyle: any, + target: any, + fragment: any, + settleInfo: any + ) => any; + encodeParameters?: (xhr: XMLHttpRequest, parameters: any, elt: any) => any; +} From 1de3626e907c7f35cf6cb9fbd3bf1c2d369826c2 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 7 May 2024 16:11:20 -0700 Subject: [PATCH 17/28] Further refactoring of the frontend code, just because. --- server/utils/components.py | 26 +- server/vb/components/base_page.css | 10 + server/vb/components/base_page.py | 22 +- server/vb/components/button.css | 20 ++ server/vb/components/button.py | 33 +-- server/vb/components/clipboard.svg | 5 + server/vb/components/clipboard_check.svg | 5 + server/vb/components/footer.css | 63 +++++ server/vb/components/footer.py | 70 +---- server/vb/components/home_page.css | 75 +++++ server/vb/components/home_page.py | 279 +------------------ server/vb/components/logo.py | 97 +------ server/vb/components/logo_specimen.css | 49 ++++ server/vb/components/ongoing_contest.css | 68 +++++ server/vb/components/ongoing_contest.js | 54 ++++ server/vb/components/ongoing_contest.py | 34 +++ server/vb/components/school_page.css | 54 ++++ server/vb/components/school_page.py | 108 +++---- server/vb/components/upcoming_contest.css | 34 +++ server/vb/components/upcoming_contest.py | 19 ++ server/vb/components/validate_email_page.css | 110 ++++++++ server/vb/components/validate_email_page.js | 23 ++ server/vb/components/validate_email_page.py | 178 +----------- server/vb/components/voter_bowl_logo.svg | 42 +++ 24 files changed, 756 insertions(+), 722 deletions(-) create mode 100644 server/vb/components/base_page.css create mode 100644 server/vb/components/button.css create mode 100644 server/vb/components/clipboard.svg create mode 100644 server/vb/components/clipboard_check.svg create mode 100644 server/vb/components/footer.css create mode 100644 server/vb/components/home_page.css create mode 100644 server/vb/components/logo_specimen.css create mode 100644 server/vb/components/ongoing_contest.css create mode 100644 server/vb/components/ongoing_contest.js create mode 100644 server/vb/components/ongoing_contest.py create mode 100644 server/vb/components/school_page.css create mode 100644 server/vb/components/upcoming_contest.css create mode 100644 server/vb/components/upcoming_contest.py create mode 100644 server/vb/components/validate_email_page.css create mode 100644 server/vb/components/validate_email_page.js create mode 100644 server/vb/components/voter_bowl_logo.svg diff --git a/server/utils/components.py b/server/utils/components.py index d95ecdc..0b604ff 100644 --- a/server/utils/components.py +++ b/server/utils/components.py @@ -10,18 +10,15 @@ from pydantic.alias_generators import to_camel -def load_file(file_name: str | pathlib.Path, markup: bool) -> str: +def load_file(file_name: str | pathlib.Path) -> str: """Load a text file and return its contents.""" with open(file_name, "r") as f: - text = f.read() - return Markup(text) if markup else text + return f.read() -def load_sibling_file( - base_file_name: str | pathlib.Path, file_name: str, markup: bool -) -> str: +def load_sibling_file(base_file_name: str | pathlib.Path, file_name: str) -> str: """Load a file in the same directory as the base file.""" - return load_file(pathlib.Path(base_file_name).resolve().parent / file_name, markup) + return load_file(pathlib.Path(base_file_name).resolve().parent / file_name) def _css_vars(selector: str, /, **vars: str) -> str: @@ -37,10 +34,10 @@ def style(base_file_name: str | pathlib.Path, file_name: str, **vars: str) -> h. In addition to the file, you can pass in CSS variables to inject into the stylesheet. """ - text = load_sibling_file(base_file_name, file_name, markup=True) + text = load_sibling_file(base_file_name, file_name) if vars: text = _css_vars("me", **vars) + text - return h.style[text] + return h.style[Markup(text)] def js( @@ -65,7 +62,7 @@ def js( CONSIDER: this still feels awkward to me, and I bet there's a cleaner pattern -- our CSS pattern feels very clean to me, for instance. """ - text = load_sibling_file(base_file_name, file_name, markup=True) + text = load_sibling_file(base_file_name, file_name) element = h.script if props: as_camel = {to_camel(k): v for k, v in props.items()} @@ -73,10 +70,15 @@ def js( element = element(data_props=as_json) if surreal: text = f"({text})(me(), me('script').dataset.props && JSON.parse(me('script').dataset.props))" # noqa: E501 - return element[text] + return element[Markup(text)] + + +def svg(base_file_name: str | pathlib.Path, file_name: str) -> Markup: + """Load an SVG file in the same directory as the base file.""" + return Markup(load_sibling_file(base_file_name, file_name)) def markdown_html(base_file_name: str | pathlib.Path, file_name: str) -> Markup: """Load a markdown file in the same directory as the base file.""" - text = load_sibling_file(base_file_name, file_name, markup=False) + text = load_sibling_file(base_file_name, file_name) return Markup(markdown.markdown(text)) diff --git a/server/vb/components/base_page.css b/server/vb/components/base_page.css new file mode 100644 index 0000000..f2da01b --- /dev/null +++ b/server/vb/components/base_page.css @@ -0,0 +1,10 @@ +html { + background-color: var(--bg-color); +} + +.faq { + width: 100%; + color: white; + padding: 2rem 0; + background-color: black; +} \ No newline at end of file diff --git a/server/vb/components/base_page.py b/server/vb/components/base_page.py index 3bdf524..007e1e3 100644 --- a/server/vb/components/base_page.py +++ b/server/vb/components/base_page.py @@ -2,6 +2,8 @@ from django.templatetags.static import static from markupsafe import Markup +from server.utils.components import style + from .faq import faq from .footer import footer from .utils import with_children @@ -28,24 +30,6 @@ def _gtag_scripts() -> h.Node: ] -_STYLE = """ -html { - background-color: {bg_color}; -} - -.faq { - width: 100%; - color: white; - padding: 2rem 0; - background-color: black; -} -""" - - -def _style(bg_color: str) -> h.Element: - return h.style[_STYLE.replace("{bg_color}", bg_color)] - - @with_children def base_page( children: h.Node = None, @@ -72,7 +56,7 @@ def base_page( h.script(src=static("js/htmx.min.js")), h.script(src=static("js/css-scope-inline.js")), h.script(src=static("/js/surreal.js")), - _style(bg_color), + style(__file__, "base_page.css", bg_color=bg_color), extra_head, ], h.body[ diff --git a/server/vb/components/button.css b/server/vb/components/button.css new file mode 100644 index 0000000..11c0fea --- /dev/null +++ b/server/vb/components/button.css @@ -0,0 +1,20 @@ +me { + cursor: pointer; + transition: opacity 0.2s ease-in-out; + text-transform: uppercase; + text-decoration: none; + font-weight: 600; + font-size: 18px; + line-height: 100%; + border: none; + text-align: center; + letter-spacing: 0.05em; + padding: 20px 24px; + background-color: var(--bg-color); + color: var(--color); +} + +me:hover { + opacity: 0.7; + transition: opacity 0.2s ease-in-out; +} \ No newline at end of file diff --git a/server/vb/components/button.py b/server/vb/components/button.py index 059eb13..9d581e2 100644 --- a/server/vb/components/button.py +++ b/server/vb/components/button.py @@ -1,36 +1,13 @@ import htpy as h -from .utils import with_children - -_STYLE = """ -me { - cursor: pointer; - transition: opacity 0.2s ease-in-out; - text-transform: uppercase; - text-decoration: none; - font-weight: 600; - font-size: 18px; - line-height: 100%; - border: none; - text-align: center; - letter-spacing: 0.05em; - padding: 20px 24px; - background-color: {bg_color}; - color: {color}; -} - -me:hover { - opacity: 0.7; - transition: opacity 0.2s ease-in-out; -} -""" +from server.utils.components import style - -def _style(bg_color: str, color: str) -> h.Element: - return h.style[_STYLE.replace("{bg_color}", bg_color).replace("{color}", color)] +from .utils import with_children @with_children def button(children: h.Node, href: str, bg_color: str, color: str) -> h.Element: """Render a button with the given background and text color.""" - return h.a(href=href)[_style(bg_color, color), children] + return h.a(href=href)[ + style(__file__, "button.css", bg_color=bg_color, color=color), children + ] diff --git a/server/vb/components/clipboard.svg b/server/vb/components/clipboard.svg new file mode 100644 index 0000000..2a574bb --- /dev/null +++ b/server/vb/components/clipboard.svg @@ -0,0 +1,5 @@ + + + diff --git a/server/vb/components/clipboard_check.svg b/server/vb/components/clipboard_check.svg new file mode 100644 index 0000000..5b853d3 --- /dev/null +++ b/server/vb/components/clipboard_check.svg @@ -0,0 +1,5 @@ + + + diff --git a/server/vb/components/footer.css b/server/vb/components/footer.css new file mode 100644 index 0000000..0f4b027 --- /dev/null +++ b/server/vb/components/footer.css @@ -0,0 +1,63 @@ +me { + background-color: black; + color: #aaa; + padding-top: 4rem; + padding-bottom: 2rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + width: 100%; +} + +@media screen and (min-width: 768px) { + me { + padding-left: 2em; + padding-right: 2rem; + } +} + +me div.center { + margin-bottom: 2em; + display: flex; + justify-content: center; + color: #fff; +} + +me div.center svg { + width: 120px !important; +} + +me div.outer { + display: flex; + flex-direction: column-reverse; + justify-content: space-between; + align-items: center; +} + +@media screen and (min-width: 768px) { + me div.outer { + flex-direction: row; + } +} + +me div.inner { + display: flex; + flex-direction: row; + gap: 1em; +} + +me a { + color: #aaa; + text-decoration: underline; +} + +me a:hover { + color: white; +} + +me .colophon { + text-align: center; + color: #888; + font-size: 0.8em; + padding-top: 1em; + padding-bottom: 3em; +} \ No newline at end of file diff --git a/server/vb/components/footer.py b/server/vb/components/footer.py index 6dc8f48..e7e65ce 100644 --- a/server/vb/components/footer.py +++ b/server/vb/components/footer.py @@ -1,79 +1,15 @@ import htpy as h from django.urls import reverse -from .logo import VOTER_BOWL_LOGO - -_STYLE = """ -me { - background-color: black; - color: #aaa; - padding-top: 4rem; - padding-bottom: 2rem; - padding-left: 0.5rem; - padding-right: 0.5rem; - width: 100%; -} - -@media screen and (min-width: 768px) { - me { - padding-left: 2em; - padding-right: 2rem; - } -} - -me div.center { - margin-bottom: 2em; - display: flex; - justify-content: center; - color: #fff; -} - -me div.center svg { - width: 120px !important; -} - -me div.outer { - display: flex; - flex-direction: column-reverse; - justify-content: space-between; - align-items: center; -} +from server.utils.components import style -@media screen and (min-width: 768px) { - me div.outer { - flex-direction: row; - } -} - -me div.inner { - display: flex; - flex-direction: row; - gap: 1em; -} - -me a { - color: #aaa; - text-decoration: underline; -} - -me a:hover { - color: white; -} - -me .colophon { - text-align: center; - color: #888; - font-size: 0.8em; - padding-top: 1em; - padding-bottom: 3em; -} -""" +from .logo import VOTER_BOWL_LOGO def footer() -> h.Element: """Render the site-wide footer.""" return h.footer[ - h.style[_STYLE], + style(__file__, "footer.css"), h.div(".center")[VOTER_BOWL_LOGO], h.div(".outer")[ h.p(".copyright")["© 2024 The Voter Bowl"], diff --git a/server/vb/components/home_page.css b/server/vb/components/home_page.css new file mode 100644 index 0000000..6cd6feb --- /dev/null +++ b/server/vb/components/home_page.css @@ -0,0 +1,75 @@ +me { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + background-color: #cdff64; + color: black; +} + +me main { + width: 100%; + text-align: center; + padding: 2rem 0; +} + +me main svg { + width: 104px; + margin: 1.5rem 0; +} + +@media screen and (min-width: 768px) { + me main svg { + width: 112px; + } +} + +me main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +me main h2 { + font-weight: 500; + font-size: 28px; + line-height: 140%; +} + +@media screen and (min-width: 768px) { + me main h2 { + font-size: 32px; + } +} + +me .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +me .ongoing { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2rem; + margin: 2rem 0; +} + +me .upcoming { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5rem; + margin: 0.5rem 0; +} + +me .coming-soon { + text-transform: uppercase; + font-weight: bold; + font-size: 20px; + line-height: 130%; + display: flex; + justify-content: center; + margin: 1.5rem 0; +} \ No newline at end of file diff --git a/server/vb/components/home_page.py b/server/vb/components/home_page.py index ddc86da..b13b95b 100644 --- a/server/vb/components/home_page.py +++ b/server/vb/components/home_page.py @@ -1,295 +1,32 @@ import typing as t import htpy as h -from markupsafe import Markup + +from server.utils.components import style from ..models import Contest from .base_page import base_page -from .button import button -from .logo import VOTER_BOWL_LOGO, school_logo - -_COUNTDOWN_JS = Markup(""" -(function(self) { - function countdown(self) { - // compute the deadline - const deadline = new Date(self.dataset.endAt); - const deadlineTime = deadline.getTime(); - - /** Update the countdown. */ - function updateCountdown() { - const now = new Date().getTime(); - const diff = deadlineTime - now; - - if (diff <= 0) { - clearInterval(interval); - self.innerText = "Just ended!"; - return; - } - - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - - const h0digit = Math.floor(hours / 10); - const h1digit = hours % 10; - const m0digit = Math.floor(minutes / 10); - const m1digit = minutes % 10; - const s0digit = Math.floor(seconds / 10); - const s1digit = seconds % 10; - - const endsIn = `Ends in ${h0digit}${h1digit}:${m0digit}${m1digit}:${s0digit}${s1digit}`; - self.innerText = endsIn; - } - - updateCountdown(); - const interval = setInterval(updateCountdown, 1000); - } - - onloadAdd(() => countdown(self)); -})(me()); -""") - - -def _ongoing_style(logo_bg_color: str) -> str: - return """ -me { - border: 3px solid black; - color: black; - font-weight: 400; - font-size: 18px; - line-height: 140%; - padding-left: 1em; - padding-right: 1em; - position: relative; -} - -me .content { - display: flex; - flex-direction: column; -} - -me .logo { - border-radius: 100%; - border: 2px solid black; - background-color: {logo_bg_color}; - overflow: hidden; - width: 60px; - height: 60px; - margin: 1.5em auto 1em auto; -} - -me .logo img { - width: 100%; - height: 100%; - object-fit: contain; -} - -me .school { - margin: 0; - font-weight: 500; - font-size: 24px; - line-height: 100%; - display: flex; - justify-content: center; -} - -me .description { - margin-bottom: 0; -} - -me .button-holder { - width: 100%; -} - -me .button-holder a { - width: 100%; -} - -/* A centered box at the top of the card */ -me .box { - position: absolute; - top: -1em; - left: 50%; - transform: translateX(-50%); - border: 3px solid black; - background-color: #cdff64; - font-weight: 600; - line-height: 100%; - letter-spacing: 4%; - min-width: 30%; - padding: 0.25rem; - text-transform: uppercase; -} -""".replace("{logo_bg_color}", logo_bg_color) - - -def _ongoing_contest(contest: Contest) -> h.Element: - return h.div[ - h.style[_ongoing_style(contest.school.logo.bg_color)], - h.div(".content")[ - school_logo(contest.school), - h.p(".school")[contest.school.name], - h.p(".description")[ - "Check your voter registration status", - None if contest.is_giveaway else f" for a 1 in {contest.in_n} chance", - f" to win a ${contest.amount} Amazon gift card.", - ], - h.div(".button-holder")[ - button( - href=contest.school.relative_url, bg_color="black", color="white" - )["Visit event"] - ], - ], - h.div(".box", data_end_at=contest.end_at.isoformat())[ - h.script[_COUNTDOWN_JS], "Ends in ..." - ], - ] +from .logo import VOTER_BOWL_LOGO +from .ongoing_contest import ongoing_contest +from .upcoming_contest import upcoming_contest def _ongoing_contests(contests: list[Contest]) -> h.Node: """Render a list of ongoing contests.""" if contests: - return h.div(".ongoing")[(_ongoing_contest(contest) for contest in contests)] + return h.div(".ongoing")[(ongoing_contest(contest) for contest in contests)] return None -def _upcoming_style(logo_bg_color: str) -> str: - return """ -me { - border: 3px solid black; - padding: 1rem; - color: black; - font-size: 18px; - font-weight: 440; - font-variation-settings: "wght" 440; - line-height: 1; -} - -me .content { - display: flex; - align-items: center; - gap: 1em; -} - -me .logo { - border-radius: 100%; - border: 2px solid black; - background-color: {logo_bg_color}; - overflow: hidden; - width: 36px; - height: 36px; -} - -me .logo img { - width: 100%; - height: 100%; - object-fit: contain; -} - -me p { - margin: 0; -} -""".replace("{logo_bg_color}", logo_bg_color) - - -def _upcoming_contest(contest: Contest) -> h.Element: - return h.div[ - h.style[_upcoming_style(contest.school.logo.bg_color)], - h.div(".content")[ - school_logo(contest.school), - h.p(".school")[contest.school.name], - ], - ] - - def _upcoming_contests(contests: list[Contest]) -> h.Node: if contests: return [ h.p(".coming-soon")["Coming Soon"], - h.div(".upcoming")[(_upcoming_contest(contest) for contest in contests)], + h.div(".upcoming")[(upcoming_contest(contest) for contest in contests)], ] return None -_STYLE = """ -me { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - background-color: #cdff64; - color: black; -} - -me main { - width: 100%; - text-align: center; - padding: 2rem 0; -} - -me main svg { - width: 104px; - margin: 1.5rem 0; -} - -@media screen and (min-width: 768px) { - me main svg { - width: 112px; - } -} - -me main p { - font-weight: 378; - font-size: 20px; - line-height: 130%; -} - -me main h2 { - font-weight: 500; - font-size: 28px; - line-height: 140%; -} - -@media screen and (min-width: 768px) { - me main h2 { - font-size: 32px; - } -} - -me .button-holder { - display: flex; - justify-content: center; - margin: 1.5rem 0; -} - -me .ongoing { - display: flex; - flex-direction: column; - justify-content: center; - gap: 2rem; - margin: 2rem 0; -} - -me .upcoming { - display: flex; - flex-direction: column; - justify-content: center; - gap: 0.5rem; - margin: 0.5rem 0; -} - -me .coming-soon { - text-transform: uppercase; - font-weight: bold; - font-size: 20px; - line-height: 130%; - display: flex; - justify-content: center; - margin: 1.5rem 0; -} -""" - - def home_page( ongoing_contests: t.Iterable[Contest], upcoming_contests: t.Iterable[Contest], @@ -300,7 +37,7 @@ def home_page( return base_page[ h.div[ - h.style[_STYLE], + style(__file__, "home_page.css"), h.main[ h.div(".container")[ h.div(".center")[VOTER_BOWL_LOGO], diff --git a/server/vb/components/logo.py b/server/vb/components/logo.py index 6210791..bdd8b4f 100644 --- a/server/vb/components/logo.py +++ b/server/vb/components/logo.py @@ -1,30 +1,10 @@ import htpy as h -from markupsafe import Markup + +from server.utils.components import style, svg from ..models import Logo, School -VOTER_BOWL_LOGO = Markup(""" - - - - - - - - - - - - - - - - - - - - -""") +VOTER_BOWL_LOGO = svg(__file__, "voter_bowl_logo.svg") def school_logo(school: School) -> h.Element: @@ -37,72 +17,17 @@ def school_logo(school: School) -> h.Element: ] -_LOGO_STYLE = """ -me { - display: flex; - gap: 0.5rem; -} - -me .bubble { - display: flex; - align-items: center; - overflow: hidden; - width: 48px; - height: 48px; - background-color: {logo_bg_color}; -} - -me .bubble img { - display: block; - margin: 0 auto; - max-width: 80%; - max-height: 80%; -} - -me .bg { - display: flex; - font-weight: 600; - align-items: center; - justify-content: center; - padding-left: 0.5rem; - padding-right: 0.5rem; - background-color: {logo_bg_color}; - color: {logo_bg_text_color}; -} - -me .action { - cursor: pointer; - display: flex; - font-weight: 600; - align-items: center; - justify-content: center; - padding-left: 0.5rem; - padding-right: 0.5rem; - transition: opacity 0.2s; - background-color: {logo_action_color}; - color: {logo_action_text_color}; -} - -me .action:hover { - opacity: 0.7; - transition: opacity 0.2s ease-in-out; -} -""" - - -def _style(logo: Logo) -> h.Element: - return h.style[ - _LOGO_STYLE.replace("{logo_bg_color}", logo.bg_color) - .replace("{logo_bg_text_color}", logo.bg_text_color) - .replace("{logo_action_color}", logo.action_color) - .replace("{logo_action_text_color}", logo.action_text_color) - ] - - def logo_specimen(logo: Logo) -> h.Element: """Render a school's logo as a specimen for our admin views.""" return h.div[ - _style(logo), + style( + __file__, + "logo_specimen.css", + logo_bg_color=logo.bg_color, + logo_bg_text_color=logo.bg_text_color, + logo_action_color=logo.action_color, + logo_action_text_color=logo.action_text_color, + ), h.div(".bubble")[h.img(src=logo.url, alt="logo")], h.div(".bg")["text"], h.div(".action")["action"], diff --git a/server/vb/components/logo_specimen.css b/server/vb/components/logo_specimen.css new file mode 100644 index 0000000..fa1ec29 --- /dev/null +++ b/server/vb/components/logo_specimen.css @@ -0,0 +1,49 @@ +me { + display: flex; + gap: 0.5rem; +} + +me .bubble { + display: flex; + align-items: center; + overflow: hidden; + width: 48px; + height: 48px; + background-color: var(--logo-bg-color); +} + +me .bubble img { + display: block; + margin: 0 auto; + max-width: 80%; + max-height: 80%; +} + +me .bg { + display: flex; + font-weight: 600; + align-items: center; + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + background-color: var(--logo-bg-color); + color: var(--logo-bg-text-color); +} + +me .action { + cursor: pointer; + display: flex; + font-weight: 600; + align-items: center; + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + transition: opacity 0.2s; + background-color: var(--logo-action-color); + color: var(--logo-action_text_color); +} + +me .action:hover { + opacity: 0.7; + transition: opacity 0.2s ease-in-out; +} diff --git a/server/vb/components/ongoing_contest.css b/server/vb/components/ongoing_contest.css new file mode 100644 index 0000000..148623e --- /dev/null +++ b/server/vb/components/ongoing_contest.css @@ -0,0 +1,68 @@ +me { + border: 3px solid black; + color: black; + font-weight: 400; + font-size: 18px; + line-height: 140%; + padding-left: 1em; + padding-right: 1em; + position: relative; +} + +me .content { + display: flex; + flex-direction: column; +} + +me .logo { + border-radius: 100%; + border: 2px solid black; + background-color: var(--logo-bg-color); + overflow: hidden; + width: 60px; + height: 60px; + margin: 1.5em auto 1em auto; +} + +me .logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +me .school { + margin: 0; + font-weight: 500; + font-size: 24px; + line-height: 100%; + display: flex; + justify-content: center; +} + +me .description { + margin-bottom: 0; +} + +me .button-holder { + width: 100%; +} + +me .button-holder a { + width: 100%; +} + +/* A centered box at the top of the card */ +me .box { + position: absolute; + top: -1em; + left: 50%; + transform: translateX(-50%); + border: 3px solid black; + background-color: #cdff64; + font-weight: 600; + line-height: 100%; + letter-spacing: 4%; + min-width: 30%; + padding: 0.25rem; + text-transform: uppercase; +} \ No newline at end of file diff --git a/server/vb/components/ongoing_contest.js b/server/vb/components/ongoing_contest.js new file mode 100644 index 0000000..7e8cbc7 --- /dev/null +++ b/server/vb/components/ongoing_contest.js @@ -0,0 +1,54 @@ +/** + * Provide a countdown for an ongoing contest. + * + * @param {HTMLElement} self + * @param {object} props + * @param {string} props.endAt + */ + +function ongoingCountdown(self, props) { + + /** + * + * @param {HTMLElement} self + * @param {object} props + * @param {string} props.endAt + */ + function countdown(self, props) { + // compute the deadline + const { endAt } = props; + const deadline = new Date(endAt); + const deadlineTime = deadline.getTime(); + + /** Update the countdown. */ + function updateCountdown() { + const now = new Date().getTime(); + const diff = deadlineTime - now; + + if (diff <= 0) { + clearInterval(interval); + self.innerText = "Just ended!"; + return; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + const h0digit = Math.floor(hours / 10); + const h1digit = hours % 10; + const m0digit = Math.floor(minutes / 10); + const m1digit = minutes % 10; + const s0digit = Math.floor(seconds / 10); + const s1digit = seconds % 10; + + const endsIn = `Ends in ${h0digit}${h1digit}:${m0digit}${m1digit}:${s0digit}${s1digit}`; + self.innerText = endsIn; + } + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + } + + onloadAdd(() => countdown(self, props)); +} diff --git a/server/vb/components/ongoing_contest.py b/server/vb/components/ongoing_contest.py new file mode 100644 index 0000000..4e61570 --- /dev/null +++ b/server/vb/components/ongoing_contest.py @@ -0,0 +1,34 @@ +import htpy as h + +from server.utils.components import js, style + +from ..models import Contest +from .button import button +from .logo import school_logo + + +def ongoing_contest(contest: Contest) -> h.Element: + """Render an ongoing contest.""" + return h.div[ + style( + __file__, "ongoing_contest.css", logo_bg_color=contest.school.logo.bg_color + ), + h.div(".content")[ + school_logo(contest.school), + h.p(".school")[contest.school.name], + h.p(".description")[ + "Check your voter registration status", + None if contest.is_giveaway else f" for a 1 in {contest.in_n} chance", + f" to win a ${contest.amount} Amazon gift card.", + ], + h.div(".button-holder")[ + button( + href=contest.school.relative_url, bg_color="black", color="white" + )["Visit event"] + ], + ], + h.div(".box", data_end_at=contest.end_at.isoformat())[ + js(__file__, "ongoing_contest.js", ends_at=contest.end_at.isoformat()), + "Ends in ...", + ], + ] diff --git a/server/vb/components/school_page.css b/server/vb/components/school_page.css new file mode 100644 index 0000000..c2325b5 --- /dev/null +++ b/server/vb/components/school_page.css @@ -0,0 +1,54 @@ +me { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +me main { + width: 100%; + text-align: center; + padding-bottom: 2rem; + color: var(--color); + background-color: var(--bg-color); +} + +@media screen and (min-width: 768px) { + me main { + padding: 2rem 0; + } +} + +me main img { + height: 150px; + margin: 1.5rem 0; +} + +me main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +me main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; +} + +me .faq { + width: 100%; + color: white; + padding: 2rem 0; +} + +me .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +me .faq { + background-color: black; +} diff --git a/server/vb/components/school_page.py b/server/vb/components/school_page.py index 18a7758..0b239be 100644 --- a/server/vb/components/school_page.py +++ b/server/vb/components/school_page.py @@ -1,98 +1,51 @@ import htpy as h +from server.utils.components import style + from ..models import Contest, School from .base_page import base_page from .button import button from .countdown import countdown from .logo import school_logo -_STYLE = """ - me { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - } - - me main { - width: 100%; - text-align: center; - padding-bottom: 2rem; - color: {color}; - background-color: {bg_color}; - } - - @media screen and (min-width: 768px) { - me main { - padding: 2rem 0; - } - } - - me main img { - height: 150px; - margin: 1.5rem 0; - } - me main p { - font-weight: 378; - font-size: 20px; - line-height: 130%; - } - - me main h2 { - font-weight: 500; - font-size: 36px; - line-height: 120%; - text-transform: uppercase; - } - - me .faq { - width: 100%; - color: white; - padding: 2rem 0; - } +def _current_contest_info(school: School, contest: Contest) -> h.Node: + return h.p[ + school.short_name, + " students: check your registration status", + f"for a 1 in {contest.in_n } chance", + f"to win a ${contest.amount} Amazon gift card.", + ] - me .button-holder { - display: flex; - justify-content: center; - margin: 1.5rem 0; - } - me .faq { - background-color: black; - } -""" +def _past_contest_info(school: School, contest: Contest) -> h.Node: + return [ + h.p[ + school.short_name, + f" students: the ${contest.amount} ", + "giveaway" if contest.is_giveaway else "contest", + " has ended.", + ], + h.p["But: it's always a good time to make sure you're ready to vote."], + ] -def _style(color: str, bg_color: str) -> h.Element: - return h.style[_STYLE.replace("{color}", color).replace("{bg_color}", bg_color)] +def _no_contest_info(school: School) -> h.Node: + return [ + h.p[school.short_name, " students: there's no contest right now."], + h.p["But: it's always a good time to make sure you're ready to vote."], + ] def _contest_info( school: School, current_contest: Contest | None, past_contest: Contest | None ) -> h.Node: if current_contest: - return h.p[ - school.short_name, - " students: check your registration status", - f"for a 1 in { current_contest.in_n } chance", - f"to win a ${current_contest.amount} Amazon gift card.", - ] + return _current_contest_info(school, current_contest) elif past_contest: - return [ - h.p[ - school.short_name, - f" students: the ${past_contest.amount} ", - "giveaway" if past_contest.is_giveaway else "contest", - " has ended.", - ], - h.p["But: it's always a good time to make sure you're ready to vote."], - ] + return _past_contest_info(school, past_contest) else: - return [ - h.p[school.short_name, " students: there's no contest right now."], - h.p["But: it's always a good time to make sure you're ready to vote."], - ] + return _no_contest_info(school) def school_page( @@ -103,7 +56,12 @@ def school_page( title=f"Voter Bowl x {school.name}", bg_color=school.logo.bg_color )[ h.div[ - _style(bg_color=school.logo.bg_color, color=school.logo.bg_text_color), + style( + __file__, + "school_page.css", + bg_color=school.logo.bg_color, + color=school.logo.bg_text_color, + ), h.main[ h.div(".container")[ countdown(current_contest) if current_contest else None, diff --git a/server/vb/components/upcoming_contest.css b/server/vb/components/upcoming_contest.css new file mode 100644 index 0000000..79768e6 --- /dev/null +++ b/server/vb/components/upcoming_contest.css @@ -0,0 +1,34 @@ +me { + border: 3px solid black; + padding: 1rem; + color: black; + font-size: 18px; + font-weight: 440; + font-variation-settings: "wght" 440; + line-height: 1; +} + +me .content { + display: flex; + align-items: center; + gap: 1em; +} + +me .logo { + border-radius: 100%; + border: 2px solid black; + background-color: var(--logo-bg-color); + overflow: hidden; + width: 36px; + height: 36px; +} + +me .logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +me p { + margin: 0; +} \ No newline at end of file diff --git a/server/vb/components/upcoming_contest.py b/server/vb/components/upcoming_contest.py new file mode 100644 index 0000000..495582d --- /dev/null +++ b/server/vb/components/upcoming_contest.py @@ -0,0 +1,19 @@ +import htpy as h + +from server.utils.components import style + +from ..models import Contest +from .logo import school_logo + + +def upcoming_contest(contest: Contest) -> h.Element: + """Render an upcoming contest.""" + return h.div[ + style( + __file__, "upcoming_contest.css", logo_bg_color=contest.school.logo.bg_color + ), + h.div(".content")[ + school_logo(contest.school), + h.p(".school")[contest.school.name], + ], + ] diff --git a/server/vb/components/validate_email_page.css b/server/vb/components/validate_email_page.css new file mode 100644 index 0000000..d0ef1de --- /dev/null +++ b/server/vb/components/validate_email_page.css @@ -0,0 +1,110 @@ +me { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +me a { + color: var(--main-color); + text-decoration: underline; + transition: opacity 0.2s; +} + +me a:hover { + opacity: 0.7; + transition: opacity 0.2s; +} + +me main { + width: 100%; + text-align: center; + padding: 2rem 0; +} + +me main img { + height: 150px; + margin: 1.5rem 0; +} + +me main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +me main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; +} + +me .faq { + width: 100%; + color: white; + background-color: black; + padding: 2rem 0; +} + +me .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +me main { + color: var(--main-color); + background-color: var(--main-bg-color); +} + +me main h2 { + display: flex; + justify-content: center; + align-items: center; +} + +me main .hidden { + display: none; +} + +me main .code { + font-size: 0.75em; +} + +me main .clipboard, +me main .copied { + margin-left: 0.2em; + margin-top: 0.05em; + width: 0.75em; +} + +me main .clipboard { + opacity: 0.5; + cursor: pointer; + transition: opacity 0.2s; +} + +me main .clipboard:hover { + opacity: 1; + transition: opacity 0.2s; +} + +me main .copied { + opacity: 0.5; +} + +@media screen and (min-width: 768px) { + + me main .clipboard, + me main .copied { + margin-left: 0.2em; + margin-top: 0.2em; + width: 1em; + } + + + me main .code { + font-size: 1em; + } +} diff --git a/server/vb/components/validate_email_page.js b/server/vb/components/validate_email_page.js new file mode 100644 index 0000000..1558225 --- /dev/null +++ b/server/vb/components/validate_email_page.js @@ -0,0 +1,23 @@ +/** + * + * @param {HTMLElement} self + */ +function(self) { + onloadAdd(() => { + const clipboard = self.querySelector(".clipboard"); + const copied = self.querySelector(".copied"); + /** @type {HTMLElement?} */ + const code = self.querySelector(".code"); + if (!clipboard || !copied || !code) { + return; + } + clipboard.addEventListener("click", () => { + navigator.clipboard.writeText(code.innerText); + // hide the `clipboard` span; show the `copied` span + // do this by adding `hidden` class to `clipboard` and + // removing it from `copied` + clipboard.classList.add("hidden"); + copied.classList.remove("hidden"); + }); + }); +} diff --git a/server/vb/components/validate_email_page.py b/server/vb/components/validate_email_page.py index 701ad5c..a14e8f0 100644 --- a/server/vb/components/validate_email_page.py +++ b/server/vb/components/validate_email_page.py @@ -1,178 +1,25 @@ import htpy as h from django.conf import settings from django.urls import reverse -from markupsafe import Markup + +from server.utils.components import js, style, svg from ..models import ContestEntry, School from .base_page import base_page from .logo import school_logo -_STYLE = """ -me { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; -} - -me a { - color: {main_color}; - text-decoration: underline; - transition: opacity 0.2s; -} - -me a:hover { - opacity: 0.7; - transition: opacity 0.2s; -} - -me main { - width: 100%; - text-align: center; - padding: 2rem 0; -} - -me main img { - height: 150px; - margin: 1.5rem 0; -} - -me main p { - font-weight: 378; - font-size: 20px; - line-height: 130%; -} - -me main h2 { - font-weight: 500; - font-size: 36px; - line-height: 120%; - text-transform: uppercase; -} - -me .faq { - width: 100%; - color: white; - background-color: black; - padding: 2rem 0; -} - -me .button-holder { - display: flex; - justify-content: center; - margin: 1.5rem 0; -} - -me main { - color: {main_color}; - background-color: {main_bg_color}; -} - -me main h2 { - display: flex; - justify-content: center; - align-items: center; -} - -me main .hidden { - display: none; -} - -me main .code { - font-size: 0.75em; -} - -me main .clipboard, -me main .copied { - margin-left: 0.2em; - margin-top: 0.05em; - width: 0.75em; -} - -me main .clipboard { - opacity: 0.5; - cursor: pointer; - transition: opacity 0.2s; -} - -me main .clipboard:hover { - opacity: 1; - transition: opacity 0.2s; -} - -me main .copied { - opacity: 0.5; -} - -@media screen and (min-width: 768px) { - - me main .clipboard, - me main .copied { - margin-left: 0.2em; - margin-top: 0.2em; - width: 1em; - } - - - me main .code { - font-size: 1em; - } -} -""" - - -_CLIPBOARD_SVG = Markup(""" - - - -""") - -_CLIPBOARD_CHECK_SVG = Markup(""" - - - -""") - - -def _style(main_color: str, main_bg_color: str) -> h.Element: - return h.style[ - _STYLE.replace("{main_color}", main_color).replace( - "{main_bg_color}", main_bg_color - ) - ] - - -_SCRIPT = h.script[ - Markup(""" -(function(self) { - onloadAdd(() => { - const clipboard = self.querySelector(".clipboard"); - const copied = self.querySelector(".copied"); - const code = self.querySelector(".code"); - if (!clipboard || !copied || !code) { - return; - } - clipboard.addEventListener("click", () => { - navigator.clipboard.writeText(code.innerText); - // hide the `clipboard` span; show the `copied` span - // do this by adding `hidden` class to `clipboard` and - // removing it from `copied` - clipboard.classList.add("hidden"); - copied.classList.remove("hidden"); - }); - }); -})(me()); -""") -] - def _congrats(contest_entry: ContestEntry, claim_code: str) -> h.Node: return [ h.p[f"Congrats! You won a ${contest_entry.amount_won} gift card!"], h.h2[ h.span(".code")[claim_code], - h.span(".clipboard", title="Copy to clipboard")[_CLIPBOARD_SVG], - h.span(".copied hidden", title="Copied!")[_CLIPBOARD_CHECK_SVG], + h.span(".clipboard", title="Copy to clipboard")[ + svg(__file__, "clipboard.svg") + ], + h.span(".copied hidden", title="Copied!")[ + svg(__file__, "clipboard_check.svg") + ], ], h.p[ "To use your gift card, copy the code above and paste it into ", @@ -212,11 +59,14 @@ def validate_email_page( title=f"Voter Bowl x {school.short_name}", bg_color=school.logo.bg_color )[ h.div[ - _style( - main_color=school.logo.bg_text_color, main_bg_color=school.logo.bg_color + style( + __file__, + "validate_email_page.css", + main_color=school.logo.bg_text_color, + main_bg_color=school.logo.bg_color, ), h.main[ - _SCRIPT, + js(__file__, "validate_email_page.js"), h.div(".container")[ school_logo(school), _congrats(contest_entry, claim_code) diff --git a/server/vb/components/voter_bowl_logo.svg b/server/vb/components/voter_bowl_logo.svg new file mode 100644 index 0000000..12b6328 --- /dev/null +++ b/server/vb/components/voter_bowl_logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + From f5b81bbb3b8cd7881df5ce482636f0d948a32656 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 7 May 2024 16:21:59 -0700 Subject: [PATCH 18/28] Several fixes post refactoring. --- server/utils/components.py | 4 ++-- server/vb/components/check_page.css | 3 ++- server/vb/components/check_page.py | 2 +- server/vb/components/ongoing_contest.js | 8 ++++---- server/vb/components/ongoing_contest.py | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/server/utils/components.py b/server/utils/components.py index 0b604ff..40273d9 100644 --- a/server/utils/components.py +++ b/server/utils/components.py @@ -23,7 +23,7 @@ def load_sibling_file(base_file_name: str | pathlib.Path, file_name: str) -> str def _css_vars(selector: str, /, **vars: str) -> str: """Generate CSS variables to inject into a stylesheet.""" - as_css = "\n".join(" --{k.replace('_', '-')}: {v};" for k, v in vars.items()) + as_css = "\n".join(f" --{k.replace('_', '-')}: {v};" for k, v in vars.items()) return f"{selector} {{\n{as_css}\n}}\n" @@ -69,7 +69,7 @@ def js( as_json = json.dumps(as_camel) element = element(data_props=as_json) if surreal: - text = f"({text})(me(), me('script').dataset.props && JSON.parse(me('script').dataset.props))" # noqa: E501 + text = f"({text})(me(), me().querySelector('script').dataset.props && JSON.parse(me().querySelector('script').dataset.props))" # noqa: E501 return element[Markup(text)] diff --git a/server/vb/components/check_page.css b/server/vb/components/check_page.css index 18416ca..116e7fa 100644 --- a/server/vb/components/check_page.css +++ b/server/vb/components/check_page.css @@ -92,6 +92,7 @@ me main .urgency { } me main .fireworks { + pointer-events: none; position: absolute; top: 0; left: 0; @@ -112,4 +113,4 @@ me main img { me main .urgency { flex-direction: row; } -} \ No newline at end of file +} diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index 335ea2a..6297037 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -41,7 +41,7 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: countdown(current_contest) if current_contest else h.div(".separate")[ - h.p["Check your voter registraiton status below."] + h.p["Check your voter registration status below."] ], ] ], diff --git a/server/vb/components/ongoing_contest.js b/server/vb/components/ongoing_contest.js index 7e8cbc7..b60ed0d 100644 --- a/server/vb/components/ongoing_contest.js +++ b/server/vb/components/ongoing_contest.js @@ -3,7 +3,7 @@ * * @param {HTMLElement} self * @param {object} props - * @param {string} props.endAt + * @param {string} props.endsAt */ function ongoingCountdown(self, props) { @@ -12,12 +12,12 @@ function ongoingCountdown(self, props) { * * @param {HTMLElement} self * @param {object} props - * @param {string} props.endAt + * @param {string} props.endsAt */ function countdown(self, props) { // compute the deadline - const { endAt } = props; - const deadline = new Date(endAt); + const { endsAt } = props; + const deadline = new Date(endsAt); const deadlineTime = deadline.getTime(); /** Update the countdown. */ diff --git a/server/vb/components/ongoing_contest.py b/server/vb/components/ongoing_contest.py index 4e61570..97a46f8 100644 --- a/server/vb/components/ongoing_contest.py +++ b/server/vb/components/ongoing_contest.py @@ -27,7 +27,7 @@ def ongoing_contest(contest: Contest) -> h.Element: )["Visit event"] ], ], - h.div(".box", data_end_at=contest.end_at.isoformat())[ + h.div(".box")[ js(__file__, "ongoing_contest.js", ends_at=contest.end_at.isoformat()), "Ends in ...", ], From 2c97eb7f288f4d4c59ad836f9dff3412960531b1 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 7 May 2024 16:31:24 -0700 Subject: [PATCH 19/28] Think this refactor works; I've tested most of the paths at this point. Still want to think over how best to factor the javascript, etc. --- server/vb/components/check_page.js | 7 ++----- server/vb/components/finish_check_partial.js | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/server/vb/components/check_page.js b/server/vb/components/check_page.js index 0da8ba5..c178edd 100644 --- a/server/vb/components/check_page.js +++ b/server/vb/components/check_page.js @@ -26,11 +26,8 @@ function checkPage(self) { }; window.addEventListener('VoteAmericaEvent', (event) => { - /** @type {object} - * @property {object} detail - */ - const objEvent = event; - const { data } = objEvent; + // @ts-ignore-next-line + const { data } = event.detail; if (data?.tool === "verify" && data?.event === "action-finish") { setTimeout(() => { finishVerify(data.first_name, data.last_name, data.email); diff --git a/server/vb/components/finish_check_partial.js b/server/vb/components/finish_check_partial.js index 7392ab2..b2850da 100644 --- a/server/vb/components/finish_check_partial.js +++ b/server/vb/components/finish_check_partial.js @@ -2,9 +2,10 @@ function finishCheckPartial(self, props) { const { isWinner } = props; if (isWinner) { + // @ts-ignore-next-line const fireworks = new Fireworks.default(document.querySelector(".fireworks")); fireworks.start(); - setTimeout(() => fireworks.stop(), 10_0000); + setTimeout(() => fireworks.stop(), 10_000); } // smoothly scroll to the top of the page after a slight delay From 19703b60065b805a977d156ef63c5ffc9ce22452 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 9 May 2024 13:21:44 -0700 Subject: [PATCH 20/28] In progress: use js modules and web components. --- .vscode/settings.json | 7 + jsconfig.json | 4 +- server/static/js/surreal.js | 317 ------------------------ server/static/js/voterbowl.mjs | 215 ++++++++++++++++ server/vb/components/base_page.py | 3 +- server/vb/components/countdown.js | 78 ------ server/vb/components/countdown.py | 25 +- server/vb/components/ongoing_contest.js | 54 ---- server/vb/components/ongoing_contest.py | 9 +- 9 files changed, 244 insertions(+), 468 deletions(-) delete mode 100644 server/static/js/surreal.js create mode 100644 server/static/js/voterbowl.mjs delete mode 100644 server/vb/components/countdown.js delete mode 100644 server/vb/components/ongoing_contest.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 7347d39..ea22fcb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,13 @@ "source.fixAll": "explicit" } }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + }, "emmet.includeLanguages": { "django-html": "html" }, diff --git a/jsconfig.json b/jsconfig.json index a817f43..a801064 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { "target": "ES6", - "module": "None", + "module": "ESNext", "checkJs": true, "allowJs": true, "lib": ["dom", "es6"] }, - "include": ["server/**/*.js", "types/htmx.d.ts"] + "include": ["server/**/*.js", "server/**/*.mjs"] } diff --git a/server/static/js/surreal.js b/server/static/js/surreal.js deleted file mode 100644 index e8ebca3..0000000 --- a/server/static/js/surreal.js +++ /dev/null @@ -1,317 +0,0 @@ -// Welcome to Surreal 1.1.8 -// Documentation: https://github.com/gnat/surreal -// Locality of Behavior (LoB): https://htmx.org/essays/locality-of-behaviour/ -let surreal = (function () { -let $ = { // Convenience for internals. - $: this, // Convenience for internals. - plugins: [], - - // Table of contents and convenient call chaining sugar. For a familiar "jQuery like" syntax. 🙂 - // Check before adding new: https://youmightnotneedjquery.com/ - sugar(e) { - if (e == null) { console.warn(`Surreal: Cannot use "${e}". Missing a character?`) } - if (e.hasOwnProperty('hasSurreal')) return e // Surreal already applied - - // General - e.run = (value) => { return $.run(e, value) } - e.remove = () => { return $.remove(e) } - - // Classes and CSS. - e.classAdd = (name) => { return $.classAdd(e, name) } - e.class_add = e.add_class = e.addClass = e.classAdd // Aliases - e.classRemove = (name) => { return $.classRemove(e, name) } - e.class_remove = e.remove_class = e.removeClass = e.classRemove // Aliases - e.classToggle = (name) => { return $.classToggle(e, name) } - e.class_toggle = e.toggle_class = e.toggleClass = e.classToggle // Aliases - e.styles = (value) => { return $.styles(e, value) } - - // Events. - e.on = (name, fn) => { return $.on(e, name, fn) } - e.off = (name, fn) => { return $.off(e, name, fn) } - e.offAll = (name) => { return $.offAll(e, name) } - e.off_all = e.offAll - e.disable = () => { return $.disable(e) } - e.enable = () => { return $.enable(e) } - e.send = (name, detail) => { return $.send(e, name, detail) } - e.trigger = e.send - e.halt = (ev, keepBubbling, keepDefault) => { return $.halt(ev, keepBubbling, keepDefault) } - - // Attributes. - e.attribute = (name, value) => { return $.attribute(e, name, value) } - e.attributes = e.attribute - e.attr = e.attribute - - // Add all plugins. - $.plugins.forEach(function(func) { func(e) }) - - e.hasSurreal = 1 - return e - }, - // Return single element. Selector not needed if used with inline - //
- me(selector=null, start=document, warning=true) { - if (selector == null) return $.sugar(start.currentScript.parentElement) // Just local me() in -// Example: -const onloadAdd = addOnload = onload_add = add_onload = (f) => { - if (typeof window.onload === 'function') { // window.onload already is set, queue functions together (creates a call chain). - let onload_old = window.onload - window.onload = () => { - onload_old() - f() - } - return - } - window.onload = f // window.onload was not set yet. -} -console.log("Surreal: Added shortcuts.") diff --git a/server/static/js/voterbowl.mjs b/server/static/js/voterbowl.mjs new file mode 100644 index 0000000..d378d5d --- /dev/null +++ b/server/static/js/voterbowl.mjs @@ -0,0 +1,215 @@ +import * as htmx from "./htmx.min.js"; + +/*----------------------------------------------------------------- + * Countdown Timer + * -----------------------------------------------------------------*/ + +/** + * Return the time remaining until the given end time as a structure + * containing separate digits. + * + * @typedef {object} RemainingTime + * @property {number} h0 The tens digit of the hours. + * @property {number} h1 The ones digit of the hours. + * @property {number} m0 The tens digit of the minutes. + * @property {number} m1 The ones digit of the minutes. + * @property {number} s0 The tens digit of the seconds. + * @property {number} s1 The ones digit of the seconds. + * + * @param {Date} endAt The end time. + * @returns {RemainingTime|"ended"} The time remaining. + */ +const remainingTime = (endAt) => { + const now = new Date().getTime(); + const diff = endAt.getTime() - now; + + if (diff <= 0) { + return "ended"; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + return { + h0: Math.floor(hours / 10), + h1: hours % 10, + m0: Math.floor(minutes / 10), + m1: minutes % 10, + s0: Math.floor(seconds / 10), + s1: seconds % 10, + }; +}; + +/** + * @description A base class for countdown timers. + */ +class BaseCountdown extends HTMLElement { + /** @type {number|null} */ + #interval = null; + + connectedCallback() { + if (!this.#interval) { + this.#interval = setInterval(() => this.tick(), 1000); + this.tick(); + } + } + + disconnectedCallback() { + if (this.#interval) { + clearInterval(this.#interval); + this.#interval = null; + } + } + + /** @returns {Date} The end time of the countdown. */ + get endAt() { + return new Date(this.dataset.endAt); + } + + tick() { + const remaining = remainingTime(this.endAt); + if (remaining === "ended") { + this.ended(); + } else { + this.update(remaining); + } + } + + /** + * Update the display with positive time remaining. + * + * @param {RemainingTime} remaining The time remaining. + * @returns {void} + */ + update(remaining) { + throw new Error("Not implemented"); + } + + /** + * Update the display with the countdown ended. + * + * @returns {void} + */ + ended() { + throw new Error("Not implemented"); + } +} + +/** + * @description A large-display countdown timer. + * + * The HTML structure is provided in the HTML itself; we don't + * explicitly define it here. + */ +class BigCountdown extends BaseCountdown { + /** @type {HTMLElement|null} */ + #h0 = null; + /** @type {HTMLElement|null} */ + #h1 = null; + /** @type {HTMLElement|null} */ + #m0 = null; + /** @type {HTMLElement|null} */ + #m1 = null; + /** @type {HTMLElement|null} */ + #s0 = null; + /** @type {HTMLElement|null} */ + #s1 = null; + + connectedCallback() { + this.#h0 = this.querySelector("[data-number=h0]"); + this.#h1 = this.querySelector("[data-number=h1]"); + this.#m0 = this.querySelector("[data-number=m0]"); + this.#m1 = this.querySelector("[data-number=m1]"); + this.#s0 = this.querySelector("[data-number=s0]"); + this.#s1 = this.querySelector("[data-number=s1]"); + + // if any of the numbers are missing, don't start the countdown + const numbers = [ + this.#h0, + this.#h1, + this.#m0, + this.#m1, + this.#s0, + this.#s1, + ]; + if (numbers.some((number) => !number)) { + return; + } + + super.connectedCallback(); + } + + /** + * Update the display with positive time remaining. + * + * @param {RemainingTime} remaining The time remaining. + * @returns {void} + */ + update(remaining) { + this.#h0.innerText = remaining.h0.toString(); + this.#h1.innerText = remaining.h1.toString(); + this.#m0.innerText = remaining.m0.toString(); + this.#m1.innerText = remaining.m1.toString(); + this.#s0.innerText = remaining.s0.toString(); + this.#s1.innerText = remaining.s1.toString(); + } + + /** + * Update the display with the countdown ended. + * + * @returns {void} + */ + ended() { + this.#h0.innerText = "0"; + this.#h1.innerText = "0"; + this.#m0.innerText = "0"; + this.#m1.innerText = "0"; + this.#s0.innerText = "0"; + this.#s1.innerText = "0"; + } +} + +/** + * @description A small-display countdown timer. + * + * The HTML structure is provided in the HTML itself; we don't + * explicitly define it here. + */ +class SmallCountdown extends BaseCountdown { + /** @type {HTMLElement|null} */ + #countdown = null; + + connectedCallback() { + this.#countdown = this.querySelector(".countdown"); + + if (!this.#countdown) { + return; + } + + super.connectedCallback(); + } + + /** + * Update the display with positive time remaining. + * + * @param {RemainingTime} remaining The time remaining. + * @returns {void} + */ + update(remaining) { + this.#countdown.innerText = `Ends in ${remaining.h0}${remaining.h1}:${remaining.m0}${remaining.m1}:${remaining.s0}${remaining.s1}`; + } + + /** + * Update the display with the countdown ended. + * + * @returns {void} + */ + ended() { + this.#countdown.innerText = "Just ended!"; + } +} + +// register the custom elements +customElements.define("big-countdown", BigCountdown); +customElements.define("small-countdown", SmallCountdown); diff --git a/server/vb/components/base_page.py b/server/vb/components/base_page.py index 007e1e3..ad48d27 100644 --- a/server/vb/components/base_page.py +++ b/server/vb/components/base_page.py @@ -53,9 +53,8 @@ def base_page( h.meta(name="format-detection", content="telephone=no"), h.link(rel="stylesheet", href=static("/css/modern-normalize.min.css")), h.link(rel="stylesheet", href=static("/css/base.css")), - h.script(src=static("js/htmx.min.js")), h.script(src=static("js/css-scope-inline.js")), - h.script(src=static("/js/surreal.js")), + h.script(src=static("js/voterbowl.mjs"), type="module"), style(__file__, "base_page.css", bg_color=bg_color), extra_head, ], diff --git a/server/vb/components/countdown.js b/server/vb/components/countdown.js deleted file mode 100644 index 19f66a2..0000000 --- a/server/vb/components/countdown.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Outer wrapper for countdown code. - * - * @param {HTMLElement} self - * @param {object} props - * @param {string} props.endAt - */ - -function countdownOuter(self, props) { - /** - * Countdown to a deadline. - * - * @param {HTMLElement} self element containing the countdown. - * @param {object} props properties of the countdown. - * @param {string} props.endAt deadline of the countdown. - * @returns {void} - */ - function countdown(self, props) { - // compute the deadline - const { endAt } = props; - const deadline = new Date(endAt); - const deadlineTime = deadline.getTime(); - - /** Update the countdown. */ - function updateCountdown() { - const now = new Date().getTime(); - const diff = deadlineTime - now; - - // get the number elements - /** @type {HTMLElement} */ - const h0 = self.querySelector('[data-number=h0]'); - /** @type {HTMLElement} */ - const h1 = self.querySelector('[data-number=h1]'); - /** @type {HTMLElement} */ - const m0 = self.querySelector('[data-number=m0]'); - /** @type {HTMLElement} */ - const m1 = self.querySelector('[data-number=m1]'); - /** @type {HTMLElement} */ - const s0 = self.querySelector('[data-number=s0]'); - /** @type {HTMLElement} */ - const s1 = self.querySelector('[data-number=s1]'); - const numbers = [h0, h1, m0, m1, s0, s1]; - - if (numbers.some(number => !number)) { - return; - } - - if (diff <= 0) { - clearInterval(interval); - numbers.forEach(number => number.textContent = '0'); - return; - } - - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - - const h0digit = Math.floor(hours / 10); - const h1digit = hours % 10; - const m0digit = Math.floor(minutes / 10); - const m1digit = minutes % 10; - const s0digit = Math.floor(seconds / 10); - const s1digit = seconds % 10; - - numbers[0].innerText = h0digit.toString(); - numbers[1].innerText = h1digit.toString(); - numbers[2].innerText = m0digit.toString(); - numbers[3].innerText = m1digit.toString(); - numbers[4].innerText = s0digit.toString(); - numbers[5].innerText = s1digit.toString(); - } - - updateCountdown(); - const interval = setInterval(updateCountdown, 1000); - } - - onloadAdd(() => countdown(self, props)); -} diff --git a/server/vb/components/countdown.py b/server/vb/components/countdown.py index 34a0f58..3f9a03b 100644 --- a/server/vb/components/countdown.py +++ b/server/vb/components/countdown.py @@ -1,9 +1,11 @@ import htpy as h -from server.utils.components import js, style +from server.utils.components import style from ..models import Contest +big_countdown = h.Element("big-countdown", {}, None) + def countdown(contest: Contest) -> h.Element: """Render a countdown timer for the given contest.""" @@ -22,15 +24,16 @@ def countdown(contest: Contest) -> h.Element: "giveaway " if contest.is_giveaway else "contest ", "ends in:", ], - h.div(".countdown")[ - js(__file__, "countdown.js", end_at=contest.end_at.isoformat()), - h.span(".number", data_number="h0"), - h.span(".number", data_number="h1"), - h.span(".colon")[":"], - h.span(".number", data_number="m0"), - h.span(".number", data_number="m1"), - h.span(".colon")[":"], - h.span(".number", data_number="s0"), - h.span(".number", data_number="s1"), + big_countdown(data_end_at=contest.end_at.isoformat())[ + h.div(".countdown")[ + h.span(".number", data_number="h0"), + h.span(".number", data_number="h1"), + h.span(".colon")[":"], + h.span(".number", data_number="m0"), + h.span(".number", data_number="m1"), + h.span(".colon")[":"], + h.span(".number", data_number="s0"), + h.span(".number", data_number="s1"), + ] ], ] diff --git a/server/vb/components/ongoing_contest.js b/server/vb/components/ongoing_contest.js deleted file mode 100644 index b60ed0d..0000000 --- a/server/vb/components/ongoing_contest.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Provide a countdown for an ongoing contest. - * - * @param {HTMLElement} self - * @param {object} props - * @param {string} props.endsAt - */ - -function ongoingCountdown(self, props) { - - /** - * - * @param {HTMLElement} self - * @param {object} props - * @param {string} props.endsAt - */ - function countdown(self, props) { - // compute the deadline - const { endsAt } = props; - const deadline = new Date(endsAt); - const deadlineTime = deadline.getTime(); - - /** Update the countdown. */ - function updateCountdown() { - const now = new Date().getTime(); - const diff = deadlineTime - now; - - if (diff <= 0) { - clearInterval(interval); - self.innerText = "Just ended!"; - return; - } - - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - - const h0digit = Math.floor(hours / 10); - const h1digit = hours % 10; - const m0digit = Math.floor(minutes / 10); - const m1digit = minutes % 10; - const s0digit = Math.floor(seconds / 10); - const s1digit = seconds % 10; - - const endsIn = `Ends in ${h0digit}${h1digit}:${m0digit}${m1digit}:${s0digit}${s1digit}`; - self.innerText = endsIn; - } - - updateCountdown(); - const interval = setInterval(updateCountdown, 1000); - } - - onloadAdd(() => countdown(self, props)); -} diff --git a/server/vb/components/ongoing_contest.py b/server/vb/components/ongoing_contest.py index 97a46f8..5c533e8 100644 --- a/server/vb/components/ongoing_contest.py +++ b/server/vb/components/ongoing_contest.py @@ -1,11 +1,13 @@ import htpy as h -from server.utils.components import js, style +from server.utils.components import style from ..models import Contest from .button import button from .logo import school_logo +small_countdown = h.Element("small-countdown", {}, None) + def ongoing_contest(contest: Contest) -> h.Element: """Render an ongoing contest.""" @@ -27,8 +29,7 @@ def ongoing_contest(contest: Contest) -> h.Element: )["Visit event"] ], ], - h.div(".box")[ - js(__file__, "ongoing_contest.js", ends_at=contest.end_at.isoformat()), - "Ends in ...", + small_countdown(data_end_at=contest.end_at.isoformat())[ + h.div(".box countdown")[""] ], ] From c6d7fba6fba59d3b0d67f8e22dd7b532aab18858 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 10 May 2024 10:05:20 -0700 Subject: [PATCH 21/28] WIP --- jsconfig.json | 1 + server/static/img/voter_bowl_logo.svg | 20 -- server/static/js/voterbowl.mjs | 201 ++++++++++++++++---- server/vb/components/check_page.js | 37 ---- server/vb/components/check_page.py | 63 +++--- server/vb/components/validate_email_page.js | 23 --- server/vb/components/validate_email_page.py | 19 +- 7 files changed, 212 insertions(+), 152 deletions(-) delete mode 100644 server/static/img/voter_bowl_logo.svg delete mode 100644 server/vb/components/check_page.js delete mode 100644 server/vb/components/validate_email_page.js diff --git a/jsconfig.json b/jsconfig.json index a801064..8bf6883 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "checkJs": true, "allowJs": true, + "strictNullChecks": true, "lib": ["dom", "es6"] }, "include": ["server/**/*.js", "server/**/*.mjs"] diff --git a/server/static/img/voter_bowl_logo.svg b/server/static/img/voter_bowl_logo.svg deleted file mode 100644 index c564e7a..0000000 --- a/server/static/img/voter_bowl_logo.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/server/static/js/voterbowl.mjs b/server/static/js/voterbowl.mjs index d378d5d..7ec3656 100644 --- a/server/static/js/voterbowl.mjs +++ b/server/static/js/voterbowl.mjs @@ -1,7 +1,129 @@ import * as htmx from "./htmx.min.js"; /*----------------------------------------------------------------- - * Countdown Timer + * Check Page Component + * -----------------------------------------------------------------*/ + +/** + * @typedef {object} VoteAmericaData + * @property {string?} tool The tool that sent the event. + * @property {string?} event The event that was sent. + * @property {string?} first_name The first name of the user. + * @property {string?} last_name The last name of the user. + * @property {string?} email The email of the user. + */ + +/** + * @typedef {object} VoteAmericaDetail + * @property {VoteAmericaData?} data The data of the event. + */ + +class CheckPage extends HTMLElement { + connectedCallback() { + window.addEventListener("VoteAmericaEvent", this.handleVoteAmericaEvent); + } + + disconnectedCallback() { + window.removeEventListener("VoteAmericaEvent", this.handleVoteAmericaEvent); + } + + /** + * Listen for the VoteAmericaEvent and, if appropriate, finish the verify. + * + * @param {CustomEvent} event + */ + handleVoteAmericaEvent = (event) => { + const { data } = event.detail; + if (!data) return; + if (data?.tool === "verify" && data?.event === "action-finish") { + if (!data.first_name || !data.last_name || !data.email) { + console.error("Missing data in event"); + return; + } + this.finishVerify(data.first_name, data.last_name, data.email); + } + }; + + /** + * Finalize a verify and, possibly, mint a new gift card if all is well. + * + * @param {string} first_name + * @param {string} last_name + * @param {string} email + * @returns {Promise} + */ + async finishVerify(first_name, last_name, email) { + /** @type {HTMLElement|null} */ + const urgency = this.querySelector(".urgency"); + if (!urgency) { + console.error("Missing urgency element"); + return; + } + try { + await htmx.ajax("POST", "./finish/", { + target: urgency, + values: { + first_name, + last_name, + email, + }, + }); + } catch (error) { + console.error(error); + } + } +} + +customElements.define("check-page", CheckPage); + +/*----------------------------------------------------------------- + * Gift code clipboard behavior + * -----------------------------------------------------------------*/ + +class GiftCode extends HTMLElement { + /** @type {HTMLElement} */ + #code; + /** @type {HTMLElement} */ + #clipboard; + /** @type {HTMLElement} */ + #copied; + + connectedCallback() { + /** @type {HTMLElement|null} */ + const code = this.querySelector(".code"); + /** @type {HTMLElement|null} */ + const clipboard = this.querySelector(".clipboard"); + /** @type {HTMLElement|null} */ + const copied = this.querySelector(".copied"); + + if (!code || !clipboard || !copied) { + return; + } + + this.#code = code; + this.#clipboard = clipboard; + this.#copied = copied; + + this.#clipboard.addEventListener("click", this.handleClick); + } + + disconnectedCallback() { + if (this.#clipboard) { + this.#clipboard.removeEventListener("click", this.handleClick); + } + } + + handleClick = () => { + navigator.clipboard.writeText(this.#code.innerText); + this.#clipboard.classList.add("hidden"); + this.#copied.classList.remove("hidden"); + }; +} + +customElements.define("gift-code", GiftCode); + +/*----------------------------------------------------------------- + * Countdown Timers * -----------------------------------------------------------------*/ /** @@ -64,7 +186,11 @@ class BaseCountdown extends HTMLElement { /** @returns {Date} The end time of the countdown. */ get endAt() { - return new Date(this.dataset.endAt); + const endAt = this.dataset.endAt; + if (!endAt) { + throw new Error("Missing endAt attribute"); + } + return new Date(endAt); } tick() { @@ -103,40 +229,45 @@ class BaseCountdown extends HTMLElement { * explicitly define it here. */ class BigCountdown extends BaseCountdown { - /** @type {HTMLElement|null} */ - #h0 = null; - /** @type {HTMLElement|null} */ - #h1 = null; - /** @type {HTMLElement|null} */ - #m0 = null; - /** @type {HTMLElement|null} */ - #m1 = null; - /** @type {HTMLElement|null} */ - #s0 = null; - /** @type {HTMLElement|null} */ - #s1 = null; + /** @type {HTMLElement} */ + #h0; + /** @type {HTMLElement} */ + #h1; + /** @type {HTMLElement} */ + #m0; + /** @type {HTMLElement} */ + #m1; + /** @type {HTMLElement} */ + #s0; + /** @type {HTMLElement} */ + #s1; connectedCallback() { - this.#h0 = this.querySelector("[data-number=h0]"); - this.#h1 = this.querySelector("[data-number=h1]"); - this.#m0 = this.querySelector("[data-number=m0]"); - this.#m1 = this.querySelector("[data-number=m1]"); - this.#s0 = this.querySelector("[data-number=s0]"); - this.#s1 = this.querySelector("[data-number=s1]"); + /** @type {HTMLElement|null} */ + const h0 = this.querySelector("[data-number=h0]"); + /** @type {HTMLElement|null} */ + const h1 = this.querySelector("[data-number=h1]"); + /** @type {HTMLElement|null} */ + const m0 = this.querySelector("[data-number=m0]"); + /** @type {HTMLElement|null} */ + const m1 = this.querySelector("[data-number=m1]"); + /** @type {HTMLElement|null} */ + const s0 = this.querySelector("[data-number=s0]"); + /** @type {HTMLElement|null} */ + const s1 = this.querySelector("[data-number=s1]"); // if any of the numbers are missing, don't start the countdown - const numbers = [ - this.#h0, - this.#h1, - this.#m0, - this.#m1, - this.#s0, - this.#s1, - ]; - if (numbers.some((number) => !number)) { + if (!h0 || !h1 || !m0 || !m1 || !s0 || !s1) { return; } + this.#h0 = h0; + this.#h1 = h1; + this.#m0 = m0; + this.#m1 = m1; + this.#s0 = s0; + this.#s1 = s1; + super.connectedCallback(); } @@ -177,16 +308,18 @@ class BigCountdown extends BaseCountdown { * explicitly define it here. */ class SmallCountdown extends BaseCountdown { - /** @type {HTMLElement|null} */ - #countdown = null; + /** @type {HTMLElement} */ + #countdown; connectedCallback() { - this.#countdown = this.querySelector(".countdown"); - - if (!this.#countdown) { + /** @type {HTMLElement|null} */ + const countdown = this.querySelector(".countdown"); + if (!countdown) { return; } + this.#countdown = countdown; + super.connectedCallback(); } diff --git a/server/vb/components/check_page.js b/server/vb/components/check_page.js deleted file mode 100644 index c178edd..0000000 --- a/server/vb/components/check_page.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Implement the check page component. - * - * @param {HTMLElement} self - */ - -function checkPage(self) { - /** - * Finalize a verify and, possibly, mint a new gift card if all is well. - * - * @param {string} firstName - * @param {string} lastName - * @param {string} email - */ - const finishVerify = (firstName, lastName, email) => { - /** @type {HTMLElement} */ - const urgency = self.querySelector(".urgency"); - htmx.ajax("POST", "./finish/", { - target: urgency, - values: { - first_name: firstName, - last_name: lastName, - email: email - } - }); - }; - - window.addEventListener('VoteAmericaEvent', (event) => { - // @ts-ignore-next-line - const { data } = event.detail; - if (data?.tool === "verify" && data?.event === "action-finish") { - setTimeout(() => { - finishVerify(data.first_name, data.last_name, data.email); - }, 500); - } - }); -} diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index 6297037..d01d6f4 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -12,6 +12,8 @@ from .logo import school_logo from .utils import Fragment, fragment +check_page_elt = h.Element("check-page", {}, None) + def check_page(school: School, current_contest: Contest | None) -> h.Element: """Render a school-specific 'check voter registration' form page.""" @@ -26,37 +28,38 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: show_faq=False, show_footer=False, )[ - h.div[ - style( - __file__, - "check_page.css", - main_color=school.logo.bg_text_color, - main_bg_color=school.logo.bg_color, - ), - js(__file__, "check_page.js"), - h.main[ - h.div(".container")[ - h.div(".urgency")[ - school_logo(school), - countdown(current_contest) - if current_contest - else h.div(".separate")[ - h.p["Check your voter registration status below."] - ], + check_page_elt[ + h.div[ + style( + __file__, + "check_page.css", + main_color=school.logo.bg_text_color, + main_bg_color=school.logo.bg_color, + ), + h.main[ + h.div(".container")[ + h.div(".urgency")[ + school_logo(school), + countdown(current_contest) + if current_contest + else h.div(".separate")[ + h.p["Check your voter registration status below."] + ], + ] + ], + h.div(".fireworks"), + ], + h.div(".form")[ + h.div(".container")[ + h.div( + ".voteamerica-embed", + data_subscriber="voterbowl", + data_tool="verify", + data_edition="college", + ) ] ], - h.div(".fireworks"), - ], - h.div(".form")[ - h.div(".container")[ - h.div( - ".voteamerica-embed", - data_subscriber="voterbowl", - data_tool="verify", - data_edition="college", - ) - ] - ], + ] ] ] @@ -118,7 +121,7 @@ def _finish_check_description( ] return [ - "Thanks for checking your voter registraiton.", + "Thanks for checking your voter registration.", h.br, h.br, "Please register to vote if you haven't yet.", diff --git a/server/vb/components/validate_email_page.js b/server/vb/components/validate_email_page.js deleted file mode 100644 index 1558225..0000000 --- a/server/vb/components/validate_email_page.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * - * @param {HTMLElement} self - */ -function(self) { - onloadAdd(() => { - const clipboard = self.querySelector(".clipboard"); - const copied = self.querySelector(".copied"); - /** @type {HTMLElement?} */ - const code = self.querySelector(".code"); - if (!clipboard || !copied || !code) { - return; - } - clipboard.addEventListener("click", () => { - navigator.clipboard.writeText(code.innerText); - // hide the `clipboard` span; show the `copied` span - // do this by adding `hidden` class to `clipboard` and - // removing it from `copied` - clipboard.classList.add("hidden"); - copied.classList.remove("hidden"); - }); - }); -} diff --git a/server/vb/components/validate_email_page.py b/server/vb/components/validate_email_page.py index a14e8f0..841eed0 100644 --- a/server/vb/components/validate_email_page.py +++ b/server/vb/components/validate_email_page.py @@ -2,12 +2,14 @@ from django.conf import settings from django.urls import reverse -from server.utils.components import js, style, svg +from server.utils.components import style, svg from ..models import ContestEntry, School from .base_page import base_page from .logo import school_logo +gift_code = h.Element("gift-code", {}, None) + def _congrats(contest_entry: ContestEntry, claim_code: str) -> h.Node: return [ @@ -66,13 +68,14 @@ def validate_email_page( main_bg_color=school.logo.bg_color, ), h.main[ - js(__file__, "validate_email_page.js"), - h.div(".container")[ - school_logo(school), - _congrats(contest_entry, claim_code) - if contest_entry and claim_code - else _sorry(), - ], + gift_code[ + h.div(".container")[ + school_logo(school), + _congrats(contest_entry, claim_code) + if contest_entry and claim_code + else _sorry(), + ], + ] ], ], ] From f797cf944c7be2e29d8819102da9af07d1a0f633 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 14 May 2024 11:17:21 -0700 Subject: [PATCH 22/28] Finalize move to module script. --- server/static/js/fireworks.js | 11 +- server/static/js/voterbowl.mjs | 138 +++++++++++++++---- server/vb/components/check_page.py | 42 +++--- server/vb/components/countdown.py | 4 +- server/vb/components/fail_check_partial.js | 22 --- server/vb/components/finish_check_partial.js | 13 -- server/vb/components/ongoing_contest.py | 4 +- server/vb/components/validate_email_page.py | 4 +- 8 files changed, 142 insertions(+), 96 deletions(-) delete mode 100644 server/vb/components/fail_check_partial.js delete mode 100644 server/vb/components/finish_check_partial.js diff --git a/server/static/js/fireworks.js b/server/static/js/fireworks.js index 1a54f06..164b60f 100644 --- a/server/static/js/fireworks.js +++ b/server/static/js/fireworks.js @@ -1,8 +1,7 @@ /** - * name: fireworks-js - * version: 2.10.7 - * author: Vitalij Ryndin (https://crashmax.ru) - * homepage: https://fireworks.js.org - * license MIT + * Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2. + * Original file: /npm/fireworks-js@2.10.7/dist/index.es.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files */ -(function(c,u){typeof exports=="object"&&typeof module<"u"?u(exports):typeof define=="function"&&define.amd?define(["exports"],u):(c=typeof globalThis<"u"?globalThis:c||self,u(c.Fireworks={}))})(this,function(c){"use strict";function u(e){return Math.abs(Math.floor(e))}function p(e,t){return Math.random()*(t-e)+e}function o(e,t){return Math.floor(p(e,t+1))}function g(e,t,i,s){const n=Math.pow;return Math.sqrt(n(e-i,2)+n(t-s,2))}function f(e,t,i=1){if(e>360||e<0)throw new Error(`Expected hue 0-360 range, got \`${e}\``);if(t>100||t<0)throw new Error(`Expected lightness 0-100 range, got \`${t}\``);if(i>1||i<0)throw new Error(`Expected alpha 0-1 range, got \`${i}\``);return`hsla(${e}, 100%, ${t}%, ${i})`}const v=e=>{if(typeof e=="object"&&e!==null){if(typeof Object.getPrototypeOf=="function"){const t=Object.getPrototypeOf(e);return t===Object.prototype||t===null}return Object.prototype.toString.call(e)==="[object Object]"}return!1},b=["__proto__","constructor","prototype"],w=(...e)=>e.reduce((t,i)=>(Object.keys(i).forEach(s=>{b.includes(s)||(Array.isArray(t[s])&&Array.isArray(i[s])?t[s]=i[s]:v(t[s])&&v(i[s])?t[s]=w(t[s],i[s]):t[s]=i[s])}),t),{});function S(e,t){let i;return(...s)=>{i&&clearTimeout(i),i=setTimeout(()=>e(...s),t)}}class O{x;y;ctx;hue;friction;gravity;flickering;lineWidth;explosionLength;angle;speed;brightness;coordinates=[];decay;alpha=1;constructor({x:t,y:i,ctx:s,hue:n,decay:h,gravity:a,friction:r,brightness:l,flickering:d,lineWidth:x,explosionLength:m}){for(this.x=t,this.y=i,this.ctx=s,this.hue=n,this.gravity=a,this.friction=r,this.flickering=d,this.lineWidth=x,this.explosionLength=m,this.angle=p(0,Math.PI*2),this.speed=o(1,10),this.brightness=o(l.min,l.max),this.decay=p(h.min,h.max);this.explosionLength--;)this.coordinates.push([t,i])}update(t){this.coordinates.pop(),this.coordinates.unshift([this.x,this.y]),this.speed*=this.friction,this.x+=Math.cos(this.angle)*this.speed,this.y+=Math.sin(this.angle)*this.speed+this.gravity,this.alpha-=this.decay,this.alpha<=this.decay&&t()}draw(){const t=this.coordinates.length-1;this.ctx.beginPath(),this.ctx.lineWidth=this.lineWidth,this.ctx.fillStyle=f(this.hue,this.brightness,this.alpha),this.ctx.moveTo(this.coordinates[t][0],this.coordinates[t][1]),this.ctx.lineTo(this.x,this.y),this.ctx.strokeStyle=f(this.hue,this.flickering?p(0,this.brightness):this.brightness,this.alpha),this.ctx.stroke()}}class E{constructor(t,i){this.options=t,this.canvas=i,this.pointerDown=this.pointerDown.bind(this),this.pointerUp=this.pointerUp.bind(this),this.pointerMove=this.pointerMove.bind(this)}active=!1;x;y;get mouseOptions(){return this.options.mouse}mount(){this.canvas.addEventListener("pointerdown",this.pointerDown),this.canvas.addEventListener("pointerup",this.pointerUp),this.canvas.addEventListener("pointermove",this.pointerMove)}unmount(){this.canvas.removeEventListener("pointerdown",this.pointerDown),this.canvas.removeEventListener("pointerup",this.pointerUp),this.canvas.removeEventListener("pointermove",this.pointerMove)}usePointer(t,i){const{click:s,move:n}=this.mouseOptions;(s||n)&&(this.x=t.pageX-this.canvas.offsetLeft,this.y=t.pageY-this.canvas.offsetTop,this.active=i)}pointerDown(t){this.usePointer(t,this.mouseOptions.click)}pointerUp(t){this.usePointer(t,!1)}pointerMove(t){this.usePointer(t,this.active)}}class M{hue;rocketsPoint;opacity;acceleration;friction;gravity;particles;explosion;mouse;boundaries;sound;delay;brightness;decay;flickering;intensity;traceLength;traceSpeed;lineWidth;lineStyle;autoresize;constructor(){this.autoresize=!0,this.lineStyle="round",this.flickering=50,this.traceLength=3,this.traceSpeed=10,this.intensity=30,this.explosion=5,this.gravity=1.5,this.opacity=.5,this.particles=50,this.friction=.95,this.acceleration=1.05,this.hue={min:0,max:360},this.rocketsPoint={min:50,max:50},this.lineWidth={explosion:{min:1,max:3},trace:{min:1,max:2}},this.mouse={click:!1,move:!1,max:1},this.delay={min:30,max:60},this.brightness={min:50,max:80},this.decay={min:.015,max:.03},this.sound={enabled:!1,files:["explosion0.mp3","explosion1.mp3","explosion2.mp3"],volume:{min:4,max:8}},this.boundaries={debug:!1,height:0,width:0,x:50,y:50}}update(t){Object.assign(this,w(this,t))}}class z{constructor(t,i){this.options=t,this.render=i}tick=0;rafId=0;fps=60;tolerance=.1;now;mount(){this.now=performance.now();const t=1e3/this.fps,i=s=>{this.rafId=requestAnimationFrame(i);const n=s-this.now;n>=t-this.tolerance&&(this.render(),this.now=s-n%t,this.tick+=n*(this.options.intensity*Math.PI)/1e3)};this.rafId=requestAnimationFrame(i)}unmount(){cancelAnimationFrame(this.rafId)}}class L{constructor(t,i,s){this.options=t,this.updateSize=i,this.container=s}resizer;mount(){if(!this.resizer){const t=S(()=>this.updateSize(),100);this.resizer=new ResizeObserver(t)}this.options.autoresize&&this.resizer.observe(this.container)}unmount(){this.resizer&&this.resizer.unobserve(this.container)}}class T{constructor(t){this.options=t,this.init()}buffers=[];audioContext;onInit=!1;get isEnabled(){return this.options.sound.enabled}get soundOptions(){return this.options.sound}init(){!this.onInit&&this.isEnabled&&(this.onInit=!0,this.audioContext=new(window.AudioContext||window.webkitAudioContext),this.loadSounds())}async loadSounds(){for(const t of this.soundOptions.files){const i=await(await fetch(t)).arrayBuffer();this.audioContext.decodeAudioData(i).then(s=>{this.buffers.push(s)}).catch(s=>{throw s})}}play(){if(this.isEnabled&&this.buffers.length){const t=this.audioContext.createBufferSource(),i=this.buffers[o(0,this.buffers.length-1)],s=this.audioContext.createGain();t.buffer=i,s.gain.value=p(this.soundOptions.volume.min/100,this.soundOptions.volume.max/100),s.connect(this.audioContext.destination),t.connect(s),t.start(0)}else this.init()}}class C{x;y;sx;sy;dx;dy;ctx;hue;speed;acceleration;traceLength;totalDistance;angle;brightness;coordinates=[];currentDistance=0;constructor({x:t,y:i,dx:s,dy:n,ctx:h,hue:a,speed:r,traceLength:l,acceleration:d}){for(this.x=t,this.y=i,this.sx=t,this.sy=i,this.dx=s,this.dy=n,this.ctx=h,this.hue=a,this.speed=r,this.traceLength=l,this.acceleration=d,this.totalDistance=g(t,i,s,n),this.angle=Math.atan2(n-i,s-t),this.brightness=o(50,70);this.traceLength--;)this.coordinates.push([t,i])}update(t){this.coordinates.pop(),this.coordinates.unshift([this.x,this.y]),this.speed*=this.acceleration;const i=Math.cos(this.angle)*this.speed,s=Math.sin(this.angle)*this.speed;this.currentDistance=g(this.sx,this.sy,this.x+i,this.y+s),this.currentDistance>=this.totalDistance?t(this.dx,this.dy,this.hue):(this.x+=i,this.y+=s)}draw(){const t=this.coordinates.length-1;this.ctx.beginPath(),this.ctx.moveTo(this.coordinates[t][0],this.coordinates[t][1]),this.ctx.lineTo(this.x,this.y),this.ctx.strokeStyle=f(this.hue,this.brightness),this.ctx.stroke()}}class y{target;container;canvas;ctx;width;height;traces=[];explosions=[];waitStopRaf;running=!1;opts;sound;resize;mouse;raf;constructor(t,i={}){this.target=t,this.container=t,this.opts=new M,this.createCanvas(this.target),this.updateOptions(i),this.sound=new T(this.opts),this.resize=new L(this.opts,this.updateSize.bind(this),this.container),this.mouse=new E(this.opts,this.canvas),this.raf=new z(this.opts,this.render.bind(this))}get isRunning(){return this.running}get version(){return"2.10.7"}get currentOptions(){return this.opts}start(){this.running||(this.canvas.isConnected||this.createCanvas(this.target),this.running=!0,this.resize.mount(),this.mouse.mount(),this.raf.mount())}stop(t=!1){!this.running||(this.running=!1,this.resize.unmount(),this.mouse.unmount(),this.raf.unmount(),this.clear(),t&&this.canvas.remove())}async waitStop(t){if(!!this.running)return new Promise(i=>{this.waitStopRaf=()=>{!this.waitStopRaf||(requestAnimationFrame(this.waitStopRaf),!this.traces.length&&!this.explosions.length&&(this.waitStopRaf=null,this.stop(t),i()))},this.waitStopRaf()})}pause(){this.running=!this.running,this.running?this.raf.mount():this.raf.unmount()}clear(){!this.ctx||(this.traces=[],this.explosions=[],this.ctx.clearRect(0,0,this.width,this.height))}launch(t=1){for(let i=0;io(t.min,t.max)||this.mouse.active&&i.max>this.traces.length)&&(this.createTrace(),this.raf.tick=0)}drawTrace(){let t=this.traces.length;for(;t--;)this.traces[t].draw(),this.traces[t].update((i,s,n)=>{this.initExplosion(i,s,n),this.sound.play(),this.traces.splice(t,1)})}initExplosion(t,i,s){const{particles:n,flickering:h,lineWidth:a,explosion:r,brightness:l,friction:d,gravity:x,decay:m}=this.opts;let P=u(n);for(;P--;)this.explosions.push(new O({x:t,y:i,ctx:this.ctx,hue:s,friction:d,gravity:x,flickering:o(0,100)<=h,lineWidth:p(a.explosion.min,a.explosion.max),explosionLength:u(r),brightness:l,decay:m}))}drawExplosion(){let t=this.explosions.length;for(;t--;)this.explosions[t].draw(),this.explosions[t].update(()=>{this.explosions.splice(t,1)})}}c.Fireworks=y,c.default=y,Object.defineProperties(c,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}); +function t(t){return Math.abs(Math.floor(t))}function i(t,i){return Math.random()*(i-t)+t}function s(t,s){return Math.floor(i(t,s+1))}function e(t,i,s,e){const n=Math.pow;return Math.sqrt(n(t-s,2)+n(i-e,2))}function n(t,i,s=1){if(t>360||t<0)throw new Error(`Expected hue 0-360 range, got \`${t}\``);if(i>100||i<0)throw new Error(`Expected lightness 0-100 range, got \`${i}\``);if(s>1||s<0)throw new Error(`Expected alpha 0-1 range, got \`${s}\``);return`hsla(${t}, 100%, ${i}%, ${s})`}const h=t=>{if("object"==typeof t&&null!==t){if("function"==typeof Object.getPrototypeOf){const i=Object.getPrototypeOf(t);return i===Object.prototype||null===i}return"[object Object]"===Object.prototype.toString.call(t)}return!1},o=["__proto__","constructor","prototype"],a=(...t)=>t.reduce(((t,i)=>(Object.keys(i).forEach((s=>{o.includes(s)||(Array.isArray(t[s])&&Array.isArray(i[s])?t[s]=i[s]:h(t[s])&&h(i[s])?t[s]=a(t[s],i[s]):t[s]=i[s])})),t)),{});class r{x;y;ctx;hue;friction;gravity;flickering;lineWidth;explosionLength;angle;speed;brightness;coordinates=[];decay;alpha=1;constructor({x:t,y:e,ctx:n,hue:h,decay:o,gravity:a,friction:r,brightness:c,flickering:u,lineWidth:p,explosionLength:d}){for(this.x=t,this.y=e,this.ctx=n,this.hue=h,this.gravity=a,this.friction=r,this.flickering=u,this.lineWidth=p,this.explosionLength=d,this.angle=i(0,2*Math.PI),this.speed=s(1,10),this.brightness=s(c.min,c.max),this.decay=i(o.min,o.max);this.explosionLength--;)this.coordinates.push([t,e])}update(t){this.coordinates.pop(),this.coordinates.unshift([this.x,this.y]),this.speed*=this.friction,this.x+=Math.cos(this.angle)*this.speed,this.y+=Math.sin(this.angle)*this.speed+this.gravity,this.alpha-=this.decay,this.alpha<=this.decay&&t()}draw(){const t=this.coordinates.length-1;this.ctx.beginPath(),this.ctx.lineWidth=this.lineWidth,this.ctx.fillStyle=n(this.hue,this.brightness,this.alpha),this.ctx.moveTo(this.coordinates[t][0],this.coordinates[t][1]),this.ctx.lineTo(this.x,this.y),this.ctx.strokeStyle=n(this.hue,this.flickering?i(0,this.brightness):this.brightness,this.alpha),this.ctx.stroke()}}class c{constructor(t,i){this.options=t,this.canvas=i,this.pointerDown=this.pointerDown.bind(this),this.pointerUp=this.pointerUp.bind(this),this.pointerMove=this.pointerMove.bind(this)}active=!1;x;y;get mouseOptions(){return this.options.mouse}mount(){this.canvas.addEventListener("pointerdown",this.pointerDown),this.canvas.addEventListener("pointerup",this.pointerUp),this.canvas.addEventListener("pointermove",this.pointerMove)}unmount(){this.canvas.removeEventListener("pointerdown",this.pointerDown),this.canvas.removeEventListener("pointerup",this.pointerUp),this.canvas.removeEventListener("pointermove",this.pointerMove)}usePointer(t,i){const{click:s,move:e}=this.mouseOptions;(s||e)&&(this.x=t.pageX-this.canvas.offsetLeft,this.y=t.pageY-this.canvas.offsetTop,this.active=i)}pointerDown(t){this.usePointer(t,this.mouseOptions.click)}pointerUp(t){this.usePointer(t,!1)}pointerMove(t){this.usePointer(t,this.active)}}class u{hue;rocketsPoint;opacity;acceleration;friction;gravity;particles;explosion;mouse;boundaries;sound;delay;brightness;decay;flickering;intensity;traceLength;traceSpeed;lineWidth;lineStyle;autoresize;constructor(){this.autoresize=!0,this.lineStyle="round",this.flickering=50,this.traceLength=3,this.traceSpeed=10,this.intensity=30,this.explosion=5,this.gravity=1.5,this.opacity=.5,this.particles=50,this.friction=.95,this.acceleration=1.05,this.hue={min:0,max:360},this.rocketsPoint={min:50,max:50},this.lineWidth={explosion:{min:1,max:3},trace:{min:1,max:2}},this.mouse={click:!1,move:!1,max:1},this.delay={min:30,max:60},this.brightness={min:50,max:80},this.decay={min:.015,max:.03},this.sound={enabled:!1,files:["explosion0.mp3","explosion1.mp3","explosion2.mp3"],volume:{min:4,max:8}},this.boundaries={debug:!1,height:0,width:0,x:50,y:50}}update(t){Object.assign(this,a(this,t))}}class p{constructor(t,i){this.options=t,this.render=i}tick=0;rafId=0;fps=60;tolerance=.1;now;mount(){this.now=performance.now();const t=1e3/this.fps,i=s=>{this.rafId=requestAnimationFrame(i);const e=s-this.now;e>=t-this.tolerance&&(this.render(),this.now=s-e%t,this.tick+=e*(this.options.intensity*Math.PI)/1e3)};this.rafId=requestAnimationFrame(i)}unmount(){cancelAnimationFrame(this.rafId)}}class d{constructor(t,i,s){this.options=t,this.updateSize=i,this.container=s}resizer;mount(){if(!this.resizer){const t=function(t,i){let s;return(...e)=>{s&&clearTimeout(s),s=setTimeout((()=>t(...e)),i)}}((()=>this.updateSize()),100);this.resizer=new ResizeObserver(t)}this.options.autoresize&&this.resizer.observe(this.container)}unmount(){this.resizer&&this.resizer.unobserve(this.container)}}class l{constructor(t){this.options=t,this.init()}buffers=[];audioContext;onInit=!1;get isEnabled(){return this.options.sound.enabled}get soundOptions(){return this.options.sound}init(){!this.onInit&&this.isEnabled&&(this.onInit=!0,this.audioContext=new(window.AudioContext||window.webkitAudioContext),this.loadSounds())}async loadSounds(){for(const t of this.soundOptions.files){const i=await(await fetch(t)).arrayBuffer();this.audioContext.decodeAudioData(i).then((t=>{this.buffers.push(t)})).catch((t=>{throw t}))}}play(){if(this.isEnabled&&this.buffers.length){const t=this.audioContext.createBufferSource(),e=this.buffers[s(0,this.buffers.length-1)],n=this.audioContext.createGain();t.buffer=e,n.gain.value=i(this.soundOptions.volume.min/100,this.soundOptions.volume.max/100),n.connect(this.audioContext.destination),t.connect(n),t.start(0)}else this.init()}}class x{x;y;sx;sy;dx;dy;ctx;hue;speed;acceleration;traceLength;totalDistance;angle;brightness;coordinates=[];currentDistance=0;constructor({x:t,y:i,dx:n,dy:h,ctx:o,hue:a,speed:r,traceLength:c,acceleration:u}){for(this.x=t,this.y=i,this.sx=t,this.sy=i,this.dx=n,this.dy=h,this.ctx=o,this.hue=a,this.speed=r,this.traceLength=c,this.acceleration=u,this.totalDistance=e(t,i,n,h),this.angle=Math.atan2(h-i,n-t),this.brightness=s(50,70);this.traceLength--;)this.coordinates.push([t,i])}update(t){this.coordinates.pop(),this.coordinates.unshift([this.x,this.y]),this.speed*=this.acceleration;const i=Math.cos(this.angle)*this.speed,s=Math.sin(this.angle)*this.speed;this.currentDistance=e(this.sx,this.sy,this.x+i,this.y+s),this.currentDistance>=this.totalDistance?t(this.dx,this.dy,this.hue):(this.x+=i,this.y+=s)}draw(){const t=this.coordinates.length-1;this.ctx.beginPath(),this.ctx.moveTo(this.coordinates[t][0],this.coordinates[t][1]),this.ctx.lineTo(this.x,this.y),this.ctx.strokeStyle=n(this.hue,this.brightness),this.ctx.stroke()}}class g{target;container;canvas;ctx;width;height;traces=[];explosions=[];waitStopRaf;running=!1;opts;sound;resize;mouse;raf;constructor(t,i={}){this.target=t,this.container=t,this.opts=new u,this.createCanvas(this.target),this.updateOptions(i),this.sound=new l(this.opts),this.resize=new d(this.opts,this.updateSize.bind(this),this.container),this.mouse=new c(this.opts,this.canvas),this.raf=new p(this.opts,this.render.bind(this))}get isRunning(){return this.running}get version(){return"2.10.7"}get currentOptions(){return this.opts}start(){this.running||(this.canvas.isConnected||this.createCanvas(this.target),this.running=!0,this.resize.mount(),this.mouse.mount(),this.raf.mount())}stop(t=!1){!this.running||(this.running=!1,this.resize.unmount(),this.mouse.unmount(),this.raf.unmount(),this.clear(),t&&this.canvas.remove())}async waitStop(t){if(this.running)return new Promise((i=>{this.waitStopRaf=()=>{!this.waitStopRaf||(requestAnimationFrame(this.waitStopRaf),!this.traces.length&&!this.explosions.length&&(this.waitStopRaf=null,this.stop(t),i()))},this.waitStopRaf()}))}pause(){this.running=!this.running,this.running?this.raf.mount():this.raf.unmount()}clear(){!this.ctx||(this.traces=[],this.explosions=[],this.ctx.clearRect(0,0,this.width,this.height))}launch(t=1){for(let i=0;is(t.min,t.max)||this.mouse.active&&i.max>this.traces.length)&&(this.createTrace(),this.raf.tick=0)}drawTrace(){let t=this.traces.length;for(;t--;)this.traces[t].draw(),this.traces[t].update(((i,s,e)=>{this.initExplosion(i,s,e),this.sound.play(),this.traces.splice(t,1)}))}initExplosion(e,n,h){const{particles:o,flickering:a,lineWidth:c,explosion:u,brightness:p,friction:d,gravity:l,decay:x}=this.opts;let g=t(o);for(;g--;)this.explosions.push(new r({x:e,y:n,ctx:this.ctx,hue:h,friction:d,gravity:l,flickering:s(0,100)<=a,lineWidth:i(c.explosion.min,c.explosion.max),explosionLength:t(u),brightness:p,decay:x}))}drawExplosion(){let t=this.explosions.length;for(;t--;)this.explosions[t].draw(),this.explosions[t].update((()=>{this.explosions.splice(t,1)}))}}export{g as Fireworks,g as default}; diff --git a/server/static/js/voterbowl.mjs b/server/static/js/voterbowl.mjs index 7ec3656..f94ce3b 100644 --- a/server/static/js/voterbowl.mjs +++ b/server/static/js/voterbowl.mjs @@ -1,4 +1,36 @@ import * as htmx from "./htmx.min.js"; +import { Fireworks } from "./fireworks.js"; + +/*----------------------------------------------------------------- + * API Calls + * -----------------------------------------------------------------*/ + +const api = { + /** + * Finalize a verify and, possibly, mint a new gift ca + * + * @param {string} first_name + * @param {string} last_name + * @param {string} email + * @param {HTMLElement} target + * @returns {Promise} + */ + finishVerify: async (first_name, last_name, email, target) => { + /** @type {HTMLElement|null} */ + try { + await htmx.ajax("POST", "./finish/", { + target, + values: { + first_name, + last_name, + email, + }, + }); + } catch (error) { + console.error(error); + } + }, +}; /*----------------------------------------------------------------- * Check Page Component @@ -40,42 +72,100 @@ class CheckPage extends HTMLElement { console.error("Missing data in event"); return; } - this.finishVerify(data.first_name, data.last_name, data.email); + /** @type {HTMLElement|null} */ + const target = this.querySelector(".urgency"); + if (!target) { + console.error("Missing target element"); + return; + } + api.finishVerify(data.first_name, data.last_name, data.email, target); } }; +} + +customElements.define("check-page", CheckPage); + +/*----------------------------------------------------------------- + * Fail Check Partial + * -----------------------------------------------------------------*/ + +class FailCheck extends HTMLElement { + connectedCallback() { + const { schoolName, firstName, lastName } = this.dataset; + if (!schoolName || !firstName || !lastName) { + console.error("Missing data attributes"); + return; + } + + /** @type {HTMLElement|null} */ + const target = this.querySelector(".urgency"); + if (!target) { + console.error("Missing target element"); + return; + } + + const email = this.demandValidEmail(schoolName, 3); + if (!email) { + console.log("No email provided"); + return; + } + + api.finishVerify(firstName, lastName, email, target); + } /** - * Finalize a verify and, possibly, mint a new gift card if all is well. + * Prompt the user for a valid email address. * - * @param {string} first_name - * @param {string} last_name - * @param {string} email - * @returns {Promise} + * @param {string} schoolName The name of the school. + * @param {number} tries The number of tries to allow. + * @returns {string|null} The email address or null if not provided. + * @private + * @memberof FailCheck */ - async finishVerify(first_name, last_name, email) { - /** @type {HTMLElement|null} */ - const urgency = this.querySelector(".urgency"); - if (!urgency) { - console.error("Missing urgency element"); + demandValidEmail(schoolName, tries) { + /** @type {string|null} */ + let email = null; + let count = 0; + while (email === null && count < tries) { + email = prompt( + `Sorry, but we need your ${schoolName} student email to continue. Please enter it below:` + ); + count++; + } + return email; + } +} + +customElements.define("fail-check", FailCheck); + +/*----------------------------------------------------------------- + * Finish Check Partial + * -----------------------------------------------------------------*/ + +class FinishCheck extends HTMLElement { + connectedCallback() { + // smoothly scroll to the top of the page after a slight delay + setTimeout(() => window.scrollTo({ top: 0, behavior: "smooth" }), 100); + + // if the user is a winner, start the fireworks + const { isWinner } = this.dataset; + if (isWinner !== "true") { return; } - try { - await htmx.ajax("POST", "./finish/", { - target: urgency, - values: { - first_name, - last_name, - email, - }, - }); - } catch (error) { - console.error(error); + + // CONSIDER: use of document needed here? + /** @type {HTMLElement|null} */ + const target = document.querySelector(".fireworks"); + if (!target) { + console.error("Missing target element"); + return; } + const fireworks = new Fireworks(target); + fireworks.start(); + setTimeout(() => fireworks.stop(), 10_000); } } -customElements.define("check-page", CheckPage); - /*----------------------------------------------------------------- * Gift code clipboard behavior * -----------------------------------------------------------------*/ diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index d01d6f4..bc31016 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -4,7 +4,7 @@ from django.templatetags.static import static from django.urls import reverse -from server.utils.components import js, style +from server.utils.components import style from ..models import Contest, ContestEntry, School from .base_page import base_page @@ -12,8 +12,6 @@ from .logo import school_logo from .utils import Fragment, fragment -check_page_elt = h.Element("check-page", {}, None) - def check_page(school: School, current_contest: Contest | None) -> h.Element: """Render a school-specific 'check voter registration' form page.""" @@ -28,7 +26,7 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: show_faq=False, show_footer=False, )[ - check_page_elt[ + h.check_page[ h.div[ style( __file__, @@ -70,16 +68,15 @@ def fail_check_partial( """Render a partial page for when the user's email is invalid.""" return fragment[ school_logo(school), - h.p[ - js( - __file__, - "fail_check_partial.js", - school_name=school.short_name, - first_name=first_name, - last_name=last_name, - ), - h.b["We could not use your email"], - f". Please use your { school.short_name } student email.", + h.fail_check( + data_school_name=school.short_name, + data_first_name=first_name, + data_last_name=last_name, + )[ + h.p[ + h.b["We could not use your email"], + f". Please use your { school.short_name } student email.", + ] ], ] @@ -136,13 +133,14 @@ def finish_check_partial( """Render a partial page for when the user has finished the check.""" return fragment[ school_logo(school), - h.p[ - style(__file__, "finish_check_partial.css"), - js( - __file__, - "finish_check_partial.js", - is_winner=contest_entry and contest_entry.is_winner, - ), - _finish_check_description(school, contest_entry, most_recent_winner), + h.finish_check( + data_is_winner="true" + if contest_entry and contest_entry.is_winner + else "false" + )[ + h.p[ + style(__file__, "finish_check_partial.css"), + _finish_check_description(school, contest_entry, most_recent_winner), + ] ], ] diff --git a/server/vb/components/countdown.py b/server/vb/components/countdown.py index 3f9a03b..5cabb4e 100644 --- a/server/vb/components/countdown.py +++ b/server/vb/components/countdown.py @@ -4,8 +4,6 @@ from ..models import Contest -big_countdown = h.Element("big-countdown", {}, None) - def countdown(contest: Contest) -> h.Element: """Render a countdown timer for the given contest.""" @@ -24,7 +22,7 @@ def countdown(contest: Contest) -> h.Element: "giveaway " if contest.is_giveaway else "contest ", "ends in:", ], - big_countdown(data_end_at=contest.end_at.isoformat())[ + h.big_countdown(data_end_at=contest.end_at.isoformat())[ h.div(".countdown")[ h.span(".number", data_number="h0"), h.span(".number", data_number="h1"), diff --git a/server/vb/components/fail_check_partial.js b/server/vb/components/fail_check_partial.js deleted file mode 100644 index 0a5ae54..0000000 --- a/server/vb/components/fail_check_partial.js +++ /dev/null @@ -1,22 +0,0 @@ -function failCheckPartial(self, props) { - const { schoolName, firstName, lastName } = props; - - let email = null; - let count = 0; // give up after 3 tries - while (email === null && count < 3) { - email = prompt(`Sorry, but we need your ${schoolName} student email to continue. Please enter it below:`); - count++; - } - - if (email) { - htmx.ajax("POST", "./finish/", { - target: document.querySelector(".urgency"), - values: { - email: email, - first_name: firstName, - last_name: lastName, - school: schoolName - } - }); - } -} diff --git a/server/vb/components/finish_check_partial.js b/server/vb/components/finish_check_partial.js deleted file mode 100644 index b2850da..0000000 --- a/server/vb/components/finish_check_partial.js +++ /dev/null @@ -1,13 +0,0 @@ -function finishCheckPartial(self, props) { - const { isWinner } = props; - - if (isWinner) { - // @ts-ignore-next-line - const fireworks = new Fireworks.default(document.querySelector(".fireworks")); - fireworks.start(); - setTimeout(() => fireworks.stop(), 10_000); - } - - // smoothly scroll to the top of the page after a slight delay - setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 100); -} diff --git a/server/vb/components/ongoing_contest.py b/server/vb/components/ongoing_contest.py index 5c533e8..5852e23 100644 --- a/server/vb/components/ongoing_contest.py +++ b/server/vb/components/ongoing_contest.py @@ -6,8 +6,6 @@ from .button import button from .logo import school_logo -small_countdown = h.Element("small-countdown", {}, None) - def ongoing_contest(contest: Contest) -> h.Element: """Render an ongoing contest.""" @@ -29,7 +27,7 @@ def ongoing_contest(contest: Contest) -> h.Element: )["Visit event"] ], ], - small_countdown(data_end_at=contest.end_at.isoformat())[ + h.small_countdown(data_end_at=contest.end_at.isoformat())[ h.div(".box countdown")[""] ], ] diff --git a/server/vb/components/validate_email_page.py b/server/vb/components/validate_email_page.py index 841eed0..83455bb 100644 --- a/server/vb/components/validate_email_page.py +++ b/server/vb/components/validate_email_page.py @@ -8,8 +8,6 @@ from .base_page import base_page from .logo import school_logo -gift_code = h.Element("gift-code", {}, None) - def _congrats(contest_entry: ContestEntry, claim_code: str) -> h.Node: return [ @@ -68,7 +66,7 @@ def validate_email_page( main_bg_color=school.logo.bg_color, ), h.main[ - gift_code[ + h.gift_code[ h.div(".container")[ school_logo(school), _congrats(contest_entry, claim_code) From 4d42dc14bf189c56b879e27adeb2ec313791c097 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 14 May 2024 11:49:04 -0700 Subject: [PATCH 23/28] Fix several bugs. --- .../static/js/{fireworks.js => fireworks.mjs} | 0 server/static/js/htmx.min.js | 1 - server/static/js/htmx.mjs | 7 +++++++ server/static/js/voterbowl.mjs | 21 +++++++++++++++---- server/vb/components/check_page.py | 4 +--- 5 files changed, 25 insertions(+), 8 deletions(-) rename server/static/js/{fireworks.js => fireworks.mjs} (100%) delete mode 100644 server/static/js/htmx.min.js create mode 100644 server/static/js/htmx.mjs diff --git a/server/static/js/fireworks.js b/server/static/js/fireworks.mjs similarity index 100% rename from server/static/js/fireworks.js rename to server/static/js/fireworks.mjs diff --git a/server/static/js/htmx.min.js b/server/static/js/htmx.min.js deleted file mode 100644 index d68f3c6..0000000 --- a/server/static/js/htmx.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:B,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Fr,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.11"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",3);case"script":case"style":return s("
"+n+"
",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function F(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function B(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Be(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Fe(r,o,a);Re(o);return Be(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Ft(o)}for(var l in r){Bt(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=F(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var F=p.split(":");var B=F[0].trim();if(B==="this"){g=xe(n,"hx-sync")}else{g=ue(n,B)}p=(F[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Fr(e){delete Xr[e]}function Br(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Br(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); \ No newline at end of file diff --git a/server/static/js/htmx.mjs b/server/static/js/htmx.mjs new file mode 100644 index 0000000..6a7d45e --- /dev/null +++ b/server/static/js/htmx.mjs @@ -0,0 +1,7 @@ +/** + * Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2. + * Original file: /npm/htmx.org@1.9.12/dist/htmx.min.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +var commonjsGlobal="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},htmx_min$1={exports:{}};(function(module){var e,t;e="undefined"!=typeof self?self:commonjsGlobal,t=function(){return function(){var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){return dr(e,t||"post").values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:!1,scrollBehavior:"smooth",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get"],selfRequestsOnly:!1,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:!0})},createWebSocket:function(e){var t=new WebSocket(e,[]);return t.binaryType=Q.config.wsBinaryType,t},version:"1.9.12"},r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R},w=["get","post","put","delete","patch"],i=w.map((function(e){return"[hx-"+e+"], [data-hx-"+e+"]"})).join(", "),S=e("head"),q=e("title"),H=e("svg",!0);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",t?"gim":"im")}function d(e){if(null==e)return;let t=NaN;return t="ms"==e.slice(-2)?parseFloat(e.slice(0,-2)):"s"==e.slice(-1)?1e3*parseFloat(e.slice(0,-1)):"m"==e.slice(-1)?1e3*parseFloat(e.slice(0,-1))*60:parseFloat(e),isNaN(t)?void 0:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){for(;e&&!t(e);)e=u(e);return e||null}function L(e,t,r){var n=te(t,r),o=te(t,"hx-disinherit");return e!==t&&o&&("*"===o||o.split(" ").indexOf(r)>=0)?"unset":n}function ne(e,t){var r=null;if(c(e,(function(n){return r=L(e,n,t)})),"unset"!==r)return r}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return t?t[1].toLowerCase():""}function s(e,t){for(var r=(new DOMParser).parseFromString(e,"text/html").body;t>0;)t--,r=r.firstChild;return null==r&&(r=re().createDocumentFragment()),r}function N(e){return/",0).querySelector("template").content;return Q.config.allowScriptTags?oe(o.querySelectorAll("script"),(function(e){Q.config.inlineScriptNonce&&(e.nonce=Q.config.inlineScriptNonce),e.htmxExecuted=-1===navigator.userAgent.indexOf("Firefox")})):oe(o.querySelectorAll("script"),(function(e){_(e)})),o}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",3);case"script":case"style":return s("
"+n+"
",1);default:return s(n,0)}}function ie(e){e&&e()}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data",r=e[t];return r||(r=e[t]={}),r}function M(e){var t=[];if(e)for(var r=0;r=0}function se(e){return e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot?re().body.contains(e.getRootNode().host):re().body.contains(e)}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t)t.hasOwnProperty(r)&&(e[r]=t[r]);return e}function E(e){try{return JSON.parse(e)}catch(e){return b(e),null}}function U(){var e="htmx:localStorageTest";try{return localStorage.setItem(e,e),localStorage.removeItem(e),!0}catch(e){return!1}}function B(e){try{var t=new URL(e);return t&&(e=t.pathname+t.search),/^\/$/.test(e)||(e=e.replace(/\/+$/,"")),e}catch(t){return e}}function t(e){return Tr(re().body,(function(){return eval(e)}))}function F(e){return Q.on("htmx:load",(function(t){e(t.detail.elt)}))}function V(){Q.logger=function(e,t,r){console&&console.log(t,e,r)}}function j(){Q.logger=null}function C(e,t){return t?e.querySelector(t):C(re(),e)}function f(e,t){return t?e.querySelectorAll(t):f(re(),e)}function _(e,t){e=p(e),t?setTimeout((function(){_(e),e=null}),t):e.parentElement.removeChild(e)}function z(e,t,r){e=p(e),r?setTimeout((function(){z(e,t),e=null}),r):e.classList&&e.classList.add(t)}function n(e,t,r){e=p(e),r?setTimeout((function(){n(e,t),e=null}),r):e.classList&&(e.classList.remove(t),0===e.classList.length&&e.removeAttribute("class"))}function $(e,t){(e=p(e)).classList.toggle(t)}function W(e,t){oe((e=p(e)).parentElement.children,(function(e){n(e,t)})),z(e,t)}function v(e,t){if((e=p(e)).closest)return e.closest(t);do{if(null==e||h(e,t))return e}while(e=e&&u(e));return null}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();return g(t,"<")&&G(t,"/>")?t.substring(1,t.length-2):t}function Z(e,t){return 0===t.indexOf("closest ")?[v(e,J(t.substr(8)))]:0===t.indexOf("find ")?[C(e,J(t.substr(5)))]:"next"===t?[e.nextElementSibling]:0===t.indexOf("next ")?[K(e,J(t.substr(5)))]:"previous"===t?[e.previousElementSibling]:0===t.indexOf("previous ")?[Y(e,J(t.substr(9)))]:"document"===t?[document]:"window"===t?[window]:"body"===t?[document.body]:re().querySelectorAll(J(t))}var K=function(e,t){for(var r=re().querySelectorAll(t),n=0;n=0;n--){var o=r[n];if(o.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return o}};function ue(e,t){return t?Z(e,t)[0]:Z(re().body,e)[0]}function p(e){return I(e,"String")?C(e):e}function ve(e,t,r){return k(t)?{target:re().body,event:e,listener:t}:{target:p(e),event:t,listener:r}}function de(e,t,r){return jr((function(){var n=ve(e,t,r);n.target.addEventListener(n.event,n.listener)})),k(t)?t:r}function ge(e,t,r){return jr((function(){var n=ve(e,t,r);n.target.removeEventListener(n.event,n.listener)})),k(t)?t:r}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if("this"===r)return[xe(e,t)];var n=Z(e,r);return 0===n.length?(b('The selector "'+r+'" on '+t+" returned no matches!"),[pe]):n}}function xe(e,t){return c(e,(function(e){return null!=te(e,t)}))}function ye(e){var t=ne(e,"hx-target");return t?"this"===t?xe(e,"hx-target"):ue(e,t):ae(e).boosted?re().body:e}function be(e){for(var t=Q.config.attributesToSettle,r=0;r0?(o=e.substr(0,e.indexOf(":")),n=e.substr(e.indexOf(":")+1,e.length)):o=e);var i=re().querySelectorAll(n);return i?(oe(i,(function(e){var n,i=t.cloneNode(!0);(n=re().createDocumentFragment()).appendChild(i),Se(o,e)||(n=i);var a={shouldSwap:!0,target:e,fragment:n};ce(e,"htmx:oobBeforeSwap",a)&&(e=a.target,a.shouldSwap&&Fe(o,e,e,n,r),oe(r.elts,(function(e){ce(e,"htmx:oobAfterSwap",a)})))})),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),fe(re().body,"htmx:oobErrorNoTarget",{content:t})),e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n)for(var o=n.split(","),i=0;i0){var o=n.replace("'","\\'"),i=t.tagName.replace(":","\\:"),a=e.querySelector(i+"[id='"+o+"']");if(a&&a!==e){var s=t.cloneNode();we(t,a),r.tasks.push((function(){we(t,s)}))}}}))}function Oe(e){return function(){n(e,Q.config.addedClass),zt(e),Nt(e),qe(e),ce(e,"htmx:load")}}function qe(e){var t="[autofocus]",r=h(e,t)?e:e.querySelector(t);null!=r&&r.focus()}function a(e,t,r,n){for(Te(e,r,n);r.childNodes.length>0;){var o=r.firstChild;z(o,Q.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&n.tasks.push(Oe(o))}}function He(e,t){for(var r=0;r-1){var t=e.replace(H,"").match(q);if(t)return t[2]}}function je(e,t,r,n,o,i){o.title=Ve(n);var a=l(n);if(a)return Ce(r,a,o),Re(a=Be(r,a,i)),Fe(e,r,t,a,o)}function _e(e,t,r){var n=e.getResponseHeader(t);if(0===n.indexOf("{")){var o=E(n);for(var i in o)if(o.hasOwnProperty(i)){var a=o[i];P(a)||(a={value:a}),ce(r,i,a)}}else for(var s=n.split(","),l=0;l0;){var a=t[0];if("]"===a){if(0==--n){null===i&&(o+="true"),t.shift(),o+=")})";try{var s=Tr(e,(function(){return Function(o)()}),(function(){return!0}));return s.source=o,s}catch(e){return fe(re().body,"htmx:syntax:error",{error:e,source:o}),null}}}else"["===a&&n++;Qe(a,i,r)?o+="(("+r+"."+a+") ? ("+r+"."+a+") : (window."+a+"))":o+=a,i=t.shift()}}}function y(e,t){for(var r="";e.length>0&&!t.test(e[0]);)r+=e.shift();return r}function tt(e){var t;return e.length>0&&Ze.test(e[0])?(e.shift(),t=y(e,Ke).trim(),e.shift()):t=y(e,x),t}var rt="input, textarea, select";function nt(e,t,r){var n=[],o=Ye(t);do{y(o,Je);var i=o.length,a=y(o,/[,\[\s]/);if(""!==a)if("every"===a){var s={trigger:"every"};y(o,Je),s.pollInterval=d(y(o,/[,\[\s]/)),y(o,Je),(l=et(e,o,"event"))&&(s.eventFilter=l),n.push(s)}else if(0===a.indexOf("sse:"))n.push({trigger:"sse",sseEvent:a.substr(4)});else{var l,u={trigger:a};for((l=et(e,o,"event"))&&(u.eventFilter=l);o.length>0&&","!==o[0];){y(o,Je);var c=o.shift();if("changed"===c)u.changed=!0;else if("once"===c)u.once=!0;else if("consume"===c)u.consume=!0;else if("delay"===c&&":"===o[0])o.shift(),u.delay=d(y(o,x));else if("from"===c&&":"===o[0]){if(o.shift(),Ze.test(o[0]))var f=tt(o);else if("closest"===(f=y(o,x))||"find"===f||"next"===f||"previous"===f){o.shift();var h=tt(o);h.length>0&&(f+=" "+h)}u.from=f}else"target"===c&&":"===o[0]?(o.shift(),u.target=tt(o)):"throttle"===c&&":"===o[0]?(o.shift(),u.throttle=d(y(o,x))):"queue"===c&&":"===o[0]?(o.shift(),u.queue=y(o,x)):"root"===c&&":"===o[0]?(o.shift(),u[c]=tt(o)):"threshold"===c&&":"===o[0]?(o.shift(),u[c]=y(o,x)):fe(e,"htmx:syntax:error",{token:o.shift()})}n.push(u)}o.length===i&&fe(e,"htmx:syntax:error",{token:o.shift()}),y(o,Je)}while(","===o[0]&&o.shift());return r&&(r[t]=n),n}function it(e){var t=te(e,"hx-trigger"),r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}return r.length>0?r:h(e,"form")?[{trigger:"submit"}]:h(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:h(e,rt)?[{trigger:"change"}]:[{trigger:"click"}]}function at(e){ae(e).cancelled=!0}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout((function(){se(e)&&!0!==n.cancelled&&(ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))||t(e),ot(e,t,r))}),r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&0!==ee(e,"href").indexOf("#")}function lt(e,t,r){if("A"===e.tagName&&st(e)&&(""===e.target||"_self"===e.target)||"FORM"===e.tagName){var n,o;if(t.boosted=!0,"A"===e.tagName)n="get",o=ee(e,"href");else{var i=ee(e,"method");n=i?i.toLowerCase():"get",o=ee(e,"action")}r.forEach((function(r){ht(e,(function(e,t){v(e,Q.config.disableSelector)?m(e):he(n,o,e,t)}),t,r,!0)}))}}function ut(e,t){if("submit"===e.type||"click"===e.type){if("FORM"===t.tagName)return!0;if(h(t,'input[type="submit"], button')&&null!==v(t,"form"))return!0;if("A"===t.tagName&&t.href&&("#"===t.getAttribute("href")||0!==t.getAttribute("href").indexOf("#")))return!0}return!1}function ft(e,t){return ae(e).boosted&&"A"===e.tagName&&"click"===t.type&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n)try{return!0!==n.call(t,r)}catch(e){return fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source}),!0}return!1}function ht(e,t,r,n,o){var i,a=ae(e);i=n.from?Z(e,n.from):[e],n.changed&&i.forEach((function(e){ae(e).lastValue=e.value})),oe(i,(function(i){var s=function(r){if(se(e)){if(!ft(e,r)&&((o||ut(r,e))&&r.preventDefault(),!ct(n,e,r))){var l=ae(r);if(l.triggerSpec=n,null==l.handledFor&&(l.handledFor=[]),l.handledFor.indexOf(e)<0){if(l.handledFor.push(e),n.consume&&r.stopPropagation(),n.target&&r.target&&!h(r.target,n.target))return;if(n.once){if(a.triggeredOnce)return;a.triggeredOnce=!0}if(n.changed){var u=ae(i);if(u.lastValue===i.value)return;u.lastValue=i.value}if(a.delayed&&clearTimeout(a.delayed),a.throttle)return;n.throttle>0?a.throttle||(t(e,r),a.throttle=setTimeout((function(){a.throttle=null}),n.throttle)):n.delay>0?a.delayed=setTimeout((function(){t(e,r)}),n.delay):(ce(e,"htmx:trigger"),t(e,r))}}}else i.removeEventListener(n.trigger,s)};null==r.listenerInfos&&(r.listenerInfos=[]),r.listenerInfos.push({trigger:n.trigger,listener:s,on:i}),i.addEventListener(n.trigger,s)}))}var vt=!1,dt=null;function gt(){dt||(dt=function(){vt=!0},window.addEventListener("scroll",dt),setInterval((function(){vt&&(vt=!1,oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),(function(e){pt(e)})))}),200))}function pt(e){!o(e,"data-hx-revealed")&&X(e)&&(e.setAttribute("data-hx-revealed","true"),ae(e).initHash?ce(e,"revealed"):e.addEventListener("htmx:afterProcessNode",(function(t){ce(e,"revealed")}),{once:!0}))}function mt(e,t,r){for(var n=D(r),o=0;o=0){var o=wt(r);setTimeout((function(){xt(e,t,r+1)}),o)}},o.onopen=function(e){r=0},ae(e).webSocket=o,o.addEventListener("message",(function(t){if(!yt(e)){var r=t.data;R(e,(function(t){r=t.transformResponse(r,null,e)}));for(var n=T(e),o=M(l(r).children),i=0;i0?ce(e,"htmx:validation:halted",a):(n.send(JSON.stringify(s)),ut(r,e)&&r.preventDefault())})):fe(e,"htmx:noWebSocketSourceError")}function wt(e){var t=Q.config.wsReconnectDelay;if("function"==typeof t)return t(e);if("full-jitter"===t){var r=Math.min(e,6);return 1e3*Math.pow(2,r)*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){for(var n=D(r),o=0;o0?setTimeout(o,n):o()}function Ht(e,t,r){var n=!1;return oe(w,(function(i){if(o(e,"hx-"+i)){var a=te(e,"hx-"+i);n=!0,t.path=a,t.verb=i,r.forEach((function(r){Lt(e,r,t,(function(e,t){v(e,Q.config.disableSelector)?m(e):he(i,a,e,t)}))}))}})),n}function Lt(e,t,r,n){if(t.sseEvent)Rt(e,n,t.sseEvent);else if("revealed"===t.trigger)gt(),ht(e,n,r,t),pt(e);else if("intersect"===t.trigger){var o={};t.root&&(o.root=ue(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold));var i=new IntersectionObserver((function(t){for(var r=0;r0?(r.polling=!0,ot(e,n,t)):ht(e,n,r,t)}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&("text/javascript"===e.type||"module"===e.type||""===e.type)){var t=re().createElement("script");oe(e.attributes,(function(e){t.setAttribute(e.name,e.value)})),t.textContent=e.textContent,t.async=!1,Q.config.inlineScriptNonce&&(t.nonce=Q.config.inlineScriptNonce);var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{e.parentElement&&e.parentElement.removeChild(e)}}}function Nt(e){h(e,"script")&&At(e),oe(f(e,"script"),(function(e){At(e)}))}function It(e){var t=e.attributes;if(!t)return!1;for(var r=0;r0;){var a=n.shift(),s=a.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);0===i&&s?(a.split(":"),r[o=s[1].slice(0,-1)]=s[2]):r[o]+=a,i+=Bt(a)}for(var l in r)Ft(e,l,r[l])}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize;)o.shift();for(;o.length>0;)try{localStorage.setItem("htmx-history-cache",JSON.stringify(o));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:o}),o.shift()}}}function Yt(e){if(!U())return null;e=B(e);for(var t=E(localStorage.getItem("htmx-history-cache"))||[],r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",r);var t=l(this.response);t=t.querySelector("[hx-history-elt],[data-hx-history-elt]")||t;var n=Zt(),o=T(n),i=Ve(this.response);if(i){var a=C("title");a?a.innerHTML=i:window.document.title=i}Ue(n,t,o),nr(o.tasks),Jt=e,ce(re().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:this.response})}else fe(re().body,"htmx:historyCacheMissLoadError",r)},t.send()}function ar(e){er();var t=Yt(e=e||location.pathname+location.search);if(t){var r=l(t.content),n=Zt(),o=T(n);Ue(n,r,o),nr(o.tasks),document.title=t.title,setTimeout((function(){window.scrollTo(0,t.scroll)}),0),Jt=e,ce(re().body,"htmx:historyRestore",{path:e,item:t})}else Q.config.refreshOnHistoryMiss?window.location.reload(!0):ir(e)}function or(e){var t=me(e,"hx-indicator");return null==t&&(t=[e]),oe(t,(function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1,e.classList.add.call(e.classList,Q.config.requestClass)})),t}function sr(e){var t=me(e,"hx-disabled-elt");return null==t&&(t=[]),oe(t,(function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1,e.setAttribute("disabled","")})),t}function lr(e,t){oe(e,(function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1,0===t.requestCount&&e.classList.remove.call(e.classList,Q.config.requestClass)})),oe(t,(function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1,0===t.requestCount&&e.removeAttribute("disabled")}))}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t||ne(e,"hx-swap"),n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)&&(n.show="top"),r){var o=D(r);if(o.length>0)for(var i=0;i0?u.join(":"):null;n.scroll=s,n.scrollTarget=l}else if(0===a.indexOf("show:")){var u,c=(u=a.substr(5).split(":")).pop();l=u.length>0?u.join(":"):null,n.show=c,n.showTarget=l}else if(0===a.indexOf("focus-scroll:")){var f=a.substr(13);n.focusScroll="true"==f}else 0==i?n.swapStyle=a:b("Unknown modifier in hx-swap: "+a)}}return n}function Sr(e){return"multipart/form-data"===ne(e,"hx-encoding")||h(e,"form")&&"multipart/form-data"===ee(e,"enctype")}function Er(e,t,r){var n=null;return R(t,(function(o){null==n&&(n=o.encodeParameters(e,r,t))})),null!=n?n:Sr(t)?mr(r):pr(r)}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0],n=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=ue(r,t.scrollTarget)),"top"===t.scroll&&(r||o)&&((o=o||r).scrollTop=0),"bottom"===t.scroll&&(n||o)&&((o=o||n).scrollTop=o.scrollHeight)}if(t.show){if(o=null,t.showTarget){var i=t.showTarget;"window"===t.showTarget&&(i="body"),o=ue(r,i)}"top"===t.show&&(r||o)&&(o=o||r).scrollIntoView({block:"start",behavior:Q.config.scrollBehavior}),"bottom"===t.show&&(n||o)&&(o=o||n).scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}function Rr(e,t,r,n){if(null==n&&(n={}),null==e)return n;var o=te(e,t);if(o){var i,a=o.trim(),s=r;if("unset"===a)return null;for(var l in 0===a.indexOf("javascript:")?(a=a.substr(11),s=!0):0===a.indexOf("js:")&&(a=a.substr(3),s=!0),0!==a.indexOf("{")&&(a="{"+a+"}"),i=s?Tr(e,(function(){return Function("return ("+a+")")()}),{}):E(a))i.hasOwnProperty(l)&&null==n[l]&&(n[l]=i[l])}return Rr(u(e),t,r,n)}function Tr(e,t,r){return Q.config.allowEval?t():(fe(e,"htmx:evalDisallowedError"),r)}function Or(e,t){return Rr(e,"hx-vars",!0,t)}function qr(e,t){return Rr(e,"hx-vals",!1,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(e,t,r){if(null!==r)try{e.setRequestHeader(t,r)}catch(n){e.setRequestHeader(t,encodeURIComponent(r)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function Ar(e){if(e.responseURL&&"undefined"!=typeof URL)try{var t=new URL(e.responseURL);return t.pathname+t.search}catch(t){fe(re().body,"htmx:badResponseUrl",{url:e.responseURL})}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){return e=e.toLowerCase(),r?r instanceof Element||I(r,"String")?he(e,t,null,null,{targetOverride:p(r),returnPromise:!0}):he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:!0}):he(e,t,null,null,{returnPromise:!0})}function Ir(e){for(var t=[];e;)t.push(e),e=e.parentElement;return t}function kr(e,t,r){var n,o;return"function"==typeof URL?(o=new URL(t,document.location.href),n=document.location.origin===o.origin):(o=t,n=g(t,document.location.origin)),!(Q.config.selfRequestsOnly&&!n)&&ce(e,"htmx:validateUrl",le({url:o,sameHost:n},r))}function he(e,t,r,n,o,i){var a=null,s=null;if((o=null!=o?o:{}).returnPromise&&"undefined"!=typeof Promise)var l=new Promise((function(e,t){a=e,s=t}));null==r&&(r=re().body);var u=o.handler||Mr,c=o.select||null;if(!se(r))return ie(a),l;var f=o.targetOverride||ye(r);if(null==f||f==pe)return fe(r,"htmx:targetError",{target:te(r,"hx-target")}),ie(s),l;var h=ae(r),d=h.lastButtonClicked;if(d){var v=ee(d,"formaction");null!=v&&(t=v);var g=ee(d,"formmethod");null!=g&&"dialog"!==g.toLowerCase()&&(e=g)}var m=ne(r,"hx-confirm");if(void 0===i){var p={target:f,elt:r,path:t,verb:e,triggeringEvent:n,etc:o,issueRequest:function(i){return he(e,t,r,n,o,!!i)},question:m};if(!1===ce(r,"htmx:confirm",p))return ie(a),l}var x=r,y=ne(r,"hx-sync"),b=null,w=!1;if(y){var S=y.split(":"),E=S[0].trim();if(x="this"===E?xe(r,"hx-sync"):ue(r,E),y=(S[1]||"drop").trim(),h=ae(x),"drop"===y&&h.xhr&&!0!==h.abortable)return ie(a),l;if("abort"===y){if(h.xhr)return ie(a),l;w=!0}else"replace"===y?ce(x,"htmx:abort"):0===y.indexOf("queue")&&(b=(y.split(" ")[1]||"last").trim())}if(h.xhr){if(!h.abortable){if(null==b){if(n){var C=ae(n);C&&C.triggerSpec&&C.triggerSpec.queue&&(b=C.triggerSpec.queue)}null==b&&(b="last")}return null==h.queuedRequests&&(h.queuedRequests=[]),"first"===b&&0===h.queuedRequests.length||"all"===b?h.queuedRequests.push((function(){he(e,t,r,n,o)})):"last"===b&&(h.queuedRequests=[],h.queuedRequests.push((function(){he(e,t,r,n,o)}))),ie(a),l}ce(x,"htmx:abort")}var T=new XMLHttpRequest;h.xhr=T,h.abortable=w;var O=function(){h.xhr=null,h.abortable=!1,null!=h.queuedRequests&&h.queuedRequests.length>0&&h.queuedRequests.shift()()},R=ne(r,"hx-prompt");if(R){var q=prompt(R);if(null===q||!ce(r,"htmx:prompt",{prompt:q,target:f}))return ie(a),O(),l}if(m&&!i&&!confirm(m))return ie(a),O(),l;var H=xr(r,f,q);"get"===e||Sr(r)||(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=le(H,o.headers));var L=dr(r,e),A=L.errors,N=L.values;o.values&&(N=le(N,o.values));var I=le(N,Hr(r)),k=yr(I,r);Q.config.getCacheBusterParam&&"get"===e&&(k["org.htmx.cache-buster"]=ee(f,"id")||"true"),null!=t&&""!==t||(t=re().location.href);var P=Rr(r,"hx-request"),M=ae(r).boosted,D=Q.config.methodsThatUseUrlParams.indexOf(e)>=0,X={boosted:M,useUrlParams:D,parameters:k,unfilteredParameters:I,headers:H,target:f,verb:e,errors:A,withCredentials:o.credentials||P.credentials||Q.config.withCredentials,timeout:o.timeout||P.timeout||Q.config.timeout,path:t,triggeringEvent:n};if(!ce(r,"htmx:configRequest",X))return ie(a),O(),l;if(t=X.path,e=X.verb,H=X.headers,k=X.parameters,D=X.useUrlParams,(A=X.errors)&&A.length>0)return ce(r,"htmx:validation:halted",X),ie(a),O(),l;var F=t.split("#"),U=F[0],B=F[1],j=t;if(D&&(j=U,0!==Object.keys(k).length&&(j.indexOf("?")<0?j+="?":j+="&",j+=pr(k),B&&(j+="#"+B))),!kr(r,j,X))return fe(r,"htmx:invalidPath",X),ie(s),l;if(T.open(e.toUpperCase(),j,!0),T.overrideMimeType("text/html"),T.withCredentials=X.withCredentials,T.timeout=X.timeout,P.noHeaders);else for(var V in H)if(H.hasOwnProperty(V)){var _=H[V];Lr(T,V,_)}var J={xhr:T,target:f,requestConfig:X,etc:o,boosted:M,select:c,pathInfo:{requestPath:t,finalRequestPath:j,anchor:B}};if(T.onload=function(){try{var e=Ir(r);if(J.pathInfo.responsePath=Ar(T),u(r,J),lr(z,$),ce(r,"htmx:afterRequest",J),ce(r,"htmx:afterOnLoad",J),!se(r)){for(var t=null;e.length>0&&null==t;){var n=e.shift();se(n)&&(t=n)}t&&(ce(t,"htmx:afterRequest",J),ce(t,"htmx:afterOnLoad",J))}ie(a),O()}catch(e){throw fe(r,"htmx:onLoadError",le({error:e},J)),e}},T.onerror=function(){lr(z,$),fe(r,"htmx:afterRequest",J),fe(r,"htmx:sendError",J),ie(s),O()},T.onabort=function(){lr(z,$),fe(r,"htmx:afterRequest",J),fe(r,"htmx:sendAbort",J),ie(s),O()},T.ontimeout=function(){lr(z,$),fe(r,"htmx:afterRequest",J),fe(r,"htmx:timeout",J),ie(s),O()},!ce(r,"htmx:beforeRequest",J))return ie(a),O(),l;var z=or(r),$=sr(r);oe(["loadstart","loadend","progress","abort"],(function(e){oe([T,T.upload],(function(t){t.addEventListener(e,(function(t){ce(r,"htmx:xhr:"+e,{lengthComputable:t.lengthComputable,loaded:t.loaded,total:t.total})}))}))})),ce(r,"htmx:beforeSend",J);var Z=D?null:Er(T,r,k);return T.send(Z),l}function Pr(e,t){var r=t.xhr,n=null,o=null;if(O(r,/HX-Push:/i)?(n=r.getResponseHeader("HX-Push"),o="push"):O(r,/HX-Push-Url:/i)?(n=r.getResponseHeader("HX-Push-Url"),o="push"):O(r,/HX-Replace-Url:/i)&&(n=r.getResponseHeader("HX-Replace-Url"),o="replace"),n)return"false"===n?{}:{type:o,path:n};var i=t.pathInfo.finalRequestPath,a=t.pathInfo.responsePath,s=ne(e,"hx-push-url"),l=ne(e,"hx-replace-url"),u=ae(e).boosted,c=null,f=null;return s?(c="push",f=s):l?(c="replace",f=l):u&&(c="push",f=a||i),f?"false"===f?{}:("true"===f&&(f=a||i),t.pathInfo.anchor&&-1===f.indexOf("#")&&(f=f+"#"+t.pathInfo.anchor),{type:c,path:f}):{}}function Mr(e,t){var r=t.xhr,n=t.target,o=t.etc;t.requestConfig;var i=t.select;if(ce(e,"htmx:beforeOnLoad",t)){if(O(r,/HX-Trigger:/i)&&_e(r,"HX-Trigger",e),O(r,/HX-Location:/i)){er();var a=r.getResponseHeader("HX-Location");return 0===a.indexOf("{")&&(v=E(a),a=v.path,delete v.path),void Nr("GET",a,v).then((function(){tr(a)}))}var s=O(r,/HX-Refresh:/i)&&"true"===r.getResponseHeader("HX-Refresh");if(O(r,/HX-Redirect:/i))return location.href=r.getResponseHeader("HX-Redirect"),void(s&&location.reload());if(s)location.reload();else{O(r,/HX-Retarget:/i)&&("this"===r.getResponseHeader("HX-Retarget")?t.target=e:t.target=ue(e,r.getResponseHeader("HX-Retarget")));var l=Pr(e,t),u=r.status>=200&&r.status<400&&204!==r.status,c=r.response,f=r.status>=400,h=Q.config.ignoreTitle,d=le({shouldSwap:u,serverResponse:c,isError:f,ignoreTitle:h},t);if(ce(n,"htmx:beforeSwap",d)){if(n=d.target,c=d.serverResponse,f=d.isError,h=d.ignoreTitle,t.target=n,t.failed=f,t.successful=!f,d.shouldSwap){286===r.status&&at(e),R(e,(function(t){c=t.transformResponse(c,r,e)})),l.type&&er();var v,g=o.swapOverride;O(r,/HX-Reswap:/i)&&(g=r.getResponseHeader("HX-Reswap")),(v=wr(e,g)).hasOwnProperty("ignoreTitle")&&(h=v.ignoreTitle),n.classList.add(Q.config.swappingClass);var m=null,p=null,x=function(){try{var o,a=document.activeElement,s={};try{s={elt:a,start:a?a.selectionStart:null,end:a?a.selectionEnd:null}}catch(a){}i&&(o=i),O(r,/HX-Reselect:/i)&&(o=r.getResponseHeader("HX-Reselect")),l.type&&(ce(re().body,"htmx:beforeHistoryUpdate",le({history:l},t)),"push"===l.type?(tr(l.path),ce(re().body,"htmx:pushedIntoHistory",{path:l.path})):(rr(l.path),ce(re().body,"htmx:replacedInHistory",{path:l.path})));var u=T(n);if(je(v.swapStyle,n,e,c,u,o),s.elt&&!se(s.elt)&&ee(s.elt,"id")){var f=document.getElementById(ee(s.elt,"id")),d={preventScroll:void 0!==v.focusScroll?!v.focusScroll:!Q.config.defaultFocusScroll};if(f){if(s.start&&f.setSelectionRange)try{f.setSelectionRange(s.start,s.end)}catch(a){}f.focus(d)}}if(n.classList.remove(Q.config.swappingClass),oe(u.elts,(function(e){e.classList&&e.classList.add(Q.config.settlingClass),ce(e,"htmx:afterSwap",t)})),O(r,/HX-Trigger-After-Swap:/i)){var g=e;se(e)||(g=re().body),_e(r,"HX-Trigger-After-Swap",g)}var x=function(){if(oe(u.tasks,(function(e){e.call()})),oe(u.elts,(function(e){e.classList&&e.classList.remove(Q.config.settlingClass),ce(e,"htmx:afterSettle",t)})),t.pathInfo.anchor){var n=re().getElementById(t.pathInfo.anchor);n&&n.scrollIntoView({block:"start",behavior:"auto"})}if(u.title&&!h){var o=C("title");o?o.innerHTML=u.title:window.document.title=u.title}if(Cr(u.elts,v),O(r,/HX-Trigger-After-Settle:/i)){var i=e;se(e)||(i=re().body),_e(r,"HX-Trigger-After-Settle",i)}ie(m)};v.settleDelay>0?setTimeout(x,v.settleDelay):x()}catch(a){throw fe(e,"htmx:swapError",t),ie(p),a}},y=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")&&(y=v.transition),y&&ce(e,"htmx:beforeTransition",t)&&"undefined"!=typeof Promise&&document.startViewTransition){var b=new Promise((function(e,t){m=e,p=t})),w=x;x=function(){document.startViewTransition((function(){return w(),b}))}}v.swapDelay>0?setTimeout(x,v.swapDelay):x()}f&&fe(e,"htmx:responseError",le({error:"Response Status Error Code "+r.status+" from "+t.pathInfo.requestPath},t))}}}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return!0},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return!1},handleSwap:function(e,t,r,n){return!1},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){t.init&&t.init(r),Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,t,r){if(null==e)return t;null==t&&(t=[]),null==r&&(r=[]);var n=te(e,"hx-ext");return n&&oe(n.split(","),(function(e){if("ignore:"!=(e=e.replace(/ /g,"")).slice(0,7)){if(r.indexOf(e)<0){var n=Xr[e];n&&t.indexOf(n)<0&&t.push(n)}}else r.push(e.slice(7))})),Fr(u(e),t,r)}var Vr=!1;function jr(e){Vr||"complete"===re().readyState?e():re().addEventListener("DOMContentLoaded",e)}function _r(){!1!==Q.config.includeIndicatorStyles&&re().head.insertAdjacentHTML("beforeend","")}function zr(){var e=re().querySelector('meta[name="htmx-config"]');return e?E(e.content):null}function $r(){var e=zr();e&&(Q.config=le(Q.config,e))}return re().addEventListener("DOMContentLoaded",(function(){Vr=!0})),jr((function(){$r(),_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",(function(e){var t=ae(e.target);t&&t.xhr&&t.xhr.abort()}));const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){e.state&&e.state.htmx?(ar(),oe(t,(function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})}))):r&&r(e)},setTimeout((function(){ce(e,"htmx:load",{}),e=null}),0)})),Q}()},module.exports?module.exports=t():e.htmx=e.htmx||t()})(htmx_min$1);var htmx_min=htmx_min$1.exports;export{htmx_min as default}; diff --git a/server/static/js/voterbowl.mjs b/server/static/js/voterbowl.mjs index f94ce3b..de9716d 100644 --- a/server/static/js/voterbowl.mjs +++ b/server/static/js/voterbowl.mjs @@ -1,5 +1,5 @@ -import * as htmx from "./htmx.min.js"; -import { Fireworks } from "./fireworks.js"; +import htmx from "./htmx.mjs"; +import { Fireworks } from "./fireworks.mjs"; /*----------------------------------------------------------------- * API Calls @@ -144,11 +144,22 @@ customElements.define("fail-check", FailCheck); class FinishCheck extends HTMLElement { connectedCallback() { - // smoothly scroll to the top of the page after a slight delay - setTimeout(() => window.scrollTo({ top: 0, behavior: "smooth" }), 100); + console.log("FinishCheck connected"); + + // smoothly scroll to the top of the page after a 1/2 second + // delay, since some browsers will ignore the scrollIntoView + // if it's called too soon after the page loads or reflows + setTimeout(() => { + document.body.scrollIntoView({ + block: "start", + inline: "nearest", + behavior: "smooth", + }); + }, 500); // if the user is a winner, start the fireworks const { isWinner } = this.dataset; + console.log("FinishCheck isWinner:", isWinner); if (isWinner !== "true") { return; } @@ -166,6 +177,8 @@ class FinishCheck extends HTMLElement { } } +customElements.define("finish-check", FinishCheck); + /*----------------------------------------------------------------- * Gift code clipboard behavior * -----------------------------------------------------------------*/ diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index bc31016..0926e52 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -1,7 +1,6 @@ import htpy as h from django.conf import settings from django.contrib.humanize.templatetags.humanize import naturaltime -from django.templatetags.static import static from django.urls import reverse from server.utils.components import style @@ -16,7 +15,6 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: """Render a school-specific 'check voter registration' form page.""" extra_head = [ - h.script(src=static("js/fireworks.js")), h.script(src="https://cdn.voteamerica.com/embed/tools.js", _async=True), ] return base_page( @@ -110,7 +108,7 @@ def _finish_check_description( h.br, h.br, "You didn't win a gift card. ", - f"The last winner was {most_recent_winner.student.anonymized_name} {naturaltime(most_recent_winner.created_at)} ago." + f"The last winner was {most_recent_winner.student.anonymized_name} {naturaltime(most_recent_winner.created_at)}. " if most_recent_winner else None, "Your friends can still win! ", From 3daf042f1933da2e0944408b94260ad17b232b21 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 14 May 2024 11:57:21 -0700 Subject: [PATCH 24/28] Update README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d3023d1..420f189 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ We spend all our hipster tech tokens for this project to help us build a front-e - [HTMX](https://htmx.org/) with [django-htmx](https://github.com/adamchainz/django-htmx) - [css-scope-inline](https://github.com/gnat/css-scope-inline) -- [surreal](https://github.com/gnat/surreal?tab=readme-ov-file) +- [htpy](https://htpy.dev/) for HTML building, rather than Django templates (with a small handful of exceptions). -Having never used any of these toys before, we'll see how this pans out. (Update: so far, I sorta like HTMX but don't love how I've structured everything with the other two. And I hate Django templates as much as I remember.) +Having never used any of these toys before, we'll see how this pans out. (Update: I'm going to go back and remove `css-scope-inline` when I have a chance, in favor of, y'know, just being more careful with top-level CSS. `htpy` is better than Django templates for our purposes but not without its friction and pain.) -(Others under consideration include [django-slippers](https://github.com/mixxorz/slippers), [django-template-partials](https://github.com/carltongibson/django-template-partials), and [django-components](https://github.com/EmilStenstrom/django-components). And don't forget the [django-htmx-patterns](https://github.com/spookylukey/django-htmx-patterns/) documentation.) +(Others under consideration include [surreal](https://github.com/gnat/surreal?tab=readme-ov-file), [django-slippers](https://github.com/mixxorz/slippers), [django-template-partials](https://github.com/carltongibson/django-template-partials), and [django-components](https://github.com/EmilStenstrom/django-components). And don't forget the [django-htmx-patterns](https://github.com/spookylukey/django-htmx-patterns/) documentation.) For code cleanliness, we also use: From f466860aee9d2c8465a3cc8d8109bdec19804e95 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 14 May 2024 13:04:10 -0700 Subject: [PATCH 25/28] Fix updated static refs --- server/vb/components/base_page.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/vb/components/base_page.py b/server/vb/components/base_page.py index ad48d27..b1952d6 100644 --- a/server/vb/components/base_page.py +++ b/server/vb/components/base_page.py @@ -51,8 +51,8 @@ def base_page( h.meta(http_equiv="X-UA-Compatible", content="IE=edge"), h.meta(name="vierwport", content="width=device-width, initial-scale=1.0"), h.meta(name="format-detection", content="telephone=no"), - h.link(rel="stylesheet", href=static("/css/modern-normalize.min.css")), - h.link(rel="stylesheet", href=static("/css/base.css")), + h.link(rel="stylesheet", href=static("css/modern-normalize.min.css")), + h.link(rel="stylesheet", href=static("css/base.css")), h.script(src=static("js/css-scope-inline.js")), h.script(src=static("js/voterbowl.mjs"), type="module"), style(__file__, "base_page.css", bg_color=bg_color), From 15ea1a53cfe4e55de43af4fdb61a0de6036cb81c Mon Sep 17 00:00:00 2001 From: Dave Peck Date: Tue, 14 May 2024 23:16:50 -0700 Subject: [PATCH 26/28] Support $1000 and $0 tests (#36) * New tests. * WIP: Add migration for complex contest additions * Good first cut. --- README.md | 2 + server/vb/components/check_page.py | 69 ++++++-- server/vb/components/countdown.py | 50 +++++- server/vb/components/faq.md | 8 +- server/vb/components/logo_specimen.css | 2 +- server/vb/components/ongoing_contest.py | 41 ++++- server/vb/components/school_page.py | 62 ++++++- .../0011_complex_contest_additions.py | 53 ++++++ server/vb/models.py | 161 +++++++++++++++--- server/vb/ops.py | 25 ++- server/vb/templates/email/code/subject.txt | 2 +- server/vb/views.py | 6 +- 12 files changed, 410 insertions(+), 71 deletions(-) create mode 100644 server/vb/migrations/0011_complex_contest_additions.py diff --git a/README.md b/README.md index 420f189..f017796 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,5 @@ For code cleanliness, we also use: 1. Get postgres set up. If you've got docker installed, `./scripts/dockerpg.sh up` 1. Configure your environment variables. (See `.env.sample` and `settings.py`) 1. Run the app. `./manage.py runserver` and visit http://localhost:8000/ + +New tests. diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index 0926e52..d2adfa3 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -92,10 +92,20 @@ def _finish_check_description( ] if contest_entry and contest_entry.is_winner: + contest = contest_entry.contest + if contest.is_monetary: + return [ + h.b["You win!"], + f" We sent a ${contest_entry.amount_won} {contest.prize} to your school email. ", + "(Check your spam folder.) ", + h.br, + h.br, + "Your friends can also win. ", + share_link, + ] return [ - h.b["You win!"], - f" We sent a ${contest_entry.amount_won} gift card to your school email. ", - "(Check your spam folder.) ", + h.b["You win: "], + f"{contest.prize_long}.", h.br, h.br, "Your friends can also win. ", @@ -103,17 +113,48 @@ def _finish_check_description( ] if contest_entry: - return [ - "Please register to vote if you haven't yet.", - h.br, - h.br, - "You didn't win a gift card. ", - f"The last winner was {most_recent_winner.student.anonymized_name} {naturaltime(most_recent_winner.created_at)}. " - if most_recent_winner - else None, - "Your friends can still win! ", - share_link, - ] + contest = contest_entry.contest + if contest.is_no_prize: + return [ + "Thanks for checking your voter registration. ", + "Please register to vote if you haven't yet.", + h.br, + h.br, + "Tell your friends! ", + share_link, + ] + if contest.is_giveaway: + raise RuntimeError( + f"Giveaways should always have winners ({contest_entry.pk})" + ) + if contest.is_dice_roll: + # Works for both monetary and non-monetary dice rolls + return [ + "Please register to vote if you haven't yet.", + h.br, + h.br, + f"You didn't win a {contest.prize}. ", + f"The last winner was {most_recent_winner.student.anonymized_name} {naturaltime(most_recent_winner.created_at)}. " + if most_recent_winner + else None, + "Your friends can still win! ", + share_link, + ] + if contest.is_single_winner: + # We don't know if the user won or lost, so we don't say anything + # more than 'we'll let you know' + return [ + "Thanks! Please register to vote if you haven't yet.", + h.br, + h.br, + "We'll send you an email soon ", + "to let you know if you won.", + h.br, + h.br, + "Your friends can also enter to win. ", + share_link, + ] + raise ValueError(f"Unknown contest kind: {contest.kind}") return [ "Thanks for checking your voter registration.", diff --git a/server/vb/components/countdown.py b/server/vb/components/countdown.py index 5cabb4e..63b1312 100644 --- a/server/vb/components/countdown.py +++ b/server/vb/components/countdown.py @@ -5,6 +5,49 @@ from ..models import Contest +def _describe_contest(contest: Contest) -> h.Node: + """Render a description of the given contest.""" + if contest.is_no_prize: + return h.p["Check your registration status soon:"] + if contest.is_giveaway: + if contest.is_monetary: + return h.p[ + f"${contest.amount} {contest.prize_long}", + h.br, + "giveaway ends in:", + ] + return h.p[ + contest.prize_long, + h.br, + "ends in:", + ] + if contest.is_dice_roll: + if contest.is_monetary: + return h.p[ + f"${contest.amount} {contest.prize_long}", + h.br, + "contest ends in:", + ] + return h.p[ + contest.prize_long, + h.br, + "ends in:", + ] + if contest.is_single_winner: + if contest.is_monetary: + return h.p[ + f"${contest.amount} {contest.prize_long}", + h.br, + "sweepstakes ends in:", + ] + return h.p[ + contest.prize_long, + h.br, + "ends in:", + ] + raise ValueError(f"Unknown contest kind: {contest.kind}") + + def countdown(contest: Contest) -> h.Element: """Render a countdown timer for the given contest.""" logo = contest.school.logo @@ -16,12 +59,7 @@ def countdown(contest: Contest) -> h.Element: number_bg_color=logo.action_color, colon_color=logo.bg_text_color, ), - h.p[ - f"${contest.amount} Amazon gift card", - h.br, - "giveaway " if contest.is_giveaway else "contest ", - "ends in:", - ], + _describe_contest(contest), h.big_countdown(data_end_at=contest.end_at.isoformat())[ h.div(".countdown")[ h.span(".number", data_number="h0"), diff --git a/server/vb/components/faq.md b/server/vb/components/faq.md index 6381c8f..63e1fc7 100644 --- a/server/vb/components/faq.md +++ b/server/vb/components/faq.md @@ -10,11 +10,11 @@ The Voter Bowl is a contest where college students win prizes by checking if the The Voter Bowl is a nonprofit, nonpartisan project of [VoteAmerica](https://www.voteamerica.com/), a national leader in voter registration and participation. -### How do I claim my gift card? +### How do I claim my prize? -If you win, we'll send an Amazon gift card to your student email address. - -You can redeem your gift card by typing the claim code into [Amazon.com](https://www.amazon.com/gc/redeem). +Prizes vary depending on the contest. If the contest includes a prize that +can be delivered digitally and you win, we'll send you an email with further +instructions. [Read the full contest rules here](/rules). diff --git a/server/vb/components/logo_specimen.css b/server/vb/components/logo_specimen.css index fa1ec29..577fac3 100644 --- a/server/vb/components/logo_specimen.css +++ b/server/vb/components/logo_specimen.css @@ -40,7 +40,7 @@ me .action { padding-right: 0.5rem; transition: opacity 0.2s; background-color: var(--logo-action-color); - color: var(--logo-action_text_color); + color: var(--logo-action-text-color); } me .action:hover { diff --git a/server/vb/components/ongoing_contest.py b/server/vb/components/ongoing_contest.py index 5852e23..af708ae 100644 --- a/server/vb/components/ongoing_contest.py +++ b/server/vb/components/ongoing_contest.py @@ -7,6 +7,41 @@ from .logo import school_logo +def _ongoing_description(contest: Contest) -> list[str]: + """Render a description of the given contest.""" + if contest.is_no_prize: + return ["Check your voter registration status soon!"] + if contest.is_giveaway: + if contest.is_monetary: + return [ + "Check your voter registration status ", + f"to win a ${contest.amount} {contest.prize_long}.", + ] + return ["Check your voter registration status ", f"for {contest.prize_long}."] + if contest.is_dice_roll: + if contest.is_monetary: + return [ + "Check your voter registration status ", + f"for a 1 in {contest.in_n} chance " + f"to win a ${contest.amount} {contest.prize_long}.", + ] + return [ + "Check your voter registration status ", + f"for a 1 in {contest.in_n} chance to at {contest.prize_long}.", + ] + if contest.is_single_winner: + if contest.is_monetary: + return [ + "Check your voter registration status ", + f"for a chance to win a ${contest.amount} {contest.prize_long}.", + ] + return [ + "Check your voter registration status ", + f"for a chance to win {contest.prize_long}.", + ] + raise ValueError(f"Unknown contest kind: {contest.kind}") + + def ongoing_contest(contest: Contest) -> h.Element: """Render an ongoing contest.""" return h.div[ @@ -16,11 +51,7 @@ def ongoing_contest(contest: Contest) -> h.Element: h.div(".content")[ school_logo(contest.school), h.p(".school")[contest.school.name], - h.p(".description")[ - "Check your voter registration status", - None if contest.is_giveaway else f" for a 1 in {contest.in_n} chance", - f" to win a ${contest.amount} Amazon gift card.", - ], + h.p(".description")[*_ongoing_description(contest)], h.div(".button-holder")[ button( href=contest.school.relative_url, bg_color="black", color="white" diff --git a/server/vb/components/school_page.py b/server/vb/components/school_page.py index 0b239be..f52fcbd 100644 --- a/server/vb/components/school_page.py +++ b/server/vb/components/school_page.py @@ -10,21 +10,65 @@ def _current_contest_info(school: School, contest: Contest) -> h.Node: - return h.p[ - school.short_name, - " students: check your registration status", - f"for a 1 in {contest.in_n } chance", - f"to win a ${contest.amount} Amazon gift card.", - ] + if contest.is_no_prize: + return h.p[ + school.short_name, + " ", + "students: it's a good idea to check your registration status early!", + ] + if contest.is_giveaway: + if contest.is_monetary: + return h.p[ + school.short_name, + " ", + "students: check your registration status ", + f"to win a ${contest.amount} {contest.prize_long}.", + ] + return h.p[ + school.short_name, + " ", + "students: check your registration status ", + f"for a {contest.prize_long}.", + ] + if contest.is_dice_roll: + if contest.is_monetary: + return h.p[ + school.short_name, + " ", + "students: check your registration status ", + f"for a 1 in {contest.in_n} chance ", + f"to win a ${contest.amount} {contest.prize_long}.", + ] + return h.p[ + school.short_name, + " ", + "students: check your registration status ", + f"for a 1 in {contest.in_n} chance", + f"at {contest.prize_long}.", + ] + if contest.is_single_winner: + if contest.is_monetary: + return h.p[ + school.short_name, + " ", + "students: check your registration status ", + f"for a chance to win a ${contest.amount} {contest.prize_long}.", + ] + return h.p[ + school.short_name, + " ", + "students: check your registration status ", + f"for a chance to win {contest.prize_long}.", + ] + raise ValueError(f"Unknown contest kind: {contest.kind}") def _past_contest_info(school: School, contest: Contest) -> h.Node: return [ h.p[ school.short_name, - f" students: the ${contest.amount} ", - "giveaway" if contest.is_giveaway else "contest", - " has ended.", + " ", + "students: the contest recently ended.", ], h.p["But: it's always a good time to make sure you're ready to vote."], ] diff --git a/server/vb/migrations/0011_complex_contest_additions.py b/server/vb/migrations/0011_complex_contest_additions.py new file mode 100644 index 0000000..f73367e --- /dev/null +++ b/server/vb/migrations/0011_complex_contest_additions.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.3 on 2024-05-15 05:10 + +from django.db import migrations, models + + +def data_migrate_kind(apps, schema_editor): + Contest = apps.get_model('vb', 'Contest') + # We had to pick a default kind for each contest; we fix historical data here. + for contest in Contest.objects.all(): + if contest.kind == "giveaway" and contest.in_n > 1: + contest.kind = 'dice_roll' + contest.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('vb', '0010_remove_ambiguous_email_sent_at'), + ] + + operations = [ + migrations.AddField( + model_name='contest', + name='kind', + field=models.CharField(choices=[('giveaway', 'Giveaway'), ('dice_roll', 'Dice roll'), ('single_winner', 'Single winner'), ('no_prize', 'No prize')], default='giveaway', max_length=32), + ), + migrations.AddField( + model_name='contest', + name='prize', + field=models.CharField(blank=True, default='gift card', help_text='A short description of the prize, if any.', max_length=255), + ), + migrations.AddField( + model_name='contest', + name='prize_long', + field=models.CharField(blank=True, default='Amazon gift card', help_text='A long description of the prize, if any.', max_length=255), + ), + migrations.AddField( + model_name='contest', + name='workflow', + field=models.CharField(choices=[('amazon', 'Amazon'), ('none', 'None')], default='amazon', max_length=32), + ), + migrations.AlterField( + model_name='contest', + name='amount', + field=models.IntegerField(default=0, help_text='The amount of the prize.'), + ), + migrations.AlterField( + model_name='contest', + name='in_n', + field=models.IntegerField(default=1, help_text='1 in_n students will win a prize.'), + ), + migrations.RunPython(data_migrate_kind), + ] diff --git a/server/vb/models.py b/server/vb/models.py index dc295ba..579509d 100644 --- a/server/vb/models.py +++ b/server/vb/models.py @@ -7,7 +7,6 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import models -from django.template import Context, Template from django.urls import reverse from django.utils.timezone import now as django_now @@ -170,6 +169,32 @@ def current(self, when: datetime.datetime | None = None) -> "Contest | None": return self.ongoing(when).first() +class ContestKind(models.TextChoices): + """The various kinds of contests.""" + + # Every student wins a prize (gift card; charitable donation; etc.) + GIVEAWAY = "giveaway", "Giveaway" + + # Every student rolls a dice; some students win a prize. + DICE_ROLL = "dice_roll", "Dice roll" + + # A single student wins a prize after the contest ends. + SINGLE_WINNER = "single_winner", "Single winner" + + # No prizes are awarded. + NO_PRIZE = "no_prize", "No prize" + + +class ContestWorkflow(models.TextChoices): + """The various workflows for contests.""" + + # Issue an amazon gift card and email automatically + AMAZON = "amazon", "Amazon" + + # No automated workflow; manual intervention may be required + NONE = "none", "None" + + class Contest(models.Model): """A single contest in the competition.""" @@ -184,51 +209,108 @@ class Contest(models.Model): start_at = models.DateTimeField(blank=False) end_at = models.DateTimeField(blank=False) - # For now, we assume: + # The assumptions here have changed basically weekly as we gather more + # data and learn more. As of this writing, our current assumptions are: # - # 1. Anyone who checks their voter registration during the contest period - # is a winner. - # 2. All winners receive the same Amazon gift card amount as a prize. - amount = models.IntegerField( - blank=False, help_text="The USD amount of the gift card.", default=5 + # 1. We support four kinds of contest: + # + # - Giveaway: every student immediately wins a prize. + # - Dice roll: every student rolls a dice and may immediately win a prize. + # - Single winner: a single student wins a prize after the contest ends. + # - No prize: no prizes are awarded. + + kind = models.CharField( + max_length=32, + choices=ContestKind.choices, + blank=False, + default=ContestKind.GIVEAWAY, ) + in_n = models.IntegerField( blank=False, - help_text="1 in_n students will win a gift card.", + help_text="1 in_n students will win a prize.", default=1, ) + # 2. Some contests require automated workflows to award prizes. Currently + # we only have one such action: 'amazon', for issuing Amazon gift cards + # and sending emails to the winners. + + workflow = models.CharField( + max_length=32, + choices=ContestWorkflow.choices, + blank=False, + default=ContestWorkflow.AMAZON, + ) + + # 3. Prizes need short and long descriptions. + # + # For instance, historically we used "gift card" and "Amazon gift card" + # as our descriptions. + # + # Newer examples include "gift card" and "prepaid Visa gift card", or + # "donation" and "donation to charity". + # + # Monetary prizes have a dollar amount associated with them. + amount = models.IntegerField( + blank=False, help_text="The amount of the prize.", default=0 + ) + + prize = models.CharField( + max_length=255, + blank=True, + default="gift card", + help_text="A short description of the prize, if any.", + ) + prize_long = models.CharField( + max_length=255, + blank=True, + default="Amazon gift card", + help_text="A long description of the prize, if any.", + ) + contest_entries: "ContestEntryManager" + @property + def has_immmediate_winners(self) -> bool: + """Return whether the contest has immediate winners.""" + return self.is_giveaway or self.is_dice_roll + def most_recent_winner(self) -> "ContestEntry | None": - """Return the most recent winner for this contest.""" + """ + Return the most recent winner for this contest. + + Return None if there is not yet a winner, or if the contest has no + immediate winners. + """ + if not self.has_immmediate_winners: + return None return self.contest_entries.winners().order_by("-created_at").first() @property - def name(self) -> str: - """Render the contest name template.""" - if self.in_n > 1: - template_str = "${{ contest.amount }} Amazon Gift Card Giveaway (1 in {{ contest.in_n }} wins)" # noqa - else: - template_str = "${{ contest.amount }} Amazon Gift Card Giveaway" - context = {"school": self.school, "contest": self} - return Template(template_str).render(Context(context)) + def is_dice_roll(self) -> bool: + """Return whether the contest is a dice roll.""" + return self.kind == ContestKind.DICE_ROLL @property def is_giveaway(self) -> bool: """Return whether the contest is a giveaway.""" - return self.in_n == 1 + return self.kind == ContestKind.GIVEAWAY + + @property + def is_single_winner(self) -> bool: + """Return whether the contest is a single winner.""" + return self.kind == ContestKind.SINGLE_WINNER @property - def description(self) -> str: - """Render the contest description template.""" - template_str = "{{ school.short_name }} students: check your voter registration to win a ${{ contest.amount }} Amazon gift card." # noqa - context = {"school": self.school, "contest": self} - return Template(template_str).render(Context(context)) + def is_no_prize(self) -> bool: + """Return whether the contest is a no prize.""" + return self.kind == ContestKind.NO_PRIZE - def _roll_die(self) -> int: - """Roll a fair die from [0, self.in_n).""" - return secrets.randbelow(self.in_n) + @property + def is_monetary(self) -> bool: + """Return whether the contest has a monetary prize.""" + return self.amount > 0 def roll_die_and_get_winnings(self) -> tuple[int, int]: """ @@ -236,7 +318,12 @@ def roll_die_and_get_winnings(self) -> tuple[int, int]: Return a tuple of the roll and the amount won (or 0 if no win). """ - roll = self._roll_die() + if self.is_no_prize or self.is_single_winner: + return (1, 0) + if self.is_giveaway: + return (0, self.amount) + # self.is_dice_roll + roll = secrets.randbelow(self.in_n) amount_won = self.amount if roll == 0 else 0 return roll, amount_won @@ -255,6 +342,26 @@ def is_past(self, when: datetime.datetime | None = None) -> bool: when = when or django_now() return self.end_at <= when + @property + def name(self) -> str: + """Render an administrative name for the template.""" + if self.is_no_prize: + return "No prize" + elif self.is_giveaway: + # 1 Tree Planted, $5 Amazon Gift Card + if self.is_monetary: + return f"${self.amount} {self.prize_long.title()} Giveaway" + return f"{self.prize_long.title()}" + elif self.is_dice_roll: + if self.is_monetary: + return f"${self.amount} {self.prize_long.title()} Contest (1 in {self.in_n} wins)" # noqa + return f"{self.prize_long.title()} (1 in {self.in_n} wins)" + elif self.is_single_winner: + if self.is_monetary: + return f"${self.amount} {self.prize_long.title()} Sweepstakes" + return f"{self.prize_long.title()} Sweepstakes" + raise ValueError("Unknown contest kind") + def __str__(self): """Return the contest model's string representation.""" return f"Contest: {self.name} for {self.school.name}" diff --git a/server/vb/ops.py b/server/vb/ops.py index 033fbf5..cec2712 100644 --- a/server/vb/ops.py +++ b/server/vb/ops.py @@ -7,7 +7,14 @@ from server.utils.email import send_template_email from server.utils.tokens import make_token -from .models import Contest, ContestEntry, EmailValidationLink, School, Student +from .models import ( + Contest, + ContestEntry, + ContestWorkflow, + EmailValidationLink, + School, + Student, +) logger = logging.getLogger(__name__) @@ -167,6 +174,22 @@ def get_or_create_student( return student +# ----------------------------------------------------------------------------- +# Workflows +# ----------------------------------------------------------------------------- + + +def process_contest_workflow( + student: Student, email: str, contest_entry: ContestEntry +) -> None: + """Process the workflow for a contest entry.""" + if not contest_entry.is_winner: + return + if contest_entry.contest.workflow != ContestWorkflow.AMAZON: + return + send_validation_link_email(student, email, contest_entry) + + # ----------------------------------------------------------------------------- # Emails # ----------------------------------------------------------------------------- diff --git a/server/vb/templates/email/code/subject.txt b/server/vb/templates/email/code/subject.txt index 84456b4..c3d68ef 100644 --- a/server/vb/templates/email/code/subject.txt +++ b/server/vb/templates/email/code/subject.txt @@ -1 +1 @@ -Your ${{ contest_entry.amount_won }} Amazon gift card +Your ${{ contest_entry.amount_won }} gift card diff --git a/server/vb/views.py b/server/vb/views.py index 3d6c279..04acd3f 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -18,7 +18,7 @@ enter_contest, get_or_create_student, get_or_issue_prize, - send_validation_link_email, + process_contest_workflow, ) logger = logging.getLogger(__name__) @@ -143,8 +143,8 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: # Send the student an email validation link to claim their prize # if they won. In no other cases do we send validation links. - if contest_entry and contest_entry.is_winner: - send_validation_link_email(student, email, contest_entry) + if contest_entry is not None: + process_contest_workflow(student, email, contest_entry) most_recent_winner = None if current_contest is not None: From 6bc5e986e21e78726ad517f507a098ed3a3e568f Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 15 May 2024 08:46:57 -0700 Subject: [PATCH 27/28] Final text changes for $1k drawing. --- server/static/js/voterbowl.mjs | 2 +- server/vb/components/check_page.py | 7 ++++--- server/vb/components/countdown.py | 8 ++++---- server/vb/components/ongoing_contest.py | 6 +++--- server/vb/components/school_page.py | 6 +++--- server/vb/models.py | 8 ++++---- 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/server/static/js/voterbowl.mjs b/server/static/js/voterbowl.mjs index de9716d..a40840f 100644 --- a/server/static/js/voterbowl.mjs +++ b/server/static/js/voterbowl.mjs @@ -98,7 +98,7 @@ class FailCheck extends HTMLElement { } /** @type {HTMLElement|null} */ - const target = this.querySelector(".urgency"); + const target = document.querySelector(".urgency"); if (!target) { console.error("Missing target element"); return; diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index d2adfa3..460b223 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -147,11 +147,12 @@ def _finish_check_description( "Thanks! Please register to vote if you haven't yet.", h.br, h.br, - "We'll send you an email soon ", - "to let you know if you won.", + f"You're entered into the ${contest.amount:,} drawing and we'll email you if you win." + if contest.is_monetary + else "You're entered into the drawing and we'll email you if you win.", h.br, h.br, - "Your friends can also enter to win. ", + "Your friends can also win! ", share_link, ] raise ValueError(f"Unknown contest kind: {contest.kind}") diff --git a/server/vb/components/countdown.py b/server/vb/components/countdown.py index 63b1312..b39d40d 100644 --- a/server/vb/components/countdown.py +++ b/server/vb/components/countdown.py @@ -12,7 +12,7 @@ def _describe_contest(contest: Contest) -> h.Node: if contest.is_giveaway: if contest.is_monetary: return h.p[ - f"${contest.amount} {contest.prize_long}", + f"${contest.amount:,} {contest.prize_long}", h.br, "giveaway ends in:", ] @@ -24,7 +24,7 @@ def _describe_contest(contest: Contest) -> h.Node: if contest.is_dice_roll: if contest.is_monetary: return h.p[ - f"${contest.amount} {contest.prize_long}", + f"${contest.amount:,} {contest.prize_long}", h.br, "contest ends in:", ] @@ -36,9 +36,9 @@ def _describe_contest(contest: Contest) -> h.Node: if contest.is_single_winner: if contest.is_monetary: return h.p[ - f"${contest.amount} {contest.prize_long}", + f"${contest.amount:,} {contest.prize_long}", h.br, - "sweepstakes ends in:", + "drawing ends in:", ] return h.p[ contest.prize_long, diff --git a/server/vb/components/ongoing_contest.py b/server/vb/components/ongoing_contest.py index af708ae..2df9446 100644 --- a/server/vb/components/ongoing_contest.py +++ b/server/vb/components/ongoing_contest.py @@ -15,7 +15,7 @@ def _ongoing_description(contest: Contest) -> list[str]: if contest.is_monetary: return [ "Check your voter registration status ", - f"to win a ${contest.amount} {contest.prize_long}.", + f"to win a ${contest.amount:,} {contest.prize_long}.", ] return ["Check your voter registration status ", f"for {contest.prize_long}."] if contest.is_dice_roll: @@ -23,7 +23,7 @@ def _ongoing_description(contest: Contest) -> list[str]: return [ "Check your voter registration status ", f"for a 1 in {contest.in_n} chance " - f"to win a ${contest.amount} {contest.prize_long}.", + f"to win a ${contest.amount:,} {contest.prize_long}.", ] return [ "Check your voter registration status ", @@ -33,7 +33,7 @@ def _ongoing_description(contest: Contest) -> list[str]: if contest.is_monetary: return [ "Check your voter registration status ", - f"for a chance to win a ${contest.amount} {contest.prize_long}.", + f"for a chance to win a ${contest.amount:,} {contest.prize_long}.", ] return [ "Check your voter registration status ", diff --git a/server/vb/components/school_page.py b/server/vb/components/school_page.py index f52fcbd..95323d3 100644 --- a/server/vb/components/school_page.py +++ b/server/vb/components/school_page.py @@ -22,7 +22,7 @@ def _current_contest_info(school: School, contest: Contest) -> h.Node: school.short_name, " ", "students: check your registration status ", - f"to win a ${contest.amount} {contest.prize_long}.", + f"to win a ${contest.amount:,} {contest.prize_long}.", ] return h.p[ school.short_name, @@ -37,7 +37,7 @@ def _current_contest_info(school: School, contest: Contest) -> h.Node: " ", "students: check your registration status ", f"for a 1 in {contest.in_n} chance ", - f"to win a ${contest.amount} {contest.prize_long}.", + f"to win a ${contest.amount:,} {contest.prize_long}.", ] return h.p[ school.short_name, @@ -52,7 +52,7 @@ def _current_contest_info(school: School, contest: Contest) -> h.Node: school.short_name, " ", "students: check your registration status ", - f"for a chance to win a ${contest.amount} {contest.prize_long}.", + f"for a chance to win a ${contest.amount:,} {contest.prize_long}.", ] return h.p[ school.short_name, diff --git a/server/vb/models.py b/server/vb/models.py index 579509d..61adc6e 100644 --- a/server/vb/models.py +++ b/server/vb/models.py @@ -350,16 +350,16 @@ def name(self) -> str: elif self.is_giveaway: # 1 Tree Planted, $5 Amazon Gift Card if self.is_monetary: - return f"${self.amount} {self.prize_long.title()} Giveaway" + return f"${self.amount:,} {self.prize_long.title()} Giveaway" return f"{self.prize_long.title()}" elif self.is_dice_roll: if self.is_monetary: - return f"${self.amount} {self.prize_long.title()} Contest (1 in {self.in_n} wins)" # noqa + return f"${self.amount:,} {self.prize_long.title()} Contest (1 in {self.in_n} wins)" # noqa return f"{self.prize_long.title()} (1 in {self.in_n} wins)" elif self.is_single_winner: if self.is_monetary: - return f"${self.amount} {self.prize_long.title()} Sweepstakes" - return f"{self.prize_long.title()} Sweepstakes" + return f"${self.amount:,} {self.prize_long.title()} Drawing" + return f"{self.prize_long.title()} Drawing" raise ValueError("Unknown contest kind") def __str__(self): From c46b394d2659de41e63a0e4c4de60e955012a849 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 15 May 2024 08:56:45 -0700 Subject: [PATCH 28/28] Slight word improvement. --- server/vb/components/check_page.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index 460b223..cb40674 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -147,9 +147,9 @@ def _finish_check_description( "Thanks! Please register to vote if you haven't yet.", h.br, h.br, - f"You're entered into the ${contest.amount:,} drawing and we'll email you if you win." + f"You're entered into the ${contest.amount:,} drawing. We'll email you if you win." if contest.is_monetary - else "You're entered into the drawing and we'll email you if you win.", + else "You're entered into the drawing. We'll email you if you win.", h.br, h.br, "Your friends can also win! ",