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:
@@ -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 />} />
|
||||
|
||||
|
||||
63
frontend/src/components/BetaBanner.jsx
Normal file
63
frontend/src/components/BetaBanner.jsx
Normal 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;
|
||||
70
frontend/src/components/HtmlContentPage.jsx
Normal file
70
frontend/src/components/HtmlContentPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
71
frontend/src/components/common/TierBadge.jsx
Normal file
71
frontend/src/components/common/TierBadge.jsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
5
frontend/src/pages/PrivacyPage.jsx
Normal file
5
frontend/src/pages/PrivacyPage.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import HtmlContentPage from '../components/HtmlContentPage';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return <HtmlContentPage contentFile="privacy.html" pageTitle="Privacy Policy" />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user