feat(beta): add beta testing features and privacy policy page

Implemented comprehensive beta testing system with tier badges and
reorganized environment configuration for better maintainability.

Beta Testing Features:
- Beta banner component with dismissible state (localStorage)
- Auto-assign SUPPORTER tier to new registrations (env controlled)
- TierBadge component with SUPPORTER/COMFORT tier display
- Badge shown in Navbar, ProfilePage, and PublicProfilePage
- Environment variables: VITE_BETA_MODE, BETA_AUTO_SUPPORTER

Environment Configuration Reorganization:
- Moved .env files from root to frontend/ and backend/ directories
- Created .env.{development,production}{,.example} structure
- Updated docker-compose.yml to use env_file for frontend
- All env vars properly namespaced and documented

Privacy Policy Implementation:
- New /privacy route with dedicated PrivacyPage component
- Comprehensive GDPR/RODO compliant privacy policy (privacy.html)
- Updated CookieConsent banner to link to /privacy
- Added Privacy Policy links to all footers (HomePage, PublicFooter)
- Removed privacy section from About Us page

HTML Content System:
- Replaced react-markdown dependency with simple HTML loader
- New HtmlContentPage component for rendering .html files
- Converted about-us.md and how-it-works.md to .html format
- Inline CSS support for full styling control
- Easier content editing without React knowledge

Backend Changes:
- Registration auto-assigns SUPPORTER tier when BETA_AUTO_SUPPORTER=true
- Added accountTier to auth middleware and user routes
- Updated public profile endpoint to include accountTier

Files:
- Added: frontend/.env.{development,production}{,.example}
- Added: backend/.env variables for BETA_AUTO_SUPPORTER
- Added: components/BetaBanner.jsx, TierBadge.jsx, HtmlContentPage.jsx
- Added: pages/PrivacyPage.jsx
- Added: public/content/{about-us,how-it-works,privacy}.html
- Modified: docker-compose.yml (env_file configuration)
- Modified: App.jsx (privacy route, beta banner)
- Modified: auth.js (auto SUPPORTER tier logic)
This commit is contained in:
Radosław Gierwiało
2025-12-06 11:50:28 +01:00
parent a786b1d92d
commit e2b10387c2
28 changed files with 841 additions and 251 deletions

View File

@@ -26,10 +26,12 @@ import ContactMessagesPage from './pages/admin/ContactMessagesPage';
import ContactPage from './pages/ContactPage';
import AboutUsPage from './pages/AboutUsPage';
import HowItWorksPage from './pages/HowItWorksPage';
import PrivacyPage from './pages/PrivacyPage';
import NotFoundPage from './pages/NotFoundPage';
import VerificationBanner from './components/common/VerificationBanner';
import InstallPWA from './components/pwa/InstallPWA';
import CookieConsent from './components/common/CookieConsent';
import BetaBanner from './components/BetaBanner';
// Protected Route Component with Verification Banner
const ProtectedRoute = ({ children }) => {
@@ -98,6 +100,9 @@ function App() {
<BrowserRouter>
<AuthProvider>
<AnalyticsWrapper>
{/* Beta Testing Banner */}
<BetaBanner />
{/* PWA Install Prompt */}
<InstallPWA />
@@ -263,6 +268,9 @@ function App() {
{/* How It Works Page - Public route */}
<Route path="/how-it-works" element={<HowItWorksPage />} />
{/* Privacy Policy Page - Public route */}
<Route path="/privacy" element={<PrivacyPage />} />
{/* Public Profile - /u/username format (no auth required) */}
<Route path="/u/:username" element={<PublicProfilePage />} />

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
const BetaBanner = () => {
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const betaMode = import.meta.env.VITE_BETA_MODE === 'true';
useEffect(() => {
if (!betaMode) {
setIsVisible(false);
return;
}
// Check if banner was dismissed
const dismissed = localStorage.getItem('betaBannerDismissed');
if (!dismissed) {
setIsVisible(true);
}
}, [betaMode]);
const handleDismiss = () => {
setIsClosing(true);
setTimeout(() => {
setIsVisible(false);
localStorage.setItem('betaBannerDismissed', 'true');
}, 300); // Match animation duration
};
if (!isVisible || !betaMode) {
return null;
}
return (
<div
className={`bg-gradient-to-r from-purple-600 to-blue-600 text-white px-4 py-3 shadow-lg transition-all duration-300 ${
isClosing ? 'opacity-0 -translate-y-full' : 'opacity-100 translate-y-0'
}`}
>
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<span className="bg-white/20 backdrop-blur-sm px-3 py-1 rounded-full text-sm font-semibold uppercase tracking-wide">
Beta
</span>
<p className="text-sm md:text-base">
<span className="font-semibold">Welcome to our beta testing!</span>
<span className="hidden sm:inline"> Help us improve by reporting any issues or suggestions.</span>
<span className="ml-2 text-purple-200">All beta testers get SUPPORTER status! 🎉</span>
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 p-1 hover:bg-white/20 rounded-lg transition-colors duration-200"
aria-label="Dismiss banner"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
);
};
export default BetaBanner;

View File

@@ -0,0 +1,70 @@
import { useState, useEffect } from 'react';
import PublicLayout from './layout/PublicLayout';
import { Loader2 } from 'lucide-react';
/**
* Generic component for rendering HTML content from public/content/*.html files
* Supports inline CSS and full HTML structure
*/
export default function HtmlContentPage({ contentFile, pageTitle = 'Page' }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
// Fetch HTML content
fetch(`/content/${contentFile}`)
.then(response => {
if (!response.ok) {
throw new Error('Failed to load content');
}
return response.text();
})
.then(html => {
setContent(html);
setLoading(false);
})
.catch(err => {
console.error(`Error loading ${contentFile}:`, err);
setError('Failed to load page content');
setLoading(false);
});
}, [contentFile]);
if (loading) {
return (
<PublicLayout pageTitle={pageTitle}>
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 text-primary-600 animate-spin" />
</div>
</PublicLayout>
);
}
if (error) {
return (
<PublicLayout pageTitle={pageTitle}>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 text-lg mb-2">{error}</p>
<p className="text-gray-600">Please try again later.</p>
</div>
</div>
</PublicLayout>
);
}
return (
<PublicLayout pageTitle={pageTitle}>
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-4xl mx-auto px-4">
{/* Render raw HTML with inline styles */}
<div
className="bg-white rounded-lg shadow-sm p-8"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
</PublicLayout>
);
}

View File

@@ -57,12 +57,12 @@ const CookieConsent = () => {
We use cookies and similar technologies to enhance your experience, analyze site traffic,
and for authentication purposes. By clicking "Accept", you consent to our use of cookies.{' '}
<a
href="/about-us"
href="/privacy"
className="text-primary-600 hover:text-primary-700 underline"
target="_blank"
rel="noopener noreferrer"
>
Learn more
Privacy Policy
</a>
</p>
</div>

View File

@@ -0,0 +1,71 @@
import { Crown, Sparkles, User } from 'lucide-react';
const TierBadge = ({ tier, size = 'sm', showLabel = false }) => {
if (!tier || tier === 'BASIC') {
return null; // Don't show badge for BASIC tier
}
const configs = {
SUPPORTER: {
icon: Sparkles,
label: 'Supporter',
bgColor: 'bg-blue-100',
textColor: 'text-blue-700',
borderColor: 'border-blue-300',
iconColor: 'text-blue-600',
},
COMFORT: {
icon: Crown,
label: 'Comfort',
bgColor: 'bg-purple-100',
textColor: 'text-purple-700',
borderColor: 'border-purple-300',
iconColor: 'text-purple-600',
},
};
const config = configs[tier];
if (!config) return null;
const sizeClasses = {
xs: {
container: 'px-1.5 py-0.5 text-xs',
icon: 'w-3 h-3',
},
sm: {
container: 'px-2 py-1 text-xs',
icon: 'w-3.5 h-3.5',
},
md: {
container: 'px-2.5 py-1.5 text-sm',
icon: 'w-4 h-4',
},
};
const sizes = sizeClasses[size] || sizeClasses.sm;
const Icon = config.icon;
if (!showLabel) {
// Icon only - for compact display
return (
<span
className={`inline-flex items-center justify-center ${config.bgColor} ${config.borderColor} border rounded-full ${sizes.container}`}
title={config.label}
>
<Icon className={`${sizes.icon} ${config.iconColor}`} />
</span>
);
}
// Full badge with label
return (
<span
className={`inline-flex items-center gap-1 ${config.bgColor} ${config.textColor} ${config.borderColor} border rounded-full ${sizes.container} font-medium`}
>
<Icon className={`${sizes.icon} ${config.iconColor}`} />
<span>{config.label}</span>
</span>
);
};
export default TierBadge;

View File

@@ -2,6 +2,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Video, LogOut, User, History, Users, Menu, X, LayoutDashboard, Calendar, Shield, Mail, Activity, ChevronDown } from 'lucide-react';
import Avatar from '../common/Avatar';
import TierBadge from '../common/TierBadge';
import { useState, useEffect, useRef } from 'react';
import { matchesAPI } from '../../services/api';
import { connectSocket, disconnectSocket, getSocket } from '../../services/socket';
@@ -178,7 +179,10 @@ const Navbar = ({ pageTitle = null }) => {
className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-100"
>
<Avatar src={user?.avatar} username={user.username} size={32} />
<span className="text-sm font-medium text-gray-700">{user.username}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">{user.username}</span>
<TierBadge tier={user.accountTier} size="xs" />
</div>
</Link>
<button

View File

@@ -54,6 +54,11 @@ const PublicFooter = () => {
Contact Us
</Link>
</li>
<li>
<Link to="/privacy" className="text-gray-600 hover:text-primary-600 transition-colors">
Privacy Policy
</Link>
</li>
</ul>
</div>
</div>

View File

@@ -1,91 +1,5 @@
import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import PublicLayout from '../components/layout/PublicLayout';
import HtmlContentPage from '../components/HtmlContentPage';
export default function AboutUsPage() {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
// Fetch markdown content
fetch('/content/about-us.md')
.then(response => {
if (!response.ok) {
throw new Error('Failed to load content');
}
return response.text();
})
.then(text => {
setContent(text);
setLoading(false);
})
.catch(err => {
console.error('Error loading about-us content:', err);
setError('Failed to load page content');
setLoading(false);
});
}, []);
if (loading) {
return (
<PublicLayout>
<div className="bg-gray-50 flex items-center justify-center py-12">
<div className="text-center">
<div className="w-12 h-12 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
</PublicLayout>
);
}
if (error) {
return (
<PublicLayout>
<div className="bg-gray-50 flex items-center justify-center py-12">
<div className="text-center">
<p className="text-red-600">{error}</p>
</div>
</div>
</PublicLayout>
);
}
return (
<PublicLayout>
<div className="bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<article className="bg-white rounded-lg shadow-sm p-8 md:p-12 prose prose-lg max-w-none">
<ReactMarkdown
components={{
// Customize link rendering to use React Router for internal links
a: ({ node, href, children, ...props }) => {
const isInternal = href && href.startsWith('/');
if (isInternal) {
return (
<a href={href} {...props}>
{children}
</a>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
},
// Ensure images are responsive
img: ({ node, ...props }) => (
<img className="rounded-lg shadow-md" {...props} alt={props.alt || ''} />
),
}}
>
{content}
</ReactMarkdown>
</article>
</div>
</div>
</PublicLayout>
);
return <HtmlContentPage contentFile="about-us.html" pageTitle="About Us" />;
}

View File

@@ -284,6 +284,11 @@ const HomePage = () => {
Contact Us
</Link>
</li>
<li>
<Link to="/privacy" className="hover:text-primary-400 transition">
Privacy Policy
</Link>
</li>
</ul>
</div>

View File

@@ -1,91 +1,5 @@
import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import PublicLayout from '../components/layout/PublicLayout';
import HtmlContentPage from '../components/HtmlContentPage';
export default function HowItWorksPage() {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
// Fetch markdown content
fetch('/content/how-it-works.md')
.then(response => {
if (!response.ok) {
throw new Error('Failed to load content');
}
return response.text();
})
.then(text => {
setContent(text);
setLoading(false);
})
.catch(err => {
console.error('Error loading how-it-works content:', err);
setError('Failed to load page content');
setLoading(false);
});
}, []);
if (loading) {
return (
<PublicLayout>
<div className="bg-gray-50 flex items-center justify-center py-12">
<div className="text-center">
<div className="w-12 h-12 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
</PublicLayout>
);
}
if (error) {
return (
<PublicLayout>
<div className="bg-gray-50 flex items-center justify-center py-12">
<div className="text-center">
<p className="text-red-600">{error}</p>
</div>
</div>
</PublicLayout>
);
}
return (
<PublicLayout>
<div className="bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<article className="bg-white rounded-lg shadow-sm p-8 md:p-12 prose prose-lg max-w-none">
<ReactMarkdown
components={{
// Customize link rendering to use React Router for internal links
a: ({ node, href, children, ...props }) => {
const isInternal = href && href.startsWith('/');
if (isInternal) {
return (
<a href={href} {...props}>
{children}
</a>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
},
// Ensure images are responsive
img: ({ node, ...props }) => (
<img className="rounded-lg shadow-md" {...props} alt={props.alt || ''} />
),
}}
>
{content}
</ReactMarkdown>
</article>
</div>
</div>
</PublicLayout>
);
return <HtmlContentPage contentFile="how-it-works.html" pageTitle="How It Works" />;
}

View File

@@ -0,0 +1,5 @@
import HtmlContentPage from '../components/HtmlContentPage';
export default function PrivacyPage() {
return <HtmlContentPage contentFile="privacy.html" pageTitle="Privacy Policy" />;
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import Layout from '../components/layout/Layout';
import Avatar from '../components/common/Avatar';
import TierBadge from '../components/common/TierBadge';
import { ProfileForm, PasswordChangeForm } from '../components/profile';
import { User, Lock } from 'lucide-react';
@@ -25,9 +26,12 @@ const ProfilePage = () => {
title={user?.username}
/>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{user?.firstName || user?.username}
</h1>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">
{user?.firstName || user?.username}
</h1>
<TierBadge tier={user?.accountTier} size="md" showLabel={true} />
</div>
<p className="text-gray-600">@{user?.username}</p>
{!user?.emailVerified && (
<p className="text-sm text-yellow-600 mt-1">

View File

@@ -4,6 +4,7 @@ import { authAPI, ratingsAPI } from '../services/api';
import Layout from '../components/layout/Layout';
import { User, MapPin, Globe, Hash, Youtube, Instagram, Facebook, Award, Star, Calendar, Loader2, ThumbsUp } from 'lucide-react';
import Avatar from '../components/common/Avatar';
import TierBadge from '../components/common/TierBadge';
import NotFoundPage from './NotFoundPage';
const PublicProfilePage = () => {
@@ -80,11 +81,14 @@ const PublicProfilePage = () => {
title={profile.username}
/>
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold text-gray-900 mb-1">
{profile.firstName && profile.lastName
? `${profile.firstName} ${profile.lastName}`
: profile.username}
</h1>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-3xl font-bold text-gray-900">
{profile.firstName && profile.lastName
? `${profile.firstName} ${profile.lastName}`
: profile.username}
</h1>
<TierBadge tier={profile.accountTier} size="md" showLabel={true} />
</div>
<p className="text-lg text-gray-600 mb-4">@{profile.username}</p>
{/* Location */}