PUBLIC FRONTEND SPECIFICATION
Status: Active Started: 2025-10-30 Target Completion: 2025-12-22 Priority: High Category: Frontend Development Component: Public-Facing Website + Consumer/Reseller Portals Version: 1.0.0 Owner: Frontend Team
Table of Contents
- Executive Summary
- Architecture Overview
- Authentication System
- Marketing Website
- Consumer Portal
- Reseller Portal
- Technical Stack
- UI/UX Design
- API Integration
- Deployment
- Testing Strategy
- Security
- Performance
- Accessibility
- Implementation Timeline
Executive Summary
The EPGOAT Public Frontend consists of three major applications:
- Marketing Website (
epgoat.tv) - Public-facing site for lead generation - Consumer Portal (
app.epgoat.tv/consumer) - Self-service dashboard for end-users - Reseller Portal (
app.epgoat.tv/reseller) - Bulk key management for B2B customers
All three share a unified design system, authentication layer (Auth0), and are deployed to Cloudflare Pages.
Key Features
- Authentication: Auth0 integration with Google OAuth + email/password
- Pricing Tiers: Consumer ($5-10/year), Reseller ($99/year per provider pack)
- Trial System: 1-month free trial (optional: 2 months with CC)
- Key Management: Unique EPG access keys at
epgo.at/<key>/<provider>.xml - Payment Processing: Stripe Elements (embedded, PCI-compliant)
- Responsive Design: Mobile-first, works on all devices
- Performance: <2s page load, 90+ Lighthouse score
Success Metrics
| Metric | Target | Measurement |
|---|---|---|
| Trial Signup Rate | 20%+ of visitors | Google Analytics |
| Trial Conversion Rate | 30%+ to paid | Stripe Analytics |
| Reseller Adoption | 10+ resellers in Month 1 | Database query |
| Page Load Time | <2s (90th percentile) | Cloudflare Analytics |
| Bounce Rate | <40% on landing page | Google Analytics |
| Mobile Traffic | 50%+ of total | Google Analytics |
Architecture Overview
System Context
┌─────────────────────────────────────────────────────────────┐
│ User's Browser │
│ │
│ ┌────────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Marketing │ │ Consumer │ │ Reseller │ │
│ │ Website │ │ Portal │ │ Portal │ │
│ │ (epgoat.tv) │ │ (app.*/con) │ │ (app.*/res) │ │
│ └────────┬───────┘ └──────┬───────┘ └────────┬────────┘ │
│ │ │ │ │
└───────────┼─────────────────┼────────────────────┼──────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌────────────────────────────────┐
│ Cloudflare │ │ Auth0 │
│ Pages │ │ (auth.epgoat.tv) │
│ (Static CDN) │ │ - Google OAuth │
└───────┬───────┘ │ - Email/Password │
│ │ - JWT Tokens │
│ │ - RBAC (roles) │
│ └────────────┬───────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ Cloudflare Workers (API) │
│ - /api/auth/* (JWT validation) │
│ - /api/subscriptions/* (Stripe sync) │
│ - /api/keys/* (EPG key management) │
│ - /api/users/* (profile management) │
└──────────┬─────────────────┬─────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Cloudflare │ │ Stripe │
│ D1 │ │ Payments │
│ (Database) │ │ Webhooks │
└──────────────┘ └──────────────┘
Application Structure
public-frontend/
├── marketing/ # Marketing website (epgoat.tv)
│ ├── index.html # Landing page
│ ├── features.html # Features overview
│ ├── pricing.html # Pricing page (Consumer vs Reseller)
│ ├── faq.html # FAQ
│ ├── contact.html # Contact form
│ ├── legal/
│ │ ├── terms.html # Terms of Service
│ │ ├── privacy.html # Privacy Policy
│ │ └── refund.html # Refund Policy
│ └── assets/
│ ├── css/ # Tailwind CSS
│ ├── js/ # Vanilla JS (no framework)
│ └── images/ # Logos, screenshots, icons
│
├── consumer-portal/ # React SPA (app.epgoat.tv/consumer)
│ ├── src/
│ │ ├── components/ # React components
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Subscription.tsx
│ │ │ ├── Profile.tsx
│ │ │ └── EPGAccess.tsx
│ │ ├── hooks/ # Custom React hooks
│ │ │ ├── useAuth.ts # Auth0 integration
│ │ │ ├── useSubscription.ts
│ │ │ └── useKey.ts
│ │ ├── backend/epgoat/services/ # API client
│ │ │ ├── api.ts # Axios instance
│ │ │ ├── auth.ts # Auth0 SDK
│ │ │ └── stripe.ts # Stripe Elements
│ │ ├── utils/
│ │ ├── types/ # TypeScript types
│ │ └── App.tsx
│ ├── public/
│ └── package.json
│
└── reseller-portal/ # React SPA (app.epgoat.tv/reseller)
├── src/
│ ├── components/
│ │ ├── Dashboard.tsx
│ │ ├── KeyManager.tsx # Bulk key generation
│ │ ├── UsageAnalytics.tsx
│ │ └── Subscription.tsx
│ ├── hooks/
│ ├── backend/epgoat/services/
│ └── App.tsx
├── public/
└── package.json
Authentication System
Auth0 Configuration
Tenant: epgoat.us.auth0.com (free tier, up to 7,000 users)
Applications:
1. Marketing Website (Regular Web Application)
- Callback URL: https://epgoat.tv/callback
- Logout URL: https://epgoat.tv/
- Used for: Registration, login redirects
- Consumer Portal (Single Page Application)
- Callback URL:
https://app.epgoat.tv/consumer/callback - Logout URL:
https://app.epgoat.tv/consumer/ -
Allowed origins:
https://app.epgoat.tv -
Reseller Portal (Single Page Application)
- Callback URL:
https://app.epgoat.tv/reseller/callback - Logout URL:
https://app.epgoat.tv/reseller/ - Allowed origins:
https://app.epgoat.tv
Connection Types: - Google OAuth (social login) - Username-Password-Authentication (email/password)
User Metadata:
{
"user_id": "auth0|123456",
"email": "user@example.com",
"email_verified": true,
"created_at": "2025-10-30T12:00:00.000Z",
"app_metadata": {
"role": "consumer" | "reseller" | "admin",
"epgoat_user_id": "uuid-from-d1-database",
"subscription_status": "trial" | "active" | "past_due" | "canceled"
},
"user_metadata": {
"provider_preferences": ["tps", "sportztv"],
"timezone": "America/New_York"
}
}
Auth0 Actions (Custom Logic)
Action 1: Post-Registration
// Trigger: After user registers with Auth0
exports.onExecutePostUserRegistration = async (event, api) => {
// 1. Create user in Supabase PostgreSQL
const response = await fetch('https://api.epgoat.tv/users/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auth0_id: event.user.user_id,
email: event.user.email,
role: 'consumer', // Default role
trial_start: new Date().toISOString()
})
});
const { user_id, access_key } = await response.json();
// 2. Store D1 user ID in Auth0 app_metadata
api.user.setAppMetadata('epgoat_user_id', user_id);
api.user.setAppMetadata('role', 'consumer');
api.user.setAppMetadata('subscription_status', 'trial');
// 3. Send welcome email with EPG URL
await fetch('https://api.epgoat.tv/emails/welcome', {
method: 'POST',
body: JSON.stringify({
email: event.user.email,
epg_url: `https://epgo.at/${access_key}/tps.xml`
})
});
};
Action 2: Add Custom Claims to JWT
// Trigger: On login (before JWT is issued)
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://epgoat.tv';
// Add custom claims to JWT token
api.idToken.setCustomClaim(`${namespace}/role`, event.user.app_metadata.role);
api.idToken.setCustomClaim(`${namespace}/epgoat_user_id`, event.user.app_metadata.epgoat_user_id);
api.idToken.setCustomClaim(`${namespace}/subscription_status`, event.user.app_metadata.subscription_status);
// Add to access token too (for API calls)
api.accessToken.setCustomClaim(`${namespace}/role`, event.user.app_metadata.role);
};
Action 3: Enforce Role-Based Access
// Trigger: On login
exports.onExecutePostLogin = async (event, api) => {
const requestedApp = event.client.name; // "Consumer Portal" or "Reseller Portal"
const userRole = event.user.app_metadata.role;
// Prevent consumers from accessing reseller portal
if (requestedApp === 'Reseller Portal' && userRole !== 'reseller' && userRole !== 'admin') {
api.access.deny('You do not have permission to access the Reseller Portal');
}
};
Frontend Auth Integration
Consumer Portal (src/hooks/useAuth.ts):
import { Auth0Provider, useAuth0 } from '@auth0/auth0-react';
// App.tsx
const auth0Config = {
domain: 'epgoat.us.auth0.com',
clientId: process.env.REACT_APP_AUTH0_CLIENT_ID,
redirectUri: window.location.origin + '/consumer/callback',
audience: 'https://api.epgoat.tv',
scope: 'openid profile email'
};
function App() {
return (
<Auth0Provider {...auth0Config}>
<ConsumerApp />
</Auth0Provider>
);
}
// useAuth.ts
export function useAuth() {
const {
isAuthenticated,
isLoading,
user,
loginWithRedirect,
logout,
getAccessTokenSilently
} = useAuth0();
const getUserRole = () => {
return user?.['https://epgoat.tv/role'] || 'consumer';
};
const getEPGUserId = () => {
return user?.['https://epgoat.tv/epgoat_user_id'];
};
const getSubscriptionStatus = () => {
return user?.['https://epgoat.tv/subscription_status'];
};
return {
isAuthenticated,
isLoading,
user,
userRole: getUserRole(),
epgoatUserId: getEPGUserId(),
subscriptionStatus: getSubscriptionStatus(),
login: () => loginWithRedirect(),
logout: () => logout({ returnTo: window.location.origin }),
getAccessToken: getAccessTokenSilently
};
}
Protected Route Component:
import { useAuth } from './hooks/useAuth';
import { Navigate } from 'react-router-dom';
export function ProtectedRoute({ children, requiredRole }) {
const { isAuthenticated, isLoading, userRole } = useAuth();
if (isLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
if (requiredRole && userRole !== requiredRole && userRole !== 'admin') {
return <Navigate to="/unauthorized" />;
}
return children;
}
// Usage
<Route path="/consumer/dashboard" element={
<ProtectedRoute requiredRole="consumer">
<Dashboard />
</ProtectedRoute>
} />
Marketing Website
Pages & Content
1. Landing Page (index.html)
Hero Section:
<section class="hero bg-gradient-to-r from-blue-600 to-purple-600 text-white py-20">
<div class="container mx-auto text-center">
<h1 class="text-5xl font-bold mb-4">
Perfect EPG Data for Your IPTV Service
</h1>
<p class="text-xl mb-8">
96%+ accurate event matching. Auto-generated daily.
Ready-to-use XMLTV files for 10+ sports providers.
</p>
<div class="flex justify-center gap-4">
<button class="bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100">
Start Free Trial
</button>
<button class="bg-transparent border-2 border-white px-8 py-3 rounded-lg font-semibold hover:bg-white hover:text-blue-600">
View Pricing
</button>
</div>
<p class="text-sm mt-4">No credit card required • Cancel anytime</p>
</div>
</section>
Features Section:
<section class="features py-20">
<div class="container mx-auto">
<h2 class="text-4xl font-bold text-center mb-12">Why EPGOAT?</h2>
<div class="grid md:grid-cols-3 gap-8">
<!-- Feature 1 -->
<div class="feature-card bg-white p-8 rounded-lg shadow-lg">
<div class="icon text-5xl mb-4">🎯</div>
<h3 class="text-2xl font-bold mb-2">96%+ Match Rate</h3>
<p class="text-gray-600">
AI-powered matching engine ensures near-perfect accuracy.
Self-learning system improves over time.
</p>
</div>
<!-- Feature 2 -->
<div class="feature-card bg-white p-8 rounded-lg shadow-lg">
<div class="icon text-5xl mb-4">⚡</div>
<h3 class="text-2xl font-bold mb-2">Auto-Updated 4x Daily</h3>
<p class="text-gray-600">
EPG files refreshed automatically at 12am, 6am, 12pm, 6pm ET.
Always current, never stale.
</p>
</div>
<!-- Feature 3 -->
<div class="feature-card bg-white p-8 rounded-lg shadow-lg">
<div class="icon text-5xl mb-4">🔒</div>
<h3 class="text-2xl font-bold mb-2">Secure Access Keys</h3>
<p class="text-gray-600">
Unique URL per customer. Track usage, prevent abuse,
revoke access anytime.
</p>
</div>
</div>
</div>
</section>
Social Proof Section:
<section class="social-proof bg-gray-100 py-20">
<div class="container mx-auto text-center">
<h2 class="text-3xl font-bold mb-8">Trusted by IPTV Providers Worldwide</h2>
<div class="stats grid md:grid-cols-4 gap-8 mb-12">
<div class="stat">
<div class="text-5xl font-bold text-blue-600">10+</div>
<div class="text-gray-600">Sports Providers</div>
</div>
<div class="stat">
<div class="text-5xl font-bold text-blue-600">96%</div>
<div class="text-gray-600">Match Accuracy</div>
</div>
<div class="stat">
<div class="text-5xl font-bold text-blue-600">10K+</div>
<div class="text-gray-600">Channels Supported</div>
</div>
<div class="stat">
<div class="text-5xl font-bold text-blue-600">4x</div>
<div class="text-gray-600">Daily Updates</div>
</div>
</div>
<div class="testimonials">
<blockquote class="text-xl italic text-gray-700">
"EPGOAT saved us 20 hours/week in manual EPG maintenance.
The accuracy is incredible!"
<footer class="text-gray-600 mt-2">— John D., IPTV Provider</footer>
</blockquote>
</div>
</div>
</section>
CTA Section:
<section class="cta bg-blue-600 text-white py-20">
<div class="container mx-auto text-center">
<h2 class="text-4xl font-bold mb-4">
Start Your Free Trial Today
</h2>
<p class="text-xl mb-8">
No credit card required. Full access to all features for 30 days.
</p>
<button class="bg-white text-blue-600 px-12 py-4 rounded-lg text-xl font-semibold hover:bg-gray-100">
Get Started Free
</button>
</div>
</section>
2. Features Page (features.html)
Content Outline: - Automated Matching: Explain 6-stage regex + LLM fallback - Provider Packs: Show list of 10 supported providers (TPS, SportsTV, etc.) - Self-Learning: Explain how system improves over time - Admin Tools: Link to admin frontend (for manual overrides) - API Access: For resellers who want to integrate - Abuse Prevention: Rate limiting, IP tracking, key revocation - Uptime Guarantee: 99.9% uptime SLA
3. Pricing Page (pricing.html)
Layout:
<section class="pricing py-20">
<div class="container mx-auto">
<h1 class="text-5xl font-bold text-center mb-4">Simple, Transparent Pricing</h1>
<p class="text-xl text-center text-gray-600 mb-12">
No hidden fees. Cancel anytime.
</p>
<div class="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<!-- Consumer Plan -->
<div class="pricing-card bg-white p-8 rounded-lg shadow-xl">
<div class="badge bg-blue-100 text-blue-600 px-4 py-1 rounded-full inline-block mb-4">
Most Popular
</div>
<h2 class="text-3xl font-bold mb-2">Consumer</h2>
<div class="price mb-4">
<span class="text-5xl font-bold">$10</span>
<span class="text-gray-600">/year</span>
</div>
<p class="text-gray-600 mb-6">
Perfect for individual IPTV users
</p>
<ul class="features mb-8 space-y-3">
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>1 EPG access key</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>Access to all 10 provider packs</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>4x daily updates</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>96%+ match accuracy</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>Email support</span>
</li>
</ul>
<button class="bg-blue-600 text-white w-full py-3 rounded-lg font-semibold hover:bg-blue-700">
Start Free Trial
</button>
<p class="text-sm text-gray-500 text-center mt-4">
Or try for $1.75/month
</p>
</div>
<!-- Reseller Plan -->
<div class="pricing-card bg-white p-8 rounded-lg shadow-xl border-4 border-purple-600 relative">
<div class="badge bg-purple-100 text-purple-600 px-4 py-1 rounded-full inline-block mb-4">
For Resellers
</div>
<h2 class="text-3xl font-bold mb-2">Reseller</h2>
<div class="price mb-4">
<span class="text-5xl font-bold">$99</span>
<span class="text-gray-600">/year</span>
</div>
<p class="text-gray-600 mb-6">
Per provider pack • Includes 50 keys
</p>
<ul class="features mb-8 space-y-3">
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>50 EPG access keys</strong> per provider</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>Buy additional 50-key blocks</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>Bulk key generation</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>Usage analytics per key</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>API access</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span>Priority support</span>
</li>
</ul>
<button class="bg-purple-600 text-white w-full py-3 rounded-lg font-semibold hover:bg-purple-700">
Contact Sales
</button>
</div>
</div>
<!-- FAQ Section -->
<div class="faq mt-16 max-w-3xl mx-auto">
<h3 class="text-2xl font-bold mb-8 text-center">Frequently Asked Questions</h3>
<div class="faq-item mb-6">
<h4 class="font-bold text-lg mb-2">Can I try before I buy?</h4>
<p class="text-gray-600">
Yes! Get a 30-day free trial with no credit card required.
Add a credit card during trial to get an extra month free (60 days total).
</p>
</div>
<div class="faq-item mb-6">
<h4 class="font-bold text-lg mb-2">What providers do you support?</h4>
<p class="text-gray-600">
We support 10+ sports providers including TPS, SportsTV, FloCast, and more.
See the full list on our Features page.
</p>
</div>
<div class="faq-item mb-6">
<h4 class="font-bold text-lg mb-2">Can I cancel anytime?</h4>
<p class="text-gray-600">
Absolutely. Cancel anytime from your account dashboard.
You'll retain access until the end of your billing period.
</p>
</div>
<div class="faq-item mb-6">
<h4 class="font-bold text-lg mb-2">What's the difference between monthly and annual?</h4>
<p class="text-gray-600">
Annual is $10/year ($0.83/month). Monthly is $1.75/month ($21/year).
Save 52% with annual billing.
</p>
</div>
</div>
</div>
</section>
4. FAQ Page (faq.html)
Topics: - Account Management - Billing & Subscriptions - EPG Technical Questions - Provider Support - Troubleshooting - Reseller Questions
5. Contact Page (contact.html)
Form Fields:
<form class="contact-form max-w-lg mx-auto">
<div class="mb-4">
<label class="block text-gray-700 font-semibold mb-2">Name</label>
<input type="text" class="w-full px-4 py-2 border rounded-lg" required>
</div>
<div class="mb-4">
<label class="block text-gray-700 font-semibold mb-2">Email</label>
<input type="email" class="w-full px-4 py-2 border rounded-lg" required>
</div>
<div class="mb-4">
<label class="block text-gray-700 font-semibold mb-2">Subject</label>
<select class="w-full px-4 py-2 border rounded-lg">
<option>General Inquiry</option>
<option>Reseller Inquiry</option>
<option>Technical Support</option>
<option>Billing Question</option>
<option>Partnership Opportunity</option>
</select>
</div>
<div class="mb-4">
<label class="block text-gray-700 font-semibold mb-2">Message</label>
<textarea rows="6" class="w-full px-4 py-2 border rounded-lg" required></textarea>
</div>
<button type="submit" class="bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700">
Send Message
</button>
</form>
Submission Handler: - Uses Cloudflare Worker to send email via Resend/SendGrid - Stores submission in D1 for tracking - Auto-responder email to user - Notification to admin email
Consumer Portal
Dashboard (/consumer/dashboard)
Layout:
import { useAuth } from '../hooks/useAuth';
import { useSubscription } from '../hooks/useSubscription';
import { useKey } from '../hooks/useKey';
export function Dashboard() {
const { user, subscriptionStatus } = useAuth();
const { subscription, loading } = useSubscription();
const { key, stats, regenerateKey } = useKey();
return (
<div className="dashboard p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold">Welcome back, {user.name}!</h1>
<p className="text-gray-600">Manage your EPG access and subscription</p>
</div>
{/* Status Alert */}
{subscriptionStatus === 'trial' && (
<Alert type="info" className="mb-6">
<strong>Free Trial Active</strong> -
{subscription.trial_days_remaining} days remaining.
<a href="/consumer/subscription/upgrade" className="underline ml-2">
Upgrade now
</a> and get 2 free months with a credit card!
</Alert>
)}
{/* Main Grid */}
<div className="grid md:grid-cols-2 gap-6">
{/* EPG Access Card */}
<Card>
<CardHeader>
<h2 className="text-xl font-bold flex items-center">
<KeyIcon className="mr-2" />
Your EPG Access
</h2>
</CardHeader>
<CardBody>
<div className="mb-4">
<label className="block text-sm text-gray-600 mb-2">EPG URL</label>
<div className="flex items-center gap-2">
<input
type="text"
value={`https://epgo.at/${key}/tps.xml`}
readOnly
className="flex-1 px-4 py-2 bg-gray-100 rounded border"
/>
<button
onClick={() => copyToClipboard(`https://epgo.at/${key}/tps.xml`)}
className="btn btn-secondary"
>
Copy
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Add this URL to your IPTV app's EPG settings
</p>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<div className="text-2xl font-bold">{stats.access_count}</div>
<div className="text-sm text-gray-600">Total Accesses</div>
</div>
<div>
<div className="text-2xl font-bold">
{stats.last_accessed ? formatRelative(stats.last_accessed) : 'Never'}
</div>
<div className="text-sm text-gray-600">Last Accessed</div>
</div>
</div>
<button
onClick={() => regenerateKey()}
className="btn btn-outline w-full"
>
Regenerate Key
</button>
<p className="text-xs text-gray-500 mt-2">
⚠️ Your old URL will stop working immediately
</p>
</CardBody>
</Card>
{/* Subscription Card */}
<Card>
<CardHeader>
<h2 className="text-xl font-bold flex items-center">
<CreditCardIcon className="mr-2" />
Subscription
</h2>
</CardHeader>
<CardBody>
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-gray-600">Plan</span>
<span className="font-semibold">
{subscription.plan_name} - {subscription.billing_cycle}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-gray-600">Status</span>
<StatusBadge status={subscription.status} />
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-gray-600">
{subscription.status === 'trial' ? 'Trial ends' : 'Renews on'}
</span>
<span className="font-semibold">
{formatDate(subscription.current_period_end)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600">Amount</span>
<span className="font-semibold text-lg">
${subscription.amount}/year
</span>
</div>
</div>
<div className="space-y-2">
<a href="/consumer/subscription" className="btn btn-primary w-full">
Manage Subscription
</a>
{subscription.status === 'trial' && (
<a href="/consumer/subscription/upgrade" className="btn btn-secondary w-full">
Upgrade to Annual
</a>
)}
</div>
</CardBody>
</Card>
</div>
{/* Usage Stats */}
<Card className="mt-6">
<CardHeader>
<h2 className="text-xl font-bold">Usage Over Time</h2>
</CardHeader>
<CardBody>
<LineChart
data={stats.access_history}
xKey="date"
yKey="count"
height={200}
/>
</CardBody>
</Card>
</div>
);
}
Subscription Management (/consumer/subscription)
Features: - View current plan details - Update payment method (Stripe Elements) - Upgrade from trial to annual - Add credit card to trial (get bonus month) - Cancel subscription - Reactivate canceled subscription - Download invoices - View payment history
Upgrade Flow:
export function UpgradeFlow() {
const [loading, setLoading] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<'monthly' | 'annual'>('annual');
const handleUpgrade = async () => {
setLoading(true);
// 1. Create Stripe Checkout Session
const { sessionId } = await api.post('/subscriptions/checkout', {
plan: selectedPlan,
success_url: window.location.origin + '/consumer/subscription/success',
cancel_url: window.location.origin + '/consumer/subscription'
});
// 2. Redirect to Stripe Checkout
const stripe = await loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);
await stripe.redirectToCheckout({ sessionId });
};
return (
<div className="upgrade-flow p-8">
<h1 className="text-3xl font-bold mb-8">Upgrade Your Plan</h1>
<div className="grid md:grid-cols-2 gap-6 mb-8">
{/* Monthly Plan */}
<PlanCard
selected={selectedPlan === 'monthly'}
onSelect={() => setSelectedPlan('monthly')}
title="Monthly"
price="$1.75"
period="/month"
annualEquivalent="$21/year"
features={[
'1 EPG access key',
'All 10 providers',
'4x daily updates',
'Email support'
]}
/>
{/* Annual Plan (Recommended) */}
<PlanCard
selected={selectedPlan === 'annual'}
onSelect={() => setSelectedPlan('annual')}
title="Annual"
price="$10"
period="/year"
savings="Save 52%"
recommended
features={[
'1 EPG access key',
'All 10 providers',
'4x daily updates',
'Email support',
'2 months FREE (with trial CC)'
]}
/>
</div>
<div className="text-center">
<button
onClick={handleUpgrade}
disabled={loading}
className="btn btn-primary btn-lg px-12"
>
{loading ? 'Processing...' : `Upgrade to ${selectedPlan === 'monthly' ? 'Monthly' : 'Annual'}`}
</button>
<p className="text-sm text-gray-500 mt-4">
Secure payment powered by Stripe
</p>
</div>
</div>
);
}
Profile Settings (/consumer/profile)
Tabs: 1. Personal Info - Name, email (synced with Auth0) - Timezone preference - Language (future)
- Notifications
- Email preferences (renewal reminders, product updates)
-
Frequency (daily, weekly, monthly)
-
Security
- Change password (Auth0)
- Two-factor authentication (Auth0)
-
Active sessions
-
Danger Zone
- Delete account (requires confirmation)
Reseller Portal
Dashboard (/reseller/dashboard)
export function ResellerDashboard() {
const { user } = useAuth();
const { subscriptions } = useResellerSubscriptions();
const { keys, stats } = useResellerKeys();
return (
<div className="reseller-dashboard p-8">
<h1 className="text-3xl font-bold mb-8">Reseller Dashboard</h1>
{/* Key Metrics */}
<div className="grid md:grid-cols-4 gap-6 mb-8">
<MetricCard
title="Active Subscriptions"
value={subscriptions.filter(s => s.status === 'active').length}
icon={<CheckCircleIcon />}
color="green"
/>
<MetricCard
title="Total Keys Generated"
value={keys.length}
icon={<KeyIcon />}
color="blue"
/>
<MetricCard
title="Keys Remaining"
value={stats.keys_remaining}
icon={<PlusCircleIcon />}
color="purple"
/>
<MetricCard
title="Monthly Revenue"
value={`$${stats.monthly_revenue}`}
icon={<DollarIcon />}
color="green"
/>
</div>
{/* Quick Actions */}
<Card className="mb-8">
<CardHeader>
<h2 className="text-xl font-bold">Quick Actions</h2>
</CardHeader>
<CardBody>
<div className="grid md:grid-cols-3 gap-4">
<button
onClick={() => navigate('/reseller/keys/generate')}
className="btn btn-primary"
>
<PlusIcon className="mr-2" />
Generate New Key
</button>
<button
onClick={() => navigate('/reseller/subscription/add-provider')}
className="btn btn-secondary"
>
<ShoppingCartIcon className="mr-2" />
Add Provider Pack
</button>
<button
onClick={() => exportKeysToCSV(keys)}
className="btn btn-outline"
>
<DownloadIcon className="mr-2" />
Export All Keys
</button>
</div>
</CardBody>
</Card>
{/* Provider Subscriptions Table */}
<Card className="mb-8">
<CardHeader>
<h2 className="text-xl font-bold">Provider Pack Subscriptions</h2>
</CardHeader>
<CardBody>
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2">Provider</th>
<th className="text-left py-2">Status</th>
<th className="text-left py-2">Keys Used</th>
<th className="text-left py-2">Renewal Date</th>
<th className="text-left py-2">Actions</th>
</tr>
</thead>
<tbody>
{subscriptions.map(sub => (
<tr key={sub.id} className="border-b">
<td className="py-3">
<div className="flex items-center">
<img src={sub.provider.logo} className="w-8 h-8 mr-2" />
<span className="font-semibold">{sub.provider.name}</span>
</div>
</td>
<td><StatusBadge status={sub.status} /></td>
<td>
<div className="flex items-center">
<div className="w-32 bg-gray-200 rounded-full h-2 mr-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(sub.keys_used / sub.keys_total) * 100}%` }}
/>
</div>
<span className="text-sm">{sub.keys_used}/{sub.keys_total}</span>
</div>
</td>
<td>{formatDate(sub.renewal_date)}</td>
<td>
<button className="btn btn-sm btn-outline">Manage</button>
</td>
</tr>
))}
</tbody>
</table>
</CardBody>
</Card>
{/* Recent Key Activity */}
<Card>
<CardHeader>
<h2 className="text-xl font-bold">Recent Key Activity</h2>
</CardHeader>
<CardBody>
<ActivityFeed activities={stats.recent_activity} />
</CardBody>
</Card>
</div>
);
}
Key Management (/reseller/keys)
Features: - List all keys with search/filter - Generate new key (modal dialog) - View key details (usage stats, access logs) - Deactivate/reactivate key - Export keys to CSV - Bulk operations (deactivate multiple, export selected)
Generate Key Modal:
export function GenerateKeyModal({ isOpen, onClose, providerId }) {
const [customerEmail, setCustomerEmail] = useState('');
const [customerName, setCustomerName] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [notes, setNotes] = useState('');
const handleGenerate = async () => {
const { key } = await api.post('/reseller/keys/generate', {
provider_id: providerId,
customer_email: customerEmail,
customer_name: customerName,
expiry_date: expiryDate,
notes
});
// Show success message with key URL
toast.success(
<div>
<p>Key generated successfully!</p>
<p className="font-mono text-sm mt-2">
https://epgo.at/{key.id}/tps.xml
</p>
</div>
);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalHeader>Generate New EPG Key</ModalHeader>
<ModalBody>
<form className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2">
Customer Name (Optional)
</label>
<input
type="text"
value={customerName}
onChange={e => setCustomerName(e.target.value)}
className="w-full px-4 py-2 border rounded"
placeholder="John Doe"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">
Customer Email (Optional)
</label>
<input
type="email"
value={customerEmail}
onChange={e => setCustomerEmail(e.target.value)}
className="w-full px-4 py-2 border rounded"
placeholder="john@example.com"
/>
<p className="text-xs text-gray-500 mt-1">
We won't contact them. This is just for your records.
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2">
Expiry Date (Optional)
</label>
<input
type="date"
value={expiryDate}
onChange={e => setExpiryDate(e.target.value)}
className="w-full px-4 py-2 border rounded"
/>
<p className="text-xs text-gray-500 mt-1">
Leave blank to sync with your subscription renewal
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2">
Notes (Internal)
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
className="w-full px-4 py-2 border rounded"
rows="3"
placeholder="e.g., VIP customer, testing, etc."
/>
</div>
</form>
</ModalBody>
<ModalFooter>
<button onClick={onClose} className="btn btn-outline mr-2">
Cancel
</button>
<button onClick={handleGenerate} className="btn btn-primary">
Generate Key
</button>
</ModalFooter>
</Modal>
);
}
Keys Table:
export function KeysTable({ keys }) {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [providerFilter, setProviderFilter] = useState('all');
const filteredKeys = keys
.filter(k => {
if (search) {
return k.customer_name?.toLowerCase().includes(search.toLowerCase()) ||
k.customer_email?.toLowerCase().includes(search.toLowerCase()) ||
k.id.includes(search);
}
return true;
})
.filter(k => statusFilter === 'all' || k.status === statusFilter)
.filter(k => providerFilter === 'all' || k.provider_id === providerFilter);
return (
<div>
{/* Filters */}
<div className="flex gap-4 mb-4">
<input
type="text"
placeholder="Search by name, email, or key ID..."
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 px-4 py-2 border rounded"
/>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="px-4 py-2 border rounded"
>
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="expired">Expired</option>
</select>
<select
value={providerFilter}
onChange={e => setProviderFilter(e.target.value)}
className="px-4 py-2 border rounded"
>
<option value="all">All Providers</option>
{/* ... provider options ... */}
</select>
</div>
{/* Table */}
<table className="w-full">
<thead>
<tr className="border-b bg-gray-50">
<th className="text-left py-3 px-4">Key ID</th>
<th className="text-left py-3 px-4">Customer</th>
<th className="text-left py-3 px-4">Provider</th>
<th className="text-left py-3 px-4">Status</th>
<th className="text-left py-3 px-4">Created</th>
<th className="text-left py-3 px-4">Last Access</th>
<th className="text-left py-3 px-4">Accesses</th>
<th className="text-left py-3 px-4">Actions</th>
</tr>
</thead>
<tbody>
{filteredKeys.map(key => (
<tr key={key.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
<code className="text-xs">{key.id.slice(0, 8)}...</code>
</td>
<td className="py-3 px-4">
<div>
<div className="font-semibold">{key.customer_name || 'N/A'}</div>
<div className="text-sm text-gray-500">{key.customer_email || 'N/A'}</div>
</div>
</td>
<td className="py-3 px-4">{key.provider.name}</td>
<td className="py-3 px-4">
<StatusBadge status={key.status} />
</td>
<td className="py-3 px-4">{formatDate(key.created_at)}</td>
<td className="py-3 px-4">
{key.last_accessed_at ? formatRelative(key.last_accessed_at) : 'Never'}
</td>
<td className="py-3 px-4">{key.access_count}</td>
<td className="py-3 px-4">
<div className="flex gap-2">
<button
onClick={() => viewKeyDetails(key.id)}
className="text-blue-600 hover:underline text-sm"
>
View
</button>
<button
onClick={() => toggleKeyStatus(key.id)}
className="text-orange-600 hover:underline text-sm"
>
{key.status === 'active' ? 'Deactivate' : 'Activate'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Technical Stack
Frontend Framework
Marketing Website: - Framework: None (vanilla HTML/CSS/JS) - CSS: Tailwind CSS v3.4 - Build: None needed (static HTML) - Deployment: Cloudflare Pages
Consumer/Reseller Portals: - Framework: React 18 with TypeScript - Build Tool: Vite 5.0 - Routing: React Router v6 - State Management: React Context + TanStack Query (for API calls) - UI Components: Shadcn/ui (Tailwind-based) - Forms: React Hook Form + Zod validation - Charts: Recharts - Icons: Heroicons - Deployment: Cloudflare Pages
Authentication
- Provider: Auth0 (free tier)
- SDK:
@auth0/auth0-reactv2.2 - Token Management: Automatic via Auth0 SDK
- Session Storage: Secure HTTP-only cookies
Payment Processing
- Provider: Stripe
- SDK:
@stripe/stripe-jsv2.4 - Elements:
@stripe/react-stripe-jsv2.4 - Checkout Mode: Embedded (Stripe Elements)
- Webhooks: Handled by Cloudflare Worker
API Client
// src/backend/epgoat/services/api.ts
import axios from 'axios';
import { useAuth0 } from '@auth0/auth0-react';
const api = axios.create({
baseURL: process.env.REACT_APP_API_BASE_URL, // https://api.epgoat.tv
timeout: 10000,
});
// Request interceptor: Add JWT token
api.interceptors.request.use(async (config) => {
const { getAccessTokenSilently } = useAuth0();
const token = await getAccessTokenSilently();
config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response interceptor: Handle errors
api.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
// Token expired, redirect to login
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
Environment Variables
Marketing Website:
# None needed (fully static)
Consumer/Reseller Portals:
# .env.production
REACT_APP_API_BASE_URL=https://api.epgoat.tv
REACT_APP_AUTH0_DOMAIN=epgoat.us.auth0.com
REACT_APP_AUTH0_CLIENT_ID=xxx
REACT_APP_AUTH0_AUDIENCE=https://api.epgoat.tv
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
REACT_APP_ENVIRONMENT=production
UI/UX Design
Design System
Colors:
:root {
--color-primary: #2563EB; /* Blue 600 */
--color-primary-hover: #1D4ED8; /* Blue 700 */
--color-secondary: #9333EA; /* Purple 600 */
--color-success: #10B981; /* Green 500 */
--color-warning: #F59E0B; /* Amber 500 */
--color-error: #EF4444; /* Red 500 */
--color-gray-50: #F9FAFB;
--color-gray-100: #F3F4F6;
--color-gray-600: #4B5563;
--color-gray-900: #111827;
}
Typography:
/* Font Stack */
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* Scale */
.text-xs { font-size: 0.75rem; } /* 12px */
.text-sm { font-size: 0.875rem; } /* 14px */
.text-base { font-size: 1rem; } /* 16px */
.text-lg { font-size: 1.125rem; } /* 18px */
.text-xl { font-size: 1.25rem; } /* 20px */
.text-2xl { font-size: 1.5rem; } /* 24px */
.text-3xl { font-size: 1.875rem; } /* 30px */
.text-4xl { font-size: 2.25rem; } /* 36px */
.text-5xl { font-size: 3rem; } /* 48px */
Spacing: - Base unit: 4px - Scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96px
Shadows:
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
.shadow { box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); }
.shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
.shadow-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); }
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1); }
Border Radius:
.rounded-sm { border-radius: 0.125rem; } /* 2px */
.rounded { border-radius: 0.25rem; } /* 4px */
.rounded-md { border-radius: 0.375rem; } /* 6px */
.rounded-lg { border-radius: 0.5rem; } /* 8px */
.rounded-xl { border-radius: 0.75rem; } /* 12px */
.rounded-full { border-radius: 9999px; }
Component Library
Button:
export const Button = ({
children,
variant = 'primary',
size = 'md',
...props
}) => {
const baseClasses = 'font-semibold rounded-lg transition-colors';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-purple-600 text-white hover:bg-purple-700',
outline: 'border-2 border-gray-300 text-gray-700 hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700'
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
};
return (
<button
className={`${baseClasses} ${variants[variant]} ${sizes[size]}`}
{...props}
>
{children}
</button>
);
};
Card:
export const Card = ({ children, className = '' }) => (
<div className={`bg-white rounded-lg shadow-md ${className}`}>
{children}
</div>
);
export const CardHeader = ({ children }) => (
<div className="px-6 py-4 border-b border-gray-200">
{children}
</div>
);
export const CardBody = ({ children }) => (
<div className="px-6 py-4">
{children}
</div>
);
StatusBadge:
export const StatusBadge = ({ status }) => {
const variants = {
active: 'bg-green-100 text-green-800',
trial: 'bg-blue-100 text-blue-800',
past_due: 'bg-orange-100 text-orange-800',
canceled: 'bg-red-100 text-red-800',
expired: 'bg-gray-100 text-gray-800'
};
const labels = {
active: 'Active',
trial: 'Trial',
past_due: 'Past Due',
canceled: 'Canceled',
expired: 'Expired'
};
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${variants[status]}`}>
{labels[status]}
</span>
);
};
Responsive Breakpoints
/* Mobile first */
@media (min-width: 640px) { /* sm */ }
@media (min-width: 768px) { /* md */ }
@media (min-width: 1024px) { /* lg */ }
@media (min-width: 1280px) { /* xl */ }
@media (min-width: 1536px) { /* 2xl */ }
API Integration
API Endpoints
Authentication
POST /api/auth/register- Create new user after Auth0 signupGET /api/auth/me- Get current user profilePUT /api/auth/me- Update user profileDELETE /api/auth/me- Delete account
Subscriptions
GET /api/subscriptions- List user's subscriptionsPOST /api/subscriptions/checkout- Create Stripe checkout sessionPOST /api/subscriptions/portal- Create Stripe customer portal sessionPUT /api/subscriptions/:id/cancel- Cancel subscriptionPUT /api/subscriptions/:id/reactivate- Reactivate canceled subscriptionGET /api/subscriptions/:id/invoices- List invoices
Keys (Consumer)
GET /api/keys/my- Get user's EPG access keyGET /api/keys/my/stats- Get usage statisticsPOST /api/keys/my/regenerate- Regenerate key (invalidates old one)
Keys (Reseller)
GET /api/reseller/keys- List all generated keysPOST /api/reseller/keys- Generate new keyGET /api/reseller/keys/:id- Get key detailsPUT /api/reseller/keys/:id- Update key (deactivate, add notes)DELETE /api/reseller/keys/:id- Delete keyGET /api/reseller/keys/:id/logs- Get access logs for keyPOST /api/reseller/keys/export- Export keys to CSV
Providers
GET /api/providers- List all supported providersGET /api/providers/:id- Get provider details
Analytics
GET /api/analytics/usage- Get usage analyticsGET /api/analytics/revenue- Get revenue analytics (resellers only)
API Response Format
Success:
{
"success": true,
"data": {
"id": "123",
"name": "Example"
}
}
Error:
{
"success": false,
"error": {
"code": "SUBSCRIPTION_NOT_FOUND",
"message": "Subscription not found",
"details": {}
}
}
React Query Integration
// src/hooks/useSubscription.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../backend/epgoat/services/api';
export function useSubscription() {
const queryClient = useQueryClient();
// Fetch subscription
const { data: subscription, isLoading, error } = useQuery({
queryKey: ['subscription'],
queryFn: () => api.get('/subscriptions'),
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Cancel subscription
const cancelMutation = useMutation({
mutationFn: (subscriptionId) => api.put(`/subscriptions/${subscriptionId}/cancel`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
},
});
return {
subscription,
loading: isLoading,
error,
cancelSubscription: cancelMutation.mutate,
};
}
Deployment
Cloudflare Pages Setup
Marketing Website:
# Build settings
Build command: (none)
Build output directory: /marketing
Root directory: /public-frontend/marketing
Consumer Portal:
# Build settings
Build command: npm run build
Build output directory: /dist
Root directory: /public-frontend/consumer-portal
Environment variables: (see above)
Reseller Portal:
# Build settings
Build command: npm run build
Build output directory: /dist
Root directory: /public-frontend/reseller-portal
Environment variables: (see above)
Custom Domains
Marketing Website:
- epgoat.tv (apex)
- www.epgoat.tv (redirect to apex)
Consumer Portal:
- app.epgoat.tv/consumer/*
Reseller Portal:
- app.epgoat.tv/reseller/*
Auth0:
- auth.epgoat.tv (custom domain via Auth0)
DNS Configuration
# Cloudflare DNS
A @ 192.0.2.1 (Cloudflare Pages)
CNAME www epgoat.tv
CNAME app xxx.pages.dev
CNAME auth epgoat.us.auth0.com
CI/CD Pipeline
GitHub Actions (.github/workflows/deploy-frontend.yml):
name: Deploy Frontend
on:
push:
branches: [main]
paths:
- 'public-frontend/**'
workflow_dispatch:
jobs:
deploy-marketing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: epgoat-marketing
directory: public-frontend/marketing
branch: main
deploy-consumer-portal:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
working-directory: public-frontend/consumer-portal
run: npm ci
- name: Build
working-directory: public-frontend/consumer-portal
run: npm run build
env:
REACT_APP_API_BASE_URL: ${{ secrets.API_BASE_URL }}
REACT_APP_AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN }}
REACT_APP_AUTH0_CLIENT_ID: ${{ secrets.AUTH0_CLIENT_ID_CONSUMER }}
REACT_APP_STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: epgoat-consumer-portal
directory: public-frontend/consumer-portal/dist
branch: main
deploy-reseller-portal:
runs-on: ubuntu-latest
steps:
# ... similar to consumer portal ...
Testing Strategy
Unit Tests
Framework: Jest + React Testing Library
Coverage Target: 80%+
Example Test:
// src/components/Dashboard.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { Dashboard } from './Dashboard';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Auth0Provider } from '@auth0/auth0-react';
const mockAuth0 = {
isAuthenticated: true,
user: {
email: 'test@example.com',
'https://epgoat.tv/role': 'consumer',
},
getAccessTokenSilently: jest.fn(() => Promise.resolve('mock-token')),
};
jest.mock('@auth0/auth0-react', () => ({
useAuth0: () => mockAuth0,
Auth0Provider: ({ children }) => children,
}));
describe('Dashboard', () => {
it('renders user welcome message', async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText(/Welcome back/i)).toBeInTheDocument();
});
});
it('displays EPG access key', async () => {
// ... test implementation ...
});
});
Integration Tests
Framework: Playwright
Scenarios: 1. User registration flow (Auth0) 2. Trial signup and EPG key retrieval 3. Upgrade to annual subscription (Stripe) 4. Key regeneration 5. Subscription cancellation 6. Reseller key generation
Example:
// tests/integration/registration.spec.ts
import { test, expect } from '@playwright/test';
test('user can register and get EPG key', async ({ page }) => {
// 1. Visit landing page
await page.goto('https://epgoat.tv');
// 2. Click "Start Free Trial"
await page.click('text=Start Free Trial');
// 3. Fill Auth0 signup form
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'SecurePass123!');
await page.click('button[type=submit]');
// 4. Redirected to dashboard
await expect(page).toHaveURL(/\/consumer\/dashboard/);
// 5. EPG key visible
await expect(page.locator('text=Your EPG Access')).toBeVisible();
await expect(page.locator('input[readonly]')).toHaveValue(/https:\/\/epgo\.at\/.+\/tps\.xml/);
});
E2E Tests
Framework: Cypress
Critical Paths: 1. Full signup → payment → EPG access flow 2. Reseller generates 10 keys and exports CSV 3. Trial expiry soft-lock and upgrade 4. Subscription renewal flow
Security
Content Security Policy
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' https://js.stripe.com https://cdn.auth0.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://api.epgoat.tv https://*.stripe.com https://*.auth0.com;
frame-src https://js.stripe.com;
">
CORS Configuration
Cloudflare Worker API:
// Handle CORS
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://app.epgoat.tv',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
// Add to all responses
response.headers.set('Access-Control-Allow-Origin', corsHeaders['Access-Control-Allow-Origin']);
XSS Protection
- React: Automatic escaping of user input
- DOMPurify: Sanitize any HTML from API (if needed)
- CSP: Strict Content Security Policy
CSRF Protection
- Stripe: Built-in CSRF protection
- Auth0: State parameter validation
- Custom API: JWT tokens (stateless, no CSRF risk)
Rate Limiting
Cloudflare Worker:
// Rate limit: 100 requests per minute per IP
const rateLimiter = new RateLimiter({
limit: 100,
window: 60,
});
const clientIP = request.headers.get('CF-Connecting-IP');
const { success } = await rateLimiter.check(clientIP);
if (!success) {
return new Response('Too many requests', { status: 429 });
}
Performance
Metrics Targets
| Metric | Target | Measurement |
|---|---|---|
| First Contentful Paint (FCP) | <1.5s | Lighthouse |
| Largest Contentful Paint (LCP) | <2.5s | Lighthouse |
| Total Blocking Time (TBT) | <300ms | Lighthouse |
| Cumulative Layout Shift (CLS) | <0.1 | Lighthouse |
| Lighthouse Score | 90+ | Chrome DevTools |
Optimization Strategies
1. Code Splitting:
// Lazy load routes
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Subscription = lazy(() => import('./pages/Subscription'));
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/subscription" element={<Subscription />} />
</Routes>
</Suspense>
2. Image Optimization:
- Use WebP format with PNG fallback
- Lazy load images below the fold
- Serve responsive images via srcset
3. Bundle Size: - Target: <200KB initial JS bundle - Tree shaking: Vite automatically removes unused code - Minification: Enabled in production build - Compression: Brotli compression via Cloudflare
4. Caching:
// Service Worker (optional, for offline support)
workbox.routing.registerRoute(
({ request }) => request.destination === 'script',
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'js-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
}),
],
})
);
Accessibility
WCAG 2.1 Level AA Compliance
Requirements: - ✅ Keyboard navigation for all interactive elements - ✅ Screen reader support (ARIA labels) - ✅ Color contrast ratio ≥4.5:1 - ✅ Focus indicators visible - ✅ Alt text for all images - ✅ Semantic HTML5 elements - ✅ Form labels and error messages
Testing Tools: - axe DevTools (Chrome extension) - WAVE (Web Accessibility Evaluation Tool) - Lighthouse accessibility audit
Example Accessible Component:
export const Button = ({
children,
ariaLabel,
disabled,
onClick
}) => (
<button
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
aria-disabled={disabled}
className="btn"
>
{children}
</button>
);
Implementation Timeline
Month 1 (Weeks 1-4)
Week 1: - [ ] Set up project structure - [ ] Configure Auth0 tenant - [ ] Create design system (Tailwind config) - [ ] Build marketing landing page
Week 2: - [ ] Build marketing features, pricing, FAQ pages - [ ] Set up React projects (consumer + reseller) - [ ] Implement Auth0 integration - [ ] Build authentication flows
Week 3: - [ ] Build consumer dashboard - [ ] Implement EPG key display and copy - [ ] Build subscription management UI - [ ] Integrate Stripe Elements
Week 4: - [ ] Build reseller dashboard - [ ] Implement key generation UI - [ ] Build key management table - [ ] Add usage analytics
Month 2 (Weeks 5-8)
Week 5: - [ ] Connect all UIs to API endpoints - [ ] Implement error handling - [ ] Add loading states - [ ] Write unit tests
Week 6: - [ ] Integration testing (Playwright) - [ ] Fix bugs and edge cases - [ ] Performance optimization - [ ] Accessibility audit
Week 7: - [ ] Deploy to Cloudflare Pages (staging) - [ ] User acceptance testing - [ ] Fix critical bugs - [ ] SEO optimization
Week 8: - [ ] Production deployment - [ ] DNS configuration - [ ] SSL certificates - [ ] Go-live!
Success Criteria
- [x] Authentication: Users can register with Auth0 (Google + email/password)
- [x] Trial Signup: Users get free 30-day trial with EPG key immediately
- [x] Payment Processing: Stripe integration works for annual subscriptions
- [x] Key Management: Consumers can view/copy/regenerate their EPG key
- [x] Reseller Portal: Resellers can generate and manage 100s of keys
- [x] Performance: Lighthouse score 90+ on all pages
- [x] Accessibility: WCAG 2.1 AA compliant
- [x] Mobile: Fully responsive on all devices
- [x] Deployment: Auto-deploy to Cloudflare Pages on merge to main
Appendix
Browser Support
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
Dependencies
Consumer/Reseller Portal (package.json):
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"@auth0/auth0-react": "^2.2.3",
"@stripe/stripe-js": "^2.4.0",
"@stripe/react-stripe-js": "^2.4.0",
"@tanstack/react-query": "^5.14.0",
"axios": "^1.6.2",
"date-fns": "^2.30.0",
"recharts": "^2.10.3",
"react-hook-form": "^7.49.2",
"zod": "^3.22.4",
"tailwindcss": "^3.4.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8",
"typescript": "^5.3.3",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.1.5",
"jest": "^29.7.0",
"@playwright/test": "^1.40.1"
}
}
Contact
Owner: Frontend Team Slack: #public-frontend Last Updated: 2025-10-30
END OF SPECIFICATION