From 0baab1972e30c0e4629e67152838e660b02a2537 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Mon, 15 Dec 2025 08:47:02 -0500 Subject: t-265.6: Add feedback collection endpoint for PIL - Add feedback table with migration in Core.py - Add FeedbackForm and FeedbackPage UI components - Add /feedback GET/POST routes and /api/feedback JSON endpoint - Add admin feedback view at /admin/feedback - Create Omni/Agent/Tools/Feedback.hs with feedback_list tool - Wire feedback tool into Telegram agent --- Biz/PodcastItLater/Web.py | 85 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 2 deletions(-) (limited to 'Biz/PodcastItLater/Web.py') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 30b5236..076eb95 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -167,7 +167,8 @@ def send_magic_link(email: str, token: str) -> None: # Create email body magic_link = f"{BASE_URL}/auth/verify?token={token}" - body_text_path.write_text(f""" + body_text_path.write_text( + f""" Hello, Click this link to login to PodcastItLater: @@ -179,7 +180,9 @@ If you didn't request this, please ignore this email. Best, PodcastItLater -""") +""", + encoding="utf-8", + ) try: Biz.EmailAgent.send_email( @@ -1033,6 +1036,83 @@ def pricing(request: Request) -> UI.PricingPage: ) +@app.get("/feedback") +def feedback_form(request: Request) -> UI.FeedbackPage: + """Display feedback form.""" + user_id = request.session.get("user_id") + user = Core.Database.get_user_by_id(user_id) if user_id else None + campaign_id = request.query_params.get("campaign") + + return UI.FeedbackPage( + user=user, + campaign_id=campaign_id, + success=False, + ) + + +@app.post("/feedback") +async def submit_feedback(request: Request) -> UI.FeedbackPage: + """Submit feedback form.""" + import secrets + + user_id = request.session.get("user_id") + user = Core.Database.get_user_by_id(user_id) if user_id else None + + form_data = await request.form() + + email = form_data.get("email") + source = form_data.get("source") + campaign_id = form_data.get("campaign_id") + rating_str = form_data.get("rating") + feedback_text = form_data.get("feedback_text") + use_case = form_data.get("use_case") + + rating = int(rating_str) if rating_str else None + + feedback_id = secrets.token_urlsafe(16) + + Core.Database.create_feedback( + feedback_id=feedback_id, + email=str(email) if email else None, + source=str(source) if source else None, + campaign_id=str(campaign_id) if campaign_id else None, + rating=rating, + feedback_text=str(feedback_text) if feedback_text else None, + use_case=str(use_case) if use_case else None, + ) + + logger.info( + "Received feedback %s from %s", feedback_id, email or "anonymous" + ) + + return UI.FeedbackPage( + user=user, + campaign_id=None, + success=True, + ) + + +@app.get("/api/feedback") +def api_feedback(request: Request) -> Response: + """Return feedback entries as JSON for agent tools.""" + import json + + limit_str = request.query_params.get("limit", "20") + since = request.query_params.get("since") + + try: + limit = min(100, max(1, int(limit_str))) + except ValueError: + limit = 20 + + feedback = Core.Database.get_feedback(limit=limit, since=since) + + return Response( + json.dumps(feedback), + media_type="application/json", + ) + + @app.post("/upgrade") def upgrade(request: Request) -> RedirectResponse: """Start upgrade checkout flow.""" @@ -1832,6 +1912,7 @@ def cancel_queue_item(request: Request, job_id: int) -> Response: app.delete("/queue/{job_id}")(Admin.delete_queue_item) app.get("/admin/users")(Admin.admin_users) app.get("/admin/metrics")(Admin.admin_metrics) +app.get("/admin/feedback")(Admin.admin_feedback) app.post("/admin/users/{user_id}/status")(Admin.update_user_status) app.post("/admin/episode/{episode_id}/toggle-public")( Admin.toggle_episode_public, -- cgit v1.2.3 From a7dcb30c7a465d9fce72b7fc3e605470b2b59814 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Tue, 16 Dec 2025 08:06:09 -0500 Subject: feat(deploy): Complete mini-PaaS deployment system (t-266) - Add Omni/Deploy/ with Manifest, Deployer, Systemd, Caddy modules - Manifest CLI: show, update, add-service, list, rollback commands - Deployer: polls S3 manifest, pulls closures, manages systemd units - Caddy integration for dynamic reverse proxy routes - bild: auto-cache to S3, outputs STORE_PATH for push.sh - push.sh: supports both NixOS and service deploys - Biz.nix: simplified to base OS + deployer only - Services (podcastitlater-web/worker) now deployer-managed - Documentation: README.md with operations guide --- Biz/PodcastItLater/Web.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) (limited to 'Biz/PodcastItLater/Web.py') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 076eb95..257938f 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -18,7 +18,7 @@ Provides ludic + htmx interface and RSS feed generation. # : dep stripe # : dep sqids import Biz.EmailAgent -import Biz.PodcastItLater.Admin as Admin +import Biz.PodcastItLater.Admin.Core as Admin import Biz.PodcastItLater.Billing as Billing import Biz.PodcastItLater.Core as Core import Biz.PodcastItLater.Episode as Episode @@ -28,7 +28,6 @@ import httpx import logging import ludic.html as html import Omni.App as App -import Omni.Log as Log import Omni.Test as Test import os import pathlib @@ -57,7 +56,9 @@ from typing import override from unittest.mock import patch logger = logging.getLogger(__name__) -Log.setup(logger) +logging.basicConfig( + level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s" +) # Configuration @@ -86,9 +87,10 @@ def decode_episode_id(sqid: str) -> int | None: # Authentication configuration MAGIC_LINK_MAX_AGE = 3600 # 1 hour SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days -EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@podcastitlater.com") +EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@bensima.com") SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.mailgun.org") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) # Initialize serializer for magic links magic_link_serializer = URLSafeTimedSerializer( @@ -192,6 +194,7 @@ PodcastItLater password=SMTP_PASSWORD, subject=subject, body_text=body_text_path, + port=SMTP_PORT, ) finally: # Clean up temporary file @@ -1067,7 +1070,7 @@ async def submit_feedback(request: Request) -> UI.FeedbackPage: feedback_text = form_data.get("feedback_text") use_case = form_data.get("use_case") - rating = int(rating_str) if rating_str else None + rating = int(str(rating_str)) if rating_str else None feedback_id = secrets.token_urlsafe(16) -- cgit v1.2.3 From 451b3421313a53b3e7ab15d95fd4b1231f5b7773 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Tue, 16 Dec 2025 16:17:47 -0500 Subject: Update homepage with marketing copy and fix push.sh --- Biz/PodcastItLater/Web.py | 119 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 110 insertions(+), 9 deletions(-) (limited to 'Biz/PodcastItLater/Web.py') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 257938f..bc12000 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -906,19 +906,114 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): error = self.attrs.get("error") if not user: - # Show public feed with login form for logged-out users + # Marketing landing page for logged-out users return UI.PageLayout( - LoginForm(error=error), + # Hero section html.div( - html.h4( - html.i(classes=["bi", "bi-broadcast", "me-2"]), - "Public Feed", - classes=["mb-3", "mt-4"], + html.h1( + "Your Reading List, Now a Podcast", + classes=["display-5", "fw-bold", "mb-3"], ), html.p( - "Featured articles converted to audio. " - "Sign up to create your own personal feed!", - classes=["text-muted", "mb-3"], + "Convert web articles to audio. " + "Listen during your commute, workout, or chores.", + classes=["lead", "text-muted", "mb-4"], + ), + html.a( + "Start Listening", + href="#login", + classes=["btn", "btn-primary", "btn-lg"], + ), + classes=["text-center", "py-5", "mb-5"], + ), + # How it works section + html.div( + html.h3( + "How It Works", + classes=["text-center", "mb-4"], + ), + html.div( + html.div( + html.div( + html.div( + html.span( + "1", + classes=[ + "badge", + "bg-primary", + "rounded-circle", + "fs-5", + "mb-3", + ], + ), + html.h5("Paste any article URL"), + html.p( + "Copy the link to any article you want to listen to.", + classes=["text-muted", "small"], + ), + classes=["card-body", "text-center"], + ), + classes=["card", "h-100"], + ), + classes=["col-md-4", "mb-3"], + ), + html.div( + html.div( + html.div( + html.span( + "2", + classes=[ + "badge", + "bg-primary", + "rounded-circle", + "fs-5", + "mb-3", + ], + ), + html.h5("We convert it to audio"), + html.p( + "Our AI reads the article and creates a podcast episode.", + classes=["text-muted", "small"], + ), + classes=["card-body", "text-center"], + ), + classes=["card", "h-100"], + ), + classes=["col-md-4", "mb-3"], + ), + html.div( + html.div( + html.div( + html.span( + "3", + classes=[ + "badge", + "bg-primary", + "rounded-circle", + "fs-5", + "mb-3", + ], + ), + html.h5("Listen in your podcast app"), + html.p( + "Subscribe to your personal RSS feed in any podcast app.", + classes=["text-muted", "small"], + ), + classes=["card-body", "text-center"], + ), + classes=["card", "h-100"], + ), + classes=["col-md-4", "mb-3"], + ), + classes=["row"], + ), + classes=["mb-5"], + ), + # Social proof section + html.div( + html.h4( + "Join others who've converted their reading backlog", + classes=["text-center", "mb-4"], ), EpisodeList( episodes=episodes, @@ -926,6 +1021,12 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): user=None, viewing_own_feed=False, ), + classes=["mb-5"], + ), + # Login form + html.div( + LoginForm(error=error), + id="login", ), user=None, current_page="home", -- cgit v1.2.3 From 32c2bb198007ab85095c14be544cfca9d083a7cd Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Wed, 17 Dec 2025 11:00:29 -0500 Subject: Improve PodcastItLater homepage and dark mode support Homepage changes: - New marketing-focused landing page for logged-out users - Hero: 'Your Reading List, as a Podcast' with clear value prop - How It Works: 3-step process cards (paste URL, convert, listen) - Social proof: 'Join others' section with public feed - Login form moved below social proof for better conversion flow Dark mode fixes: - Remove bg-light from Current Plan card on /account page - Remove bg-white from metrics card headers on /admin/metrics Layout improvements: - Stack metrics tables vertically (full-width) instead of horizontal - Add spacing between metric cards for better readability --- Biz/PodcastItLater/Web.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) (limited to 'Biz/PodcastItLater/Web.py') diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index bc12000..c472819 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -911,12 +911,13 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): # Hero section html.div( html.h1( - "Your Reading List, Now a Podcast", + "Your Reading List, as a Podcast", classes=["display-5", "fw-bold", "mb-3"], ), html.p( - "Convert web articles to audio. " - "Listen during your commute, workout, or chores.", + "Natural-sounding audio for your articles. " + "Delivered to your podcast app. " + "Listen while commuting, cooking, or working out.", classes=["lead", "text-muted", "mb-4"], ), html.a( @@ -972,7 +973,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): ), html.h5("We convert it to audio"), html.p( - "Our AI reads the article and creates a podcast episode.", + "Our AI reads the article in a natural, human-sounding voice and creates a podcast episode.", classes=["text-muted", "small"], ), classes=["card-body", "text-center"], @@ -1015,18 +1016,22 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): "Join others who've converted their reading backlog", classes=["text-center", "mb-4"], ), + classes=["mb-4"], + ), + # Login form + html.div( + LoginForm(error=error), + id="login", + classes=["mb-5"], + ), + # Recent episodes + html.div( EpisodeList( episodes=episodes, rss_url=None, user=None, viewing_own_feed=False, ), - classes=["mb-5"], - ), - # Login form - html.div( - LoginForm(error=error), - id="login", ), user=None, current_page="home", -- cgit v1.2.3