summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater/Web.py
diff options
context:
space:
mode:
Diffstat (limited to 'Biz/PodcastItLater/Web.py')
-rw-r--r--Biz/PodcastItLater/Web.py351
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")