From e34e2422be92a9df268c79003d5e55e999f1dd6c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 00:21:37 +0000 Subject: [PATCH] Give Live TV passes a front door: /iptv, and the pitch where a reader has no line The argontv line has been for sale since January, and the only way to find it was the IPTV tab inside account settings. The Live TV page linked to /account, which opens on the Account tab, so even the one link there landed a click short. Nothing on /pricing mentioned it. /iptv is the page that sells a pass and reports on the one held, readable signed out the way /pricing is. Every price on it comes from the same table the checkout charges, so the page can never advertise one number and the account take another. Buying still happens on the account's IPTV tab, where the crypto picker and the CoinPay handoff already live; /iptv chooses the term and sends the reader there with it preselected, through login if need be, and the choice survives the round trip. The same card is pitched on Live TV to a signed-in reader with no playlist at all, and nowhere to somebody who already has channels, which is the rule tipoffwatch applies on its event page. /pricing carries it between the plans and the payment methods. /account now honours ?tab= and ?package=, which it never did. Not touched: the reseller call itself. The stored argontv key is refused by distributors.argontv.nl and prod has no IPTV_ARGON_TEMPLATE_ID, so a purchase would pay and then fail to provision until both are fixed in the reseller dashboard and Doppler. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SKAohrRkqLKVQL2cGCAkR5 --- src/app/account/account-content.tsx | 18 ++- src/app/iptv/iptv-client.tsx | 143 +++++++++++++++++ src/app/iptv/page.test.tsx | 122 ++++++++++++++ src/app/iptv/page.tsx | 24 +++ src/app/live-tv/live-tv-content.tsx | 11 +- src/app/pricing/page.tsx | 6 + .../account/iptv-subscription-section.tsx | 15 +- src/components/live-tv/index.ts | 3 + src/components/live-tv/iptv-offer-card.tsx | 150 ++++++++++++++++++ 9 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 src/app/iptv/iptv-client.tsx create mode 100644 src/app/iptv/page.test.tsx create mode 100644 src/app/iptv/page.tsx create mode 100644 src/components/live-tv/iptv-offer-card.tsx diff --git a/src/app/account/account-content.tsx b/src/app/account/account-content.tsx index 7d90b4e7..7be3ba1a 100644 --- a/src/app/account/account-content.tsx +++ b/src/app/account/account-content.tsx @@ -19,6 +19,12 @@ import { ProfileManagementSection } from '@/components/profiles/ProfileManagemen type AccountTab = 'account' | 'subscription' | 'iptv' | 'profiles' | 'security'; +const ACCOUNT_TABS: readonly AccountTab[] = ['account', 'subscription', 'iptv', 'profiles', 'security']; + +function isAccountTab(value: string): value is AccountTab { + return (ACCOUNT_TABS as readonly string[]).includes(value); +} + /** * Payment history item from API */ @@ -80,6 +86,16 @@ function AccountPageContent(): React.ReactElement { } }, [searchParams, router]); + // A deep link straight to a tab (/account?tab=iptv from the Live TV pass + // page). Unknown values are ignored rather than opening a blank tab. + useEffect(() => { + const tab = searchParams.get('tab'); + if (tab && isAccountTab(tab)) setActiveTab(tab); + }, [searchParams]); + + // The term chosen on /iptv, preselected on the IPTV tab. + const requestedPackage = searchParams.get('package'); + // Fetch subscription status const fetchSubscriptionStatus = useCallback(async () => { setIsLoadingSubscription(true); @@ -748,7 +764,7 @@ function AccountPageContent(): React.ReactElement { )} {activeTab === 'iptv' && ( - + )} {activeTab === 'profiles' && ( diff --git a/src/app/iptv/iptv-client.tsx b/src/app/iptv/iptv-client.tsx new file mode 100644 index 00000000..ebd06b48 --- /dev/null +++ b/src/app/iptv/iptv-client.tsx @@ -0,0 +1,143 @@ +'use client'; + +/** + * Live TV passes: the sales page and the "you already have one" page. + * + * Nothing is charged here. A term is chosen and the reader is sent to the + * account's IPTV tab with it preselected, where the crypto picker and the + * CoinPay handoff already exist. Signed out, the same button goes through + * login and back to the tab, so the choice is not lost on the way. + */ +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { MainLayout } from '@/components/layout'; +import { useAuth } from '@/hooks/use-auth'; +import { IptvOfferCard, OFFERED_PACKAGES } from '@/components/live-tv/iptv-offer-card'; +import type { ArgonTVPackageKey } from '@/lib/argontv/types'; + +interface SubscriptionSummary { + isActive: boolean; + daysRemaining: number; + subscription: { expires_at: string; package_key: string } | null; +} + +function isOffered(key: string | null): key is ArgonTVPackageKey { + return key !== null && (OFFERED_PACKAGES as readonly string[]).includes(key); +} + +const FEATURES: ReadonlyArray<[title: string, body: string]> = [ + [ + 'Plays where you already are', + 'Your channels show up in Live TV here, with favourites, search and the guide. Or take the M3U link into VLC, Kodi, TiviMate, anything.', + ], + [ + 'Sports, news, films, everywhere', + 'Live sports from every major league, news from every continent, films and series channels, in the languages you actually watch.', + ], + [ + 'Crypto, no card, no contract', + 'Pay once for the term with USDC, BTC, ETH or the rest. It ends when it ends; buy again to extend, and the same line comes back.', + ], + [ + 'Rent it out by the game', + 'Already pay for a line elsewhere? List it on Live TV and sell time on it by the game, and let the card above pay for itself.', + ], +]; + +export function IptvPassPage(): React.ReactElement { + const { isLoggedIn, isLoading: authLoading } = useAuth(); + const params = useSearchParams(); + const wanted = params.get('package'); + const highlight: ArgonTVPackageKey = isOffered(wanted) ? wanted : '12_months'; + + const [summary, setSummary] = useState(null); + + useEffect(() => { + if (!isLoggedIn) return; + let cancelled = false; + fetch('/api/iptv/subscription') + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((data) => { + if (!cancelled && data) setSummary(data); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [isLoggedIn]); + + const active = Boolean(summary?.isActive); + const manageHref = `/account?tab=iptv&package=${highlight}`; + const buyHref = isLoggedIn ? manageHref : `/login?redirect=${encodeURIComponent(manageHref)}`; + + return ( + +
+
+

