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} +
  • + ); + })} +
+
+ ); +}