Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/app/account/account-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -748,7 +764,7 @@ function AccountPageContent(): React.ReactElement {
)}

{activeTab === 'iptv' && (
<IPTVSubscriptionSection />
<IPTVSubscriptionSection initialPackage={requestedPackage} />
)}

{activeTab === 'profiles' && (
Expand Down
143 changes: 143 additions & 0 deletions src/app/iptv/iptv-client.tsx
Original file line number Diff line number Diff line change
@@ -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<SubscriptionSummary | null>(null);

useEffect(() => {
if (!isLoggedIn) return;
let cancelled = false;
fetch('/api/iptv/subscription')
.then((r) => (r.ok ? (r.json() as Promise<SubscriptionSummary>) : 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 (
<MainLayout>
<div className="mx-auto max-w-5xl space-y-8">
<div>
<h1 className="text-3xl font-bold text-text-primary">Live TV passes</h1>
<p className="mt-2 max-w-2xl text-text-secondary">
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.
</p>
</div>

{active && summary ? (
<div
role="status"
className="rounded-lg border border-status-success/40 bg-status-success/10 p-4"
>
<p className="font-medium text-text-primary">
Your pass is active with {summary.daysRemaining} day
{summary.daysRemaining === 1 ? '' : 's'} left.
</p>
<p className="mt-1 text-sm text-text-secondary">
Extend it below and the time stacks on the end.{' '}
<Link href="/account?tab=iptv" className="text-accent-primary hover:underline">
Manage it in your account
</Link>{' '}
or{' '}
<Link href="/live-tv" className="text-accent-primary hover:underline">
open Live TV
</Link>
.
</p>
</div>
) : null}

<IptvOfferCard variant="full" direct highlight={highlight} />

{!authLoading && !isLoggedIn ? (
<p className="text-sm text-text-muted">
You will be asked to sign in first; the term you picked is kept.{' '}
<Link href={buyHref} className="text-accent-primary hover:underline">
Sign in and continue
</Link>
.
</p>
) : null}

<div className="grid gap-4 sm:grid-cols-2">
{FEATURES.map(([title, body]) => (
<div key={title} className="rounded-lg border border-border-subtle bg-bg-secondary p-4">
<h3 className="font-medium text-text-primary">{title}</h3>
<p className="mt-1 text-sm text-text-secondary">{body}</p>
</div>
))}
</div>

<p className="text-sm text-text-muted">
Have your own line?{' '}
<Link href="/live-tv/rent-out" className="text-accent-primary hover:underline">
Rent it out by the game
</Link>
. Want the whole site?{' '}
<Link href="/pricing" className="text-accent-primary hover:underline">
See the plans
</Link>
.
</p>
</div>
</MainLayout>
);
}
122 changes: 122 additions & 0 deletions src/app/iptv/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));

vi.mock('@/components/layout', () => ({
MainLayout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

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<typeof useAuth>);
}

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(<IptvPassPage />);
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(<IptvPassPage />);
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(<IptvPassPage />);
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(<IptvPassPage />);
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(<IptvPassPage />);
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(<IptvPassPage />);
expect(global.fetch).not.toHaveBeenCalled();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
24 changes: 24 additions & 0 deletions src/app/iptv/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <IptvPassPage />;
}
11 changes: 8 additions & 3 deletions src/app/live-tv/live-tv-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -544,10 +545,10 @@ export function LiveTvContent(): React.ReactElement {
<h1 className="text-2xl font-bold text-text-primary">Live TV</h1>
<p className="text-sm text-text-secondary">
Stream live channels from your IPTV playlists.{' '}
<Link href="/account" className="text-accent-primary hover:underline">
Purchase an IPTV subscription
<Link href="/iptv" className="text-accent-primary hover:underline">
Get a Live TV pass
</Link>{' '}
from your account settings, or{' '}
on our line, or{' '}
<Link href="/live-tv/rent-out" className="text-accent-primary hover:underline">
rent out your line
</Link>{' '}
Expand Down Expand Up @@ -583,6 +584,10 @@ export function LiveTvContent(): React.ReactElement {
</div>
</div>

{isLoggedIn && !isAuthLoading && playlists.length === 0 ? (
<IptvOfferCard direct />
) : null}

{/* Playlist Selector - Dropdown with Edit/Delete */}
{playlists.length > 0 && (
<div className="flex items-center gap-3">
Expand Down
6 changes: 6 additions & 0 deletions src/app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -262,6 +263,11 @@ export default function PricingPage(): React.ReactElement {
))}
</div>

{/* Live TV passes: a separate line, sold by the term, on top of any plan */}
<div className="max-w-4xl mx-auto">
<IptvOfferCard />
</div>

{/* Payment Methods */}
<div className="text-center">
<h3 className="text-lg font-semibold text-text-primary mb-4">
Expand Down
Loading
Loading