summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater
diff options
context:
space:
mode:
Diffstat (limited to 'Biz/PodcastItLater')
-rw-r--r--Biz/PodcastItLater/Billing.py6
-rw-r--r--Biz/PodcastItLater/Web.py532
2 files changed, 159 insertions, 379 deletions
diff --git a/Biz/PodcastItLater/Billing.py b/Biz/PodcastItLater/Billing.py
index 41d04d6..4996607 100644
--- a/Biz/PodcastItLater/Billing.py
+++ b/Biz/PodcastItLater/Billing.py
@@ -165,8 +165,8 @@ def create_checkout_session(user_id: int, tier: str, base_url: str) -> str:
session_params = {
"mode": "subscription",
"line_items": [{"price": price_id, "quantity": 1}],
- "success_url": f"{base_url}/billing?status=success",
- "cancel_url": f"{base_url}/billing?status=cancel",
+ "success_url": f"{base_url}/?status=success",
+ "cancel_url": f"{base_url}/?status=cancel",
"client_reference_id": str(user_id),
"metadata": {"user_id": str(user_id), "tier": tier},
"allow_promotion_codes": True,
@@ -214,7 +214,7 @@ def create_portal_session(user_id: int, base_url: str) -> str:
session = stripe.billing_portal.Session.create(
customer=user["stripe_customer_id"],
- return_url=f"{base_url}/billing",
+ return_url=f"{base_url}/",
)
logger.info("Created portal session for user %s: %s", user_id, session.id)
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index 274d46c..27b75dd 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -621,306 +621,6 @@ 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.meta(
- name="color-scheme",
- content="light dark",
- ),
- 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');",
- ),
- # Auto dark mode CSS (must come after Bootstrap)
- UI.create_auto_dark_mode_style(),
- 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("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."""
@@ -934,10 +634,10 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
"""Main page combining all components."""
@staticmethod
- def _render_plan_banner(
+ def _render_plan_callout(
user: dict[str, typing.Any],
- ) -> html.div | html.span:
- """Render plan banner with quota for free users."""
+ ) -> html.div:
+ """Render plan info callout box below navbar."""
tier = user.get("plan_tier", "free")
if tier == "free":
@@ -949,31 +649,61 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
articles_left = max(0, articles_limit - articles_used)
return html.div(
- html.span(
- html.strong("Free: "),
- f"{articles_left} articles left",
- classes=["navbar-text", "me-3"],
- ),
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-arrow-up-circle",
- "me-1",
- ],
+ html.div(
+ html.div(
+ html.i(
+ classes=[
+ "bi",
+ "bi-info-circle-fill",
+ "me-2",
+ ],
+ ),
+ html.strong(f"{articles_left} articles remaining"),
+ " of your free plan limit. ",
+ html.br(),
+ "Upgrade to ",
+ html.strong("Paid Plan"),
+ " for unlimited articles at $12/month.",
+ ),
+ html.form(
+ html.input(
+ type="hidden",
+ name="tier",
+ value="paid",
+ ),
+ html.button(
+ html.i(
+ classes=[
+ "bi",
+ "bi-arrow-up-circle",
+ "me-1",
+ ],
+ ),
+ "Upgrade Now",
+ type="submit",
+ classes=[
+ "btn",
+ "btn-success",
+ "btn-sm",
+ "mt-2",
+ ],
+ ),
+ method="post",
+ action="/billing/checkout",
),
- "Upgrade Now",
- href="/billing",
classes=[
- "btn",
- "btn-success",
- "btn-sm",
+ "alert",
+ "alert-info",
+ "d-flex",
+ "justify-content-between",
+ "align-items-center",
+ "mb-4",
],
),
- classes=["d-flex", "align-items-center"],
+ classes=["mb-4"],
)
- # Paid plan - don't show plan name on navbar
- return html.span()
+ # Paid user - no callout needed
+ return html.div()
@override
def render(self) -> html.html:
@@ -1074,11 +804,6 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
"mb-lg-0",
],
),
- # Plan banner/upgrade button
- html.div(
- self._render_plan_banner(user),
- classes=["navbar-nav", "mb-2", "mb-lg-0"],
- ),
# Action buttons
html.div(
html.div(
@@ -1086,12 +811,12 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
html.i(
classes=[
"bi",
- "bi-credit-card",
+ "bi-person-circle",
"me-1",
],
),
- "Billing",
- href="/billing",
+ "Manage Account",
+ href="/account",
classes=[
"btn",
"btn-outline-secondary",
@@ -1100,10 +825,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
"mb-2",
"mb-lg-0",
],
- )
- if user.get("plan_tier", "free")
- != "free"
- else html.span(),
+ ),
html.a(
html.i(
classes=[
@@ -1160,6 +882,8 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
)
if user
else LoginForm(error=self.attrs.get("error")),
+ # Plan info callout (only for logged in users)
+ self._render_plan_callout(user) if user else html.div(),
# Main content (only if logged in)
html.div(
SubmitForm(),
@@ -1211,6 +935,7 @@ def index(request: Request) -> HomePage:
queue_items = []
episodes = []
error = request.query_params.get("error")
+ status = request.query_params.get("status")
# Map error codes to user-friendly messages
error_messages = {
@@ -1218,8 +943,16 @@ def index(request: Request) -> HomePage:
"expired_link": "Login link has expired. Please request a new one.",
"user_not_found": "User not found. Please try logging in again.",
"forbidden": "Access denied. Admin privileges required.",
+ "cancel": "Checkout cancelled.",
}
- error_message = error_messages.get(error) if error else None
+
+ # Handle billing status messages
+ if status == "success":
+ error_message = None
+ elif status == "cancel":
+ error_message = error_messages["cancel"]
+ else:
+ error_message = error_messages.get(error) if error else None
if user_id:
user = Core.Database.get_user_by_id(user_id)
@@ -1384,6 +1117,90 @@ def verify_magic_link(request: Request) -> Response:
return RedirectResponse("/?error=expired_link")
+@app.get("/account")
+def account_page(request: Request) -> html.html | RedirectResponse:
+ """Account management page (coming soon)."""
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return RedirectResponse(url="/?error=login_required")
+
+ user = Core.Database.get_user_by_id(user_id)
+ if not user:
+ return RedirectResponse(url="/?error=user_not_found")
+
+ return html.html(
+ html.head(
+ html.meta(charset="utf-8"),
+ html.meta(
+ name="viewport",
+ content="width=device-width, initial-scale=1",
+ ),
+ html.meta(
+ name="color-scheme",
+ content="light dark",
+ ),
+ html.title("Account - PodcastItLater"),
+ ),
+ 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');",
+ ),
+ UI.create_auto_dark_mode_style(),
+ html.div(
+ html.div(
+ html.h1(
+ html.i(
+ classes=["bi", "bi-person-circle", "me-2"],
+ ),
+ "Account Management",
+ 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",
+ ],
+ ),
+ ),
+ html.div(
+ html.div(
+ html.i(
+ classes=["bi", "bi-info-circle-fill", "me-2"],
+ ),
+ html.strong("Coming Soon"),
+ html.p(
+ "Account management features including "
+ "subscription management, usage history, and "
+ "settings will be available here.",
+ classes=["mb-0", "mt-2"],
+ ),
+ classes=["alert", "alert-info"],
+ ),
+ ),
+ classes=["mb-4"],
+ ),
+ classes=["container", "my-5", "px-3", "px-md-4"],
+ style={"max-width": "900px"},
+ ),
+ html.script(
+ src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js",
+ integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL",
+ crossorigin="anonymous",
+ ),
+ ),
+ )
+
+
@app.get("/logout")
def logout(request: Request) -> Response:
"""Handle logout."""
@@ -1576,43 +1393,6 @@ app.get("/admin")(Admin.admin_queue_status)
app.post("/queue/{job_id}/retry")(Admin.retry_queue_item)
-@app.get("/billing")
-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:
- return RedirectResponse(url="/?error=login_required")
-
- user = Core.Database.get_user_by_id(user_id)
- if not user:
- return RedirectResponse(url="/?error=user_not_found")
-
- # Get current usage
- period_start, period_end = Billing.get_period_boundaries(user)
- 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."
-
- 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")
def billing_checkout(request: Request, data: FormData) -> Response:
"""Create Stripe Checkout session."""
@@ -1620,9 +1400,9 @@ def billing_checkout(request: Request, data: FormData) -> Response:
if not user_id:
return Response("Unauthorized", status_code=401)
- tier_raw = data.get("tier", "pro")
- tier = tier_raw if isinstance(tier_raw, str) else "pro"
- if tier != "pro":
+ tier_raw = data.get("tier", "paid")
+ tier = tier_raw if isinstance(tier_raw, str) else "paid"
+ if tier != "paid":
return Response("Invalid tier", status_code=400)
try: