diff options
Diffstat (limited to 'Biz/PodcastItLater/Web.py')
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 351 |
1 files changed, 332 insertions, 19 deletions
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 03d3eb7..2032746 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -22,7 +22,6 @@ import Biz.PodcastItLater.Billing as Billing import Biz.PodcastItLater.Core as Core import html as html_module import httpx -import ludic.catalog.pages as pages import ludic.html as html import Omni.App as App import Omni.Log as Log @@ -600,6 +599,300 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]): ) +class BillingPageAttrs(Attrs): + """Attributes for BillingPage component.""" + + user: dict[str, typing.Any] + usage: dict[str, int] + period_start: str + period_end: str + success: str | None + error: str | None + + +class BillingPage(Component[AnyChildren, BillingPageAttrs]): + """Billing page showing current plan, usage, and upgrade options.""" + + @override + def render(self) -> html.html: + user = self.attrs["user"] + usage = self.attrs["usage"] + period_start = self.attrs["period_start"] + period_end = self.attrs["period_end"] + success = self.attrs.get("success") + error_msg = self.attrs.get("error") + + tier = user.get("plan_tier", "free") + tier_info = Billing.get_tier_info(tier) + has_subscription = tier != "free" + + return html.html( + html.head( + html.meta(charset="utf-8"), + html.meta( + name="viewport", + content="width=device-width, initial-scale=1", + ), + html.title("Billing - PodcastItLater"), + html.script( + src="https://unpkg.com/htmx.org@1.9.10", + integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC", + crossorigin="anonymous", + ), + ), + html.body( + html.style( + "@import url('https://cdn.jsdelivr.net/npm/bootstrap@5.3.2" + "/dist/css/bootstrap.min.css');" + "@import url('https://cdn.jsdelivr.net/npm/bootstrap-icons" + "@1.11.3/font/bootstrap-icons.min.css');", + ), + html.div( + html.div( + html.h1( + html.i(classes=["bi", "bi-credit-card", "me-2"]), + "Billing & Usage", + classes=["mb-4"], + ), + html.div( + html.a( + html.i(classes=["bi", "bi-arrow-left", "me-1"]), + "Back to Dashboard", + href="/", + classes=[ + "btn", + "btn-outline-secondary", + "mb-4", + ], + ), + ), + classes=["mb-4"], + ), + # Success/Error alerts + html.div( + html.div( + html.i( + classes=["bi", "bi-check-circle-fill", "me-2"], + ), + success or "", + classes=["alert", "alert-success"], + ), + ) + if success + else html.div(), + html.div( + html.div( + html.i( + classes=[ + "bi", + "bi-exclamation-triangle-fill", + "me-2", + ], + ), + error_msg or "", + classes=["alert", "alert-danger"], + ), + ) + if error_msg + else html.div(), + # Current Plan Card + html.div( + html.div( + html.h4( + html.i(classes=["bi", "bi-star-fill", "me-2"]), + "Current Plan", + classes=["card-title", "mb-3"], + ), + html.div( + html.div( + html.h3( + tier_info["name"], + classes=["text-primary"], + ), + html.p( + tier_info["description"], + classes=["text-muted"], + ), + classes=["col-md-6"], + ), + html.div( + html.h5( + "Usage This Period", + classes=["mb-2"], + ), + html.p( + html.i( + classes=[ + "bi", + "bi-file-text", + "me-2", + ], + ), + ( + f"{usage['articles']} / " + f"{tier_info['articles_limit'] or '∞'} " # noqa: E501 + "articles" + ), + classes=["mb-1"], + ), + html.p( + html.i( + classes=[ + "bi", + "bi-calendar-range", + "me-2", + ], + ), + ( + f"Period: {period_start} - " + f"{period_end}" + ), + classes=["text-muted", "small"], + ), + classes=["col-md-6"], + ), + classes=["row"], + ), + # Manage subscription button for paid users + html.div( + html.form( + html.button( + html.i( + classes=[ + "bi", + "bi-gear-fill", + "me-1", + ], + ), + "Manage Subscription", + type="submit", + classes=[ + "btn", + "btn-primary", + ], + ), + method="post", + action="/billing/portal", + ), + classes=["mt-3"], + ) + if has_subscription + else html.div(), + classes=["card-body"], + ), + classes=["card", "mb-4"], + ), + # Pricing Cards + html.div( + html.h4("Available Plans", classes=["mb-4"]), + html.div( + self._render_pricing_card("free", tier == "free"), + self._render_pricing_card( + "personal", + tier == "personal", + ), + self._render_pricing_card("pro", tier == "pro"), + classes=["row", "g-3"], + ), + ), + classes=["container", "my-5", "px-3", "px-md-4"], + style={"max-width": "1000px"}, + ), + html.script( + src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js", + integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL", + crossorigin="anonymous", + ), + ), + ) + + @staticmethod + def _render_pricing_card( + tier_name: str, + is_current: bool, # noqa: FBT001 + ) -> html.div: + """Render a single pricing card.""" + tier_info = Billing.get_tier_info(tier_name) + + # Determine card styling based on tier + card_classes = ["card", "h-100"] + if tier_name == "pro": + card_classes.append("border-primary") + + return html.div( + html.div( + html.div( + html.div( + html.h5( + tier_info["name"], + classes=["card-title"], + ), + html.span( + "Current Plan", + classes=["badge", "bg-success", "ms-2"], + ) + if is_current + else html.span(), + ), + html.h3( + tier_info["price"], + classes=["my-3"], + ), + html.p( + tier_info["description"], + classes=["text-muted"], + ), + html.ul( + html.li( + ( + f"{tier_info['articles_limit'] or 'Unlimited'} " + "articles per month" + ), + ), + html.li("High-quality TTS"), + html.li("Personal RSS feed"), + classes=["list-unstyled", "mb-4"], + ), + # Upgrade button (only show for tiers higher than current) + html.form( + html.input( + type="hidden", + name="tier", + value=tier_name, + ), + html.button( + "Upgrade", + type="submit", + classes=[ + "btn", + "btn-primary" + if tier_name == "pro" + else "btn-outline-primary", + "w-100", + ], + ), + method="post", + action="/billing/checkout", + ) + if tier_name != "free" and not is_current + else html.div( + html.button( + "Current Plan", + type="button", + disabled=True, + classes=["btn", "btn-secondary", "w-100"], + ), + ) + if is_current + else html.div(), + classes=["card-body"], + ), + classes=card_classes, + ), + classes=["col-12", "col-sm-6", "col-md-4"], + ) + + class HomePageAttrs(Attrs): """Attributes for HomePage component.""" @@ -613,25 +906,26 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): """Main page combining all components.""" @override - def render(self) -> pages.HtmlPage: + def render(self) -> html.html: queue_items = self.attrs["queue_items"] episodes = self.attrs["episodes"] user = self.attrs.get("user") - return pages.HtmlPage( - pages.Head( - title="PodcastItLater", - htmx_version="1.9.10", - load_styles=False, - ), - pages.Body( - # Add HTMX + return html.html( + html.head( + html.meta(charset="utf-8"), + html.meta( + name="viewport", + content="width=device-width, initial-scale=1", + ), + html.title("PodcastItLater"), html.script( src="https://unpkg.com/htmx.org@1.9.10", integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC", crossorigin="anonymous", ), - # Add Bootstrap CSS and icons (pages.Head doesn't support it) + ), + html.body( html.style( "@import url('https://cdn.jsdelivr.net/npm/bootstrap@5.3.2" "/dist/css/bootstrap.min.css');" @@ -707,6 +1001,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): "btn-outline-secondary", "btn-sm", "me-2", + "mb-2", ], ), html.a( @@ -724,6 +1019,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): "btn-outline-primary", "btn-sm", "me-2", + "mb-2", ], ) if Core.is_admin(user) @@ -742,8 +1038,10 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): "btn", "btn-outline-danger", "btn-sm", + "mb-2", ], ), + classes=["d-flex", "flex-wrap"], ), classes=["card-body", "bg-light"], ), @@ -771,7 +1069,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): ) if user else html.div(), - classes=["container", "max-w-4xl"], + classes=["container", "px-3", "px-md-4"], style={"max-width": "900px"}, ), # Bootstrap JS bundle @@ -1171,7 +1469,7 @@ app.post("/queue/{job_id}/retry")(Admin.retry_queue_item) @app.get("/billing") -def billing_page(request: Request) -> Response: +def billing_page(request: Request) -> BillingPage | RedirectResponse: """Display billing page with current plan and upgrade options.""" user_id = request.session.get("user_id") if not user_id: @@ -1181,15 +1479,30 @@ def billing_page(request: Request) -> Response: if not user: return RedirectResponse(url="/?error=user_not_found") - tier = user.get("plan_tier", "free") - tier_info = Billing.get_tier_info(tier) - # Get current usage period_start, period_end = Billing.get_period_boundaries(user) - Billing.get_usage(user_id, period_start, period_end) + usage = Billing.get_usage(user_id, period_start, period_end) + + # Check for status query params + status = request.query_params.get("status") + success = None + error = None + if status == "success": + success = ( + "Subscription updated successfully! " + "Changes may take a few moments to reflect." + ) + elif status == "cancel": + error = "Checkout cancelled." - # Billing page component to be implemented - return Response(f"<h1>Billing - Current plan: {tier_info['name']}</h1>") + return BillingPage( + user=user, + usage=usage, + period_start=period_start.strftime("%Y-%m-%d"), + period_end=period_end.strftime("%Y-%m-%d"), + success=success, + error=error, + ) @app.post("/billing/checkout") |