Live TV passes

+

+ A line of our own, sold by the month. It drops into Live TV here the moment the + payment settles, and the M3U link works in any player you already use. +

+
+ + {active && summary ? ( +
+

+ Your pass is active with {summary.daysRemaining} day + {summary.daysRemaining === 1 ? '' : 's'} left. +

+

+ Extend it below and the time stacks on the end.{' '} + + Manage it in your account + {' '} + or{' '} + + open Live TV + + . +

+
+ ) : null} + + + + {!authLoading && !isLoggedIn ? ( +

+ You will be asked to sign in first; the term you picked is kept.{' '} + + Sign in and continue + + . +

+ ) : null} + +
+ {FEATURES.map(([title, body]) => ( +
+

{title}

+

{body}

+
+ ))} +
+ +

+ Have your own line?{' '} + + Rent it out by the game + + . Want the whole site?{' '} + + See the plans + + . +

+
+
+ ); +} diff --git a/src/app/iptv/page.test.tsx b/src/app/iptv/page.test.tsx new file mode 100644 index 00000000..5e75ee54 --- /dev/null +++ b/src/app/iptv/page.test.tsx @@ -0,0 +1,122 @@ +/** + * /iptv page tests: the terms on offer, where each button sends a reader, and + * that a held pass is reported rather than sold again. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { useAuth } from '@/hooks/use-auth'; +import { IptvPassPage } from './iptv-client'; +import { offeredPackages, packageHref, perMonth } from '@/components/live-tv/iptv-offer-card'; + +let search = ''; + +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(search), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock('next/link', () => ({ + default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})); + +vi.mock('@/components/layout', () => ({ + MainLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/hooks/use-auth', () => ({ + useAuth: vi.fn(), +})); + +const mockUseAuth = vi.mocked(useAuth); + +function auth(isLoggedIn: boolean): void { + mockUseAuth.mockReturnValue({ + isLoggedIn, + isLoading: false, + } as unknown as ReturnType); +} + +describe('IptvPassPage', () => { + beforeEach(() => { + search = ''; + vi.restoreAllMocks(); + global.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) as unknown as typeof fetch; + }); + + it('offers the four real terms with the checkout price and a per-month line', () => { + auth(false); + render(); + const packages = offeredPackages(); + expect(packages.map((p) => p.packageKey)).toEqual([ + '1_month', + '3_months', + '6_months', + '12_months', + ]); + for (const pkg of packages) { + expect(screen.getByText(pkg.displayName)).toBeInTheDocument(); + expect(screen.getAllByText(`$${pkg.priceUsd.toFixed(2)}`).length).toBeGreaterThan(0); + expect(screen.getAllByText(`$${perMonth(pkg)} / month`).length).toBeGreaterThan(0); + } + // Test packages are never on a sales surface. + expect(screen.queryByText('24 Hour Test')).not.toBeInTheDocument(); + expect(screen.queryByText('3 Hour Test')).not.toBeInTheDocument(); + }); + + it('sends every Get button to the account IPTV tab with that package preselected', () => { + auth(true); + render(); + for (const pkg of offeredPackages()) { + const link = screen.getByRole('link', { name: `Get ${pkg.displayName}` }); + expect(link).toHaveAttribute('href', packageHref(pkg.packageKey, true)); + expect(link.getAttribute('href')).toBe(`/account?tab=iptv&package=${pkg.packageKey}`); + } + }); + + it('routes a signed-out reader through login and back to the chosen term', () => { + auth(false); + search = 'package=3_months'; + render(); + const link = screen.getByRole('link', { name: 'Sign in and continue' }); + expect(link).toHaveAttribute( + 'href', + `/login?redirect=${encodeURIComponent('/account?tab=iptv&package=3_months')}` + ); + expect(screen.getByText('Best value').closest('li')).toHaveTextContent('3 Months'); + }); + + it('ignores an unknown package in the URL and highlights the year', () => { + auth(false); + search = 'package=3_hour_test'; + render(); + expect(screen.getByText('Best value').closest('li')).toHaveTextContent('12 Months'); + }); + + it('reports a held pass instead of pretending the reader has none', async () => { + auth(true); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + isActive: true, + daysRemaining: 12, + subscription: { expires_at: '2026-10-01T00:00:00Z', package_key: '1_month' }, + }), + } as unknown as Response); + render(); + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('active with 12 days left'); + }); + expect(global.fetch).toHaveBeenCalledWith('/api/iptv/subscription'); + }); + + it('never asks the API about a pass when nobody is signed in', () => { + auth(false); + render(); + expect(global.fetch).not.toHaveBeenCalled(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/iptv/page.tsx b/src/app/iptv/page.tsx new file mode 100644 index 00000000..f04a5b6a --- /dev/null +++ b/src/app/iptv/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from 'next'; +import { IptvPassPage } from './iptv-client'; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + title: 'Live TV Passes | BitTorrented', + description: + 'Thousands of live channels and sports on our line. One, three, six or twelve months, paid in crypto, playing in Live TV or any M3U player.', + alternates: { canonical: '/iptv' }, +}; + +/** + * /iptv: the page that sells a Live TV pass, and reports on the one held. + * + * Readable signed out, the way /pricing is, so a search hit or a link from a + * torrent page can land here. Every number comes from the same table the + * checkout charges. Buying happens on the account's IPTV tab, where the + * crypto picker and the CoinPay handoff already live; this page only chooses + * the term and sends the reader there with it preselected. + */ +export default function IptvPage(): React.ReactElement { + return ; +} diff --git a/src/app/live-tv/live-tv-content.tsx b/src/app/live-tv/live-tv-content.tsx index 7cc8ccfb..04d70b01 100644 --- a/src/app/live-tv/live-tv-content.tsx +++ b/src/app/live-tv/live-tv-content.tsx @@ -15,6 +15,7 @@ import { useState, useCallback, useEffect, useRef, memo } from 'react'; import { MainLayout } from '@/components/layout'; +import { IptvOfferCard } from '@/components/live-tv/iptv-offer-card'; import { cn } from '@/lib/utils'; import { TvIcon, PlusIcon, SearchIcon, PlayIcon, LoadingSpinner, EditIcon, TrashIcon, HeartFilledIcon } from '@/components/ui/icons'; import { AddPlaylistModal, EditPlaylistModal, HlsPlayerModal, type PlaylistData } from '@/components/live-tv'; @@ -544,10 +545,10 @@ export function LiveTvContent(): React.ReactElement {

Live TV

Stream live channels from your IPTV playlists.{' '} - - Purchase an IPTV subscription + + Get a Live TV pass {' '} - from your account settings, or{' '} + on our line, or{' '} rent out your line {' '} @@ -583,6 +584,10 @@ export function LiveTvContent(): React.ReactElement { + {isLoggedIn && !isAuthLoading && playlists.length === 0 ? ( + + ) : null} + {/* Playlist Selector - Dropdown with Edit/Delete */} {playlists.length > 0 && (

diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 2ab150af..b4397e2a 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -13,6 +13,7 @@ import { MainLayout } from '@/components/layout'; import { cn } from '@/lib/utils'; import { useSupportedCoins } from '@/hooks/use-supported-coins'; import { useModalOpen } from '@/hooks/use-modal-open'; +import { IptvOfferCard } from '@/components/live-tv/iptv-offer-card'; interface PlanFeature { text: string; @@ -262,6 +263,11 @@ export default function PricingPage(): React.ReactElement { ))}
+ {/* Live TV passes: a separate line, sold by the term, on top of any plan */} +
+ +
+ {/* Payment Methods */}

diff --git a/src/components/account/iptv-subscription-section.tsx b/src/components/account/iptv-subscription-section.tsx index 0fc4aac8..484b4244 100644 --- a/src/components/account/iptv-subscription-section.tsx +++ b/src/components/account/iptv-subscription-section.tsx @@ -58,13 +58,24 @@ interface PaymentResponse { error?: string; } -export function IPTVSubscriptionSection(): React.ReactElement { +export interface IPTVSubscriptionSectionProps { + /** Package key to start with, from /iptv?package= via the account URL. */ + initialPackage?: string | null; +} + +const KNOWN_PACKAGES = new Set(['1_month', '3_months', '6_months', '12_months', '24_hour_test', '3_hour_test']); + +export function IPTVSubscriptionSection({ + initialPackage = null, +}: IPTVSubscriptionSectionProps = {}): React.ReactElement { const router = useRouter(); const { coins, isLoading: isLoadingCoins, error: coinsError } = useSupportedCoins(); const [subscriptionData, setSubscriptionData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [selectedPackage, setSelectedPackage] = useState('1_month'); + const [selectedPackage, setSelectedPackage] = useState( + initialPackage && KNOWN_PACKAGES.has(initialPackage) ? initialPackage : '1_month' + ); const [selectedCrypto, setSelectedCrypto] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [showCredentials, setShowCredentials] = useState(false); diff --git a/src/components/live-tv/index.ts b/src/components/live-tv/index.ts index 41cc8401..1207b1ba 100644 --- a/src/components/live-tv/index.ts +++ b/src/components/live-tv/index.ts @@ -10,4 +10,7 @@ export type { PlaylistData } from './add-playlist-modal'; export { EditPlaylistModal } from './edit-playlist-modal'; export { HlsPlayerModal } from './hls-player-modal'; + +export { IptvOfferCard, offeredPackages, packageHref, perMonth } from './iptv-offer-card'; +export type { IptvOfferCardProps } from './iptv-offer-card'; export type { HlsPlayerModalProps } from './hls-player-modal'; diff --git a/src/components/live-tv/iptv-offer-card.tsx b/src/components/live-tv/iptv-offer-card.tsx new file mode 100644 index 00000000..274142de --- /dev/null +++ b/src/components/live-tv/iptv-offer-card.tsx @@ -0,0 +1,150 @@ +/** + * IPTV Offer Card + * + * The one place Live TV passes are pitched. Shown to a reader who has no + * playlist on the Live TV page, as a section on /pricing, and as the hero on + * /iptv itself. Every price on it comes from the same table the checkout + * charges (getAllPackagePrices), so the card can never advertise one number + * and the account page take another. + * + * It sells nothing itself: the button lands on /iptv, or straight on the + * account's IPTV tab with the package preselected when `direct` is set. + */ +import Link from 'next/link'; +import { cn } from '@/lib/utils'; +import { + getAllPackagePrices, + type ArgonTVPackageKey, + type IPTVPackagePrice, +} from '@/lib/argontv/types'; + +/** Packages worth showing on a sales surface: the real terms, not the tests. */ +export const OFFERED_PACKAGES: readonly ArgonTVPackageKey[] = [ + '1_month', + '3_months', + '6_months', + '12_months', +]; + +export function offeredPackages(): IPTVPackagePrice[] { + return getAllPackagePrices().filter((p) => + (OFFERED_PACKAGES as readonly string[]).includes(p.packageKey) + ); +} + +/** Where a "Get" button goes: the pass page, or the account tab with the package chosen. */ +export function packageHref(packageKey: ArgonTVPackageKey, direct: boolean): string { + return direct ? `/account?tab=iptv&package=${packageKey}` : `/iptv?package=${packageKey}`; +} + +/** Monthly-equivalent price, for the "per month" line under each term. */ +export function perMonth(pkg: IPTVPackagePrice): string { + const months = Math.max(1, Math.round(pkg.durationDays / 30)); + return (pkg.priceUsd / months).toFixed(2); +} + +export interface IptvOfferCardProps { + /** Compact: one line of copy and the four terms as pills. Full: the hero. */ + variant?: 'compact' | 'full'; + /** Send buttons straight to the account tab instead of /iptv. */ + direct?: boolean; + /** Which term to highlight. */ + highlight?: ArgonTVPackageKey; + className?: string; +} + +export function IptvOfferCard({ + variant = 'compact', + direct = false, + highlight = '12_months', + className, +}: IptvOfferCardProps): React.ReactElement { + const packages = offeredPackages(); + const cheapest = packages.reduce( + (best, p) => (Number(perMonth(p)) < Number(perMonth(best)) ? p : best), + packages[0]! + ); + + return ( +
+
+
+

+ Live TV, on our line +

+

+ Thousands of live channels and sports, from ${perMonth(cheapest)} a month. + Plays here in Live TV and in any player that takes an M3U. Paid in crypto, + no card, no contract. +

+
+ {variant === 'compact' ? ( + + Get a pass + + ) : null} +
+ +
    + {packages.map((pkg) => { + const isHighlight = pkg.packageKey === highlight; + return ( +
  • +
    + {pkg.displayName} + {isHighlight ? ( + + Best value + + ) : null} +
    +
    ${pkg.priceUsd.toFixed(2)}
    +
    ${perMonth(pkg)} / month
    + {variant === 'full' ? ( + + Get {pkg.displayName} + + ) : null} +
  • + ); + })} +
+
+ ); +}