From 028bbda4282515b95a7555209d397aaf22d32244 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 18:07:12 -0500 Subject: feat: implement t-PpYZt2 --- Biz/PodcastItLater/UI.py | 162 ++++++++++++++++++++++++++++++++++++++++++++++ Biz/PodcastItLater/Web.py | 30 +++++++++ Omni/Task.hs | 8 ++- Omni/Task/Core.hs | 26 +++++++- 4 files changed, 224 insertions(+), 2 deletions(-) diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index da0193c..27f5fff 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -210,6 +210,24 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): ), classes=["nav-item"], ), + html.li( + html.a( + html.i( + classes=[ + "bi", + "bi-stars", + "me-1", + ], + ), + "Pricing", + href="/pricing", + classes=[ + "nav-link", + "active" if is_active("pricing") else "", + ], + ), + classes=["nav-item"], + ), html.li( html.a( html.i( @@ -387,3 +405,147 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): create_bootstrap_js(), ), ) + + +class PricingPageAttrs(Attrs): + """Attributes for PricingPage component.""" + + user: dict[str, typing.Any] | None + + +class PricingPage(Component[AnyChildren, PricingPageAttrs]): + """Pricing page component.""" + + @override + def render(self) -> PageLayout: + user = self.attrs.get("user") + current_tier = user.get("plan_tier", "free") if user else "free" + + return PageLayout( + user=user, + current_page="pricing", + page_title="Pricing - PodcastItLater", + error=None, + meta_tags=[], + children=[ + html.div( + html.h2("Simple Pricing", classes=["text-center", "mb-5"]), + html.div( + # Free Tier + html.div( + html.div( + html.div( + html.h3("Free", classes=["card-title"]), + html.h4( + "$0", + classes=[ + "card-subtitle", + "mb-3", + "text-muted", + ], + ), + html.p( + "10 articles total", + classes=["card-text"], + ), + html.ul( + html.li("Convert 10 articles"), + html.li("Basic features"), + classes=["list-unstyled", "mb-4"], + ), + html.button( + "Current Plan", + classes=[ + "btn", + "btn-outline-primary", + "w-100", + ], + disabled=True, + ) + if current_tier == "free" + else html.div(), + classes=["card-body"], + ), + classes=["card", "mb-4", "shadow-sm", "h-100"], + ), + classes=["col-md-6"], + ), + # Paid Tier + html.div( + html.div( + html.div( + html.h3( + "Unlimited", + classes=["card-title"], + ), + html.h4( + "$12/mo", + classes=[ + "card-subtitle", + "mb-3", + "text-muted", + ], + ), + html.p( + "Unlimited articles", + classes=["card-text"], + ), + html.ul( + html.li("Unlimited conversions"), + html.li("Priority processing"), + html.li("Support independent software"), + classes=["list-unstyled", "mb-4"], + ), + html.form( + html.button( + "Upgrade Now", + type="submit", + classes=[ + "btn", + "btn-primary", + "w-100", + ], + ), + action="/upgrade", + method="POST", + ) + if user and current_tier == "free" + else ( + html.button( + "Current Plan", + classes=[ + "btn", + "btn-success", + "w-100", + ], + disabled=True, + ) + if user and current_tier == "paid" + else html.a( + "Login to Upgrade", + href="/", + classes=[ + "btn", + "btn-primary", + "w-100", + ], + ) + ), + classes=["card-body"], + ), + classes=[ + "card", + "mb-4", + "shadow-sm", + "border-primary", + "h-100", + ], + ), + classes=["col-md-6"], + ), + classes=["row"], + ), + classes=["container", "py-3"], + ), + ], + ) diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 4a8e57e..7e8e969 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -973,6 +973,36 @@ def public_feed(request: Request) -> PublicFeedPage: ) +@app.get("/pricing") +def pricing(request: Request) -> UI.PricingPage: + """Display pricing page.""" + user_id = request.session.get("user_id") + user = Core.Database.get_user_by_id(user_id) if user_id else None + + return UI.PricingPage( + user=user, + ) + + +@app.post("/upgrade") +def upgrade(request: Request) -> RedirectResponse: + """Start upgrade checkout flow.""" + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse(url="/?error=login_required") + + try: + checkout_url = Billing.create_checkout_session( + user_id, + "paid", + BASE_URL, + ) + return RedirectResponse(url=checkout_url, status_code=303) + except ValueError: + logger.exception("Failed to create checkout session") + return RedirectResponse(url="/pricing?error=checkout_failed") + + def _handle_test_login(email: str, request: Request) -> Response: """Handle login in test mode.""" # Special handling for demo account diff --git a/Omni/Task.hs b/Omni/Task.hs index d1e672a..10136c9 100644 --- a/Omni/Task.hs +++ b/Omni/Task.hs @@ -312,7 +312,13 @@ unitTests = ready <- getReadyTasks -- Both should be ready since Related doesn't block (taskId task1 `elem` map taskId ready) Test.@?= True - (taskId task2 `elem` map taskId ready) Test.@?= True + (taskId task2 `elem` map taskId ready) Test.@?= True, + Test.unit "child task gets sequential ID" <| do + parent <- createTask "Parent" Epic Nothing Nothing P2 [] + child1 <- createTask "Child 1" WorkTask (Just (taskId parent)) Nothing P2 [] + child2 <- createTask "Child 2" WorkTask (Just (taskId parent)) Nothing P2 [] + taskId child1 Test.@?= taskId parent <> ".1" + taskId child2 Test.@?= taskId parent <> ".2" ] -- | Test CLI argument parsing to ensure docopt string matches actual usage diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs index f7b7915..798f8fe 100644 --- a/Omni/Task/Core.hs +++ b/Omni/Task/Core.hs @@ -111,6 +111,28 @@ generateId = do encoded = toBase62 (fromIntegral microseconds) pure <| "t-" <> T.pack encoded +-- Generate a child ID based on parent ID (e.g. "t-abc.1") +generateChildId :: Text -> IO Text +generateChildId parentId = do + tasks <- loadTasks + let children = filter (\t -> taskParent t == Just parentId) tasks + -- Find the max suffix + suffixes = mapMaybe (\t -> getSuffix parentId (taskId t)) children + nextSuffix = case suffixes of + [] -> 1 + s -> maximum s + 1 + pure <| parentId <> "." <> T.pack (show nextSuffix) + +getSuffix :: Text -> Text -> Maybe Int +getSuffix parent childId = + if parent `T.isPrefixOf` childId && T.length childId > T.length parent + then + let rest = T.drop (T.length parent) childId + in if T.head rest == '.' + then readMaybe (T.unpack (T.tail rest)) + else Nothing + else Nothing + -- Convert number to base62 (0-9, a-z, A-Z) toBase62 :: Integer -> String toBase62 0 = "0" @@ -192,7 +214,9 @@ saveTask task = do -- Create a new task createTask :: Text -> TaskType -> Maybe Text -> Maybe Text -> Priority -> [Dependency] -> IO Task createTask title taskType parent namespace priority deps = do - tid <- generateId + tid <- case parent of + Nothing -> generateId + Just pid -> generateChildId pid now <- getCurrentTime let task = Task -- cgit v1.2.3