landing
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
"mcp__appwrite-mcp-server__bucket_operations",
|
||||
"Bash(npm run build:*)",
|
||||
"mcp__appwrite-mcp-server__attribute_operations",
|
||||
"mcp__appwrite-mcp-server__collection_operations"
|
||||
"mcp__appwrite-mcp-server__collection_operations",
|
||||
"mcp__appwrite-mcp-server__index_operations"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
254
src/App.jsx
254
src/App.jsx
@@ -1,12 +1,16 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, getFilePreview } from './lib/appwrite'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query, getFilePreview } from './lib/appwrite'
|
||||
|
||||
// Pages
|
||||
// Public Pages
|
||||
import Landing from './pages/Landing'
|
||||
import Signup from './pages/Signup'
|
||||
import Login from './pages/Login'
|
||||
import Home from './pages/Home'
|
||||
import ComingSoon from './pages/ComingSoon'
|
||||
import Setup from './pages/admin/Setup'
|
||||
import Login from './pages/admin/Login'
|
||||
|
||||
// Admin Pages
|
||||
import AdminLogin from './pages/admin/Login'
|
||||
import Dashboard from './pages/admin/Dashboard'
|
||||
import Links from './pages/admin/Links'
|
||||
import Social from './pages/admin/Social'
|
||||
@@ -22,25 +26,22 @@ import AdminLayout from './components/AdminLayout'
|
||||
// Session timeout: 24 hours
|
||||
const SESSION_TIMEOUT = 24 * 60 * 60 * 1000
|
||||
|
||||
function App() {
|
||||
const [isSetupComplete, setIsSetupComplete] = useState(null)
|
||||
// Component to handle slug-based profile pages
|
||||
function ProfilePage() {
|
||||
const { slug } = useParams()
|
||||
const [clientData, setClientData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkSetup()
|
||||
checkAuth()
|
||||
}, [])
|
||||
loadClientBySlug()
|
||||
}, [slug])
|
||||
|
||||
// Update page title and favicon when client data changes
|
||||
useEffect(() => {
|
||||
if (clientData?.businessName) {
|
||||
document.title = clientData.businessName
|
||||
}
|
||||
|
||||
if (clientData?.logoFileId) {
|
||||
// Update favicon
|
||||
const faviconUrl = getFilePreview(clientData.logoFileId, 64, 64)
|
||||
let link = document.querySelector("link[rel~='icon']")
|
||||
if (!link) {
|
||||
@@ -52,50 +53,26 @@ function App() {
|
||||
}
|
||||
}, [clientData])
|
||||
|
||||
const checkAuth = () => {
|
||||
const authState = sessionStorage.getItem('arklinks_auth')
|
||||
const authTime = sessionStorage.getItem('arklinks_auth_time')
|
||||
|
||||
if (authState === 'true' && authTime) {
|
||||
const elapsed = Date.now() - parseInt(authTime)
|
||||
if (elapsed < SESSION_TIMEOUT) {
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
// Session expired
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkSetup = async () => {
|
||||
const loadClientBySlug = async () => {
|
||||
try {
|
||||
// Check if client exists by looking for any client record
|
||||
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.CLIENTS)
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CLIENTS,
|
||||
[Query.equal('slug', slug)]
|
||||
)
|
||||
if (response.documents.length > 0) {
|
||||
const client = response.documents[0]
|
||||
setClientData(client)
|
||||
setIsSetupComplete(client.isSetupComplete)
|
||||
setClientData(response.documents[0])
|
||||
} else {
|
||||
setIsSetupComplete(false)
|
||||
setNotFound(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup check error:', error)
|
||||
setIsSetupComplete(false)
|
||||
console.error('Error loading client:', error)
|
||||
setNotFound(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
sessionStorage.removeItem('arklinks_auth')
|
||||
sessionStorage.removeItem('arklinks_auth_time')
|
||||
setIsAuthenticated(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
@@ -104,54 +81,161 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">Page Not Found</h1>
|
||||
<p className="text-gray-400 mb-6">The link page you're looking for doesn't exist.</p>
|
||||
<a
|
||||
href="/"
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-full font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Go to Homepage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Home clientData={clientData} />
|
||||
}
|
||||
|
||||
// Component to handle admin routes for a specific client
|
||||
function ClientAdmin() {
|
||||
const { slug } = useParams()
|
||||
const [clientData, setClientData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadClientBySlug()
|
||||
checkAuth()
|
||||
}, [slug])
|
||||
|
||||
useEffect(() => {
|
||||
if (clientData?.businessName) {
|
||||
document.title = `Admin - ${clientData.businessName}`
|
||||
}
|
||||
}, [clientData])
|
||||
|
||||
const loadClientBySlug = async () => {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CLIENTS,
|
||||
[Query.equal('slug', slug)]
|
||||
)
|
||||
if (response.documents.length > 0) {
|
||||
setClientData(response.documents[0])
|
||||
} else {
|
||||
setNotFound(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading client:', error)
|
||||
setNotFound(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuth = () => {
|
||||
const authState = sessionStorage.getItem(`arklinks_auth_${slug}`)
|
||||
const authTime = sessionStorage.getItem(`arklinks_auth_time_${slug}`)
|
||||
|
||||
if (authState === 'true' && authTime) {
|
||||
const elapsed = Date.now() - parseInt(authTime)
|
||||
if (elapsed < SESSION_TIMEOUT) {
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
sessionStorage.setItem(`arklinks_auth_${slug}`, 'true')
|
||||
sessionStorage.setItem(`arklinks_auth_time_${slug}`, Date.now().toString())
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
sessionStorage.removeItem(`arklinks_auth_${slug}`)
|
||||
sessionStorage.removeItem(`arklinks_auth_time_${slug}`)
|
||||
setIsAuthenticated(false)
|
||||
}
|
||||
|
||||
const refreshClientData = async () => {
|
||||
await loadClientBySlug()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">Page Not Found</h1>
|
||||
<p className="text-gray-400 mb-6">This admin panel doesn't exist.</p>
|
||||
<a
|
||||
href="/"
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-full font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Go to Homepage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if admin password is set
|
||||
const hasAdminPassword = clientData?.adminPassword && clientData.adminPassword.length > 0
|
||||
const needsAuth = hasAdminPassword && !isAuthenticated
|
||||
|
||||
if (needsAuth) {
|
||||
return <AdminLogin onLogin={handleLogin} clientData={clientData} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AdminLayout clientData={clientData} onLogout={handleLogout} slug={slug} />}>
|
||||
<Route index element={<Dashboard clientData={clientData} />} />
|
||||
<Route path="links" element={<Links clientData={clientData} />} />
|
||||
<Route path="social" element={<Social clientData={clientData} />} />
|
||||
<Route path="theme" element={<Theme clientData={clientData} onUpdate={refreshClientData} />} />
|
||||
<Route path="gallery" element={<Gallery clientData={clientData} />} />
|
||||
<Route path="apps" element={<Apps clientData={clientData} />} />
|
||||
<Route path="qr-code" element={<QRCode clientData={clientData} slug={slug} />} />
|
||||
<Route path="profile" element={<Profile clientData={clientData} onUpdate={refreshClientData} />} />
|
||||
<Route path="reviews" element={<Reviews clientData={clientData} />} />
|
||||
<Route path="banners" element={<Banners clientData={clientData} />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/" element={
|
||||
isSetupComplete ? <Home clientData={clientData} /> : <Navigate to="/admin/setup" />
|
||||
} />
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/coming-soon" element={<ComingSoon />} />
|
||||
|
||||
{/* Admin Login */}
|
||||
<Route path="/admin/login" element={
|
||||
isAuthenticated ? (
|
||||
<Navigate to="/admin" />
|
||||
) : (
|
||||
<Login onLogin={handleLogin} clientData={clientData} />
|
||||
)
|
||||
} />
|
||||
{/* Client Profile Page */}
|
||||
<Route path="/:slug" element={<ProfilePage />} />
|
||||
|
||||
{/* Admin Setup */}
|
||||
<Route path="/admin/setup" element={
|
||||
<Setup onSetupComplete={() => { setIsSetupComplete(true); checkSetup(); }} />
|
||||
} />
|
||||
|
||||
{/* Protected Admin Routes */}
|
||||
<Route path="/admin" element={
|
||||
!isSetupComplete ? (
|
||||
<Navigate to="/admin/setup" />
|
||||
) : needsAuth ? (
|
||||
<Navigate to="/admin/login" />
|
||||
) : (
|
||||
<AdminLayout clientData={clientData} onLogout={handleLogout} />
|
||||
)
|
||||
}>
|
||||
<Route index element={<Dashboard clientData={clientData} />} />
|
||||
<Route path="links" element={<Links clientData={clientData} />} />
|
||||
<Route path="social" element={<Social clientData={clientData} />} />
|
||||
<Route path="theme" element={<Theme clientData={clientData} onUpdate={checkSetup} />} />
|
||||
<Route path="gallery" element={<Gallery clientData={clientData} />} />
|
||||
<Route path="apps" element={<Apps clientData={clientData} />} />
|
||||
<Route path="qr-code" element={<QRCode clientData={clientData} />} />
|
||||
<Route path="profile" element={<Profile clientData={clientData} onUpdate={checkSetup} />} />
|
||||
<Route path="reviews" element={<Reviews clientData={clientData} />} />
|
||||
<Route path="banners" element={<Banners clientData={clientData} />} />
|
||||
</Route>
|
||||
{/* Client Admin Routes */}
|
||||
<Route path="/:slug/admin/*" element={<ClientAdmin />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
@@ -18,30 +18,35 @@ import {
|
||||
import { useState } from 'react'
|
||||
import { getFilePreview } from '../lib/appwrite'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ path: '/admin/profile', icon: User, label: 'Profile' },
|
||||
{ path: '/admin/links', icon: Link2, label: 'Links' },
|
||||
{ path: '/admin/social', icon: Share2, label: 'Social Media' },
|
||||
{ path: '/admin/banners', icon: Layers, label: 'Banners' },
|
||||
{ path: '/admin/reviews', icon: Star, label: 'Reviews' },
|
||||
{ path: '/admin/theme', icon: Palette, label: 'Theme' },
|
||||
{ path: '/admin/gallery', icon: Image, label: 'Gallery' },
|
||||
{ path: '/admin/apps', icon: Smartphone, label: 'Apps' },
|
||||
{ path: '/admin/qr-code', icon: QrCode, label: 'QR Code' },
|
||||
]
|
||||
|
||||
export default function AdminLayout({ clientData, onLogout }) {
|
||||
export default function AdminLayout({ clientData, onLogout, slug }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Dynamic nav items based on slug
|
||||
const navItems = [
|
||||
{ path: `/${slug}/admin`, icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ path: `/${slug}/admin/profile`, icon: User, label: 'Profile' },
|
||||
{ path: `/${slug}/admin/links`, icon: Link2, label: 'Links' },
|
||||
{ path: `/${slug}/admin/social`, icon: Share2, label: 'Social Media' },
|
||||
{ path: `/${slug}/admin/banners`, icon: Layers, label: 'Banners' },
|
||||
{ path: `/${slug}/admin/reviews`, icon: Star, label: 'Reviews' },
|
||||
{ path: `/${slug}/admin/theme`, icon: Palette, label: 'Theme' },
|
||||
{ path: `/${slug}/admin/gallery`, icon: Image, label: 'Gallery' },
|
||||
{ path: `/${slug}/admin/apps`, icon: Smartphone, label: 'Apps' },
|
||||
{ path: `/${slug}/admin/qr-code`, icon: QrCode, label: 'QR Code' },
|
||||
]
|
||||
|
||||
const handleLogout = () => {
|
||||
if (onLogout) {
|
||||
onLogout()
|
||||
navigate('/admin/login')
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewLivePage = () => {
|
||||
window.open(`/${slug}`, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Mobile header */}
|
||||
@@ -82,7 +87,7 @@ export default function AdminLayout({ clientData, onLogout }) {
|
||||
<h2 className="font-bold text-sm truncate max-w-[140px]">
|
||||
{clientData?.businessName || 'ArkLinks'}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Admin Panel</p>
|
||||
<p className="text-xs text-gray-500">/{slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -116,7 +121,7 @@ export default function AdminLayout({ clientData, onLogout }) {
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t space-y-2">
|
||||
<button
|
||||
onClick={() => window.open('/', '_blank')}
|
||||
onClick={handleViewLivePage}
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
|
||||
@@ -364,13 +364,15 @@ export default function Home({ clientData }) {
|
||||
<div className={`flex justify-center mb-6 ${socialIconSpacing}`}>
|
||||
{socialLinks.map((social) => {
|
||||
const Icon = SOCIAL_ICONS[social.platform] || Globe
|
||||
const isCustomWithIcon = social.platform === 'custom' && social.customIconFileId
|
||||
|
||||
return (
|
||||
<a
|
||||
key={social.$id}
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center transition-all ${socialIconShape} ${getShadowClass()} ${getAnimationClass()}`}
|
||||
className={`flex items-center justify-center transition-all ${socialIconShape} ${getShadowClass()} ${getAnimationClass()} overflow-hidden`}
|
||||
style={{
|
||||
width: socialIconSize.size,
|
||||
height: socialIconSize.size,
|
||||
@@ -380,8 +382,17 @@ export default function Home({ clientData }) {
|
||||
borderStyle: 'solid',
|
||||
color: socialStyle === 'minimal' ? socialIconBgColor : socialIconColor
|
||||
}}
|
||||
title={social.customName || social.platform}
|
||||
>
|
||||
<Icon size={socialIconSize.iconSize} />
|
||||
{isCustomWithIcon ? (
|
||||
<img
|
||||
src={getFilePreview(social.customIconFileId, 100, 100)}
|
||||
alt={social.customName || 'Custom'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Icon size={socialIconSize.iconSize} />
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
|
||||
405
src/pages/Landing.jsx
Normal file
405
src/pages/Landing.jsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Link2,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Palette,
|
||||
QrCode,
|
||||
Globe,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Star,
|
||||
ChevronRight
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function Landing() {
|
||||
const features = [
|
||||
{
|
||||
icon: Link2,
|
||||
title: 'Custom Links',
|
||||
description: 'Create unlimited custom links to share with your audience'
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: 'Beautiful Themes',
|
||||
description: 'Customize colors, fonts, and styles to match your brand'
|
||||
},
|
||||
{
|
||||
icon: QrCode,
|
||||
title: 'QR Codes',
|
||||
description: 'Generate QR codes for easy offline sharing'
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Social Integration',
|
||||
description: 'Connect all your social media profiles in one place'
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Analytics',
|
||||
description: 'Track visits and engagement with detailed insights'
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Secure & Fast',
|
||||
description: 'Enterprise-grade security with lightning-fast load times'
|
||||
}
|
||||
]
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '$0',
|
||||
period: 'forever',
|
||||
features: [
|
||||
'Unlimited links',
|
||||
'Custom themes',
|
||||
'QR code generation',
|
||||
'Social media links',
|
||||
'Mobile optimized'
|
||||
],
|
||||
cta: 'Get Started Free',
|
||||
popular: false
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '$9',
|
||||
period: '/month',
|
||||
features: [
|
||||
'Everything in Free',
|
||||
'Advanced analytics',
|
||||
'Custom domain',
|
||||
'Priority support',
|
||||
'Remove branding'
|
||||
],
|
||||
cta: 'Start Pro Trial',
|
||||
popular: true
|
||||
},
|
||||
{
|
||||
name: 'Business',
|
||||
price: '$29',
|
||||
period: '/month',
|
||||
features: [
|
||||
'Everything in Pro',
|
||||
'Team collaboration',
|
||||
'API access',
|
||||
'White-label solution',
|
||||
'Dedicated support'
|
||||
],
|
||||
cta: 'Contact Sales',
|
||||
popular: false
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<Link2 className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
ArkLinks
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<a href="#features" className="text-gray-600 hover:text-gray-900 transition-colors">Features</a>
|
||||
<a href="#pricing" className="text-gray-600 hover:text-gray-900 transition-colors">Pricing</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors font-medium"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
to="/signup"
|
||||
className="px-5 py-2.5 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-full font-medium hover:shadow-lg hover:shadow-blue-500/25 transition-all duration-300"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-32 pb-20 px-4 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 rounded-full text-blue-600 text-sm font-medium mb-8">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>The #1 Link-in-Bio Platform</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 leading-tight mb-6">
|
||||
One Link to
|
||||
<span className="relative">
|
||||
<span className="bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent"> Rule Them All</span>
|
||||
<svg className="absolute -bottom-2 left-0 w-full" viewBox="0 0 300 12" fill="none">
|
||||
<path d="M2 10C50 4 150 2 298 6" stroke="url(#gradient)" strokeWidth="4" strokeLinecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#2563EB" />
|
||||
<stop offset="50%" stopColor="#9333EA" />
|
||||
<stop offset="100%" stopColor="#EC4899" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<p className="text-xl text-gray-600 mb-10 max-w-2xl mx-auto leading-relaxed">
|
||||
Create a beautiful, customizable page that houses all your important links.
|
||||
Share your brand, content, and products with the world in seconds.
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
|
||||
<Link
|
||||
to="/signup"
|
||||
className="group px-8 py-4 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-full font-semibold text-lg hover:shadow-2xl hover:shadow-blue-500/30 transition-all duration-300 flex items-center gap-2"
|
||||
>
|
||||
Create Your Page
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<a
|
||||
href="#features"
|
||||
className="px-8 py-4 text-gray-700 font-semibold text-lg hover:text-gray-900 transition-colors flex items-center gap-2"
|
||||
>
|
||||
See Features
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Social Proof */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center -space-x-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 border-4 border-white flex items-center justify-center text-gray-500 font-semibold text-sm"
|
||||
>
|
||||
{String.fromCharCode(64 + i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star key={i} className="w-5 h-5 text-yellow-400 fill-yellow-400" />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-gray-600">
|
||||
<span className="font-semibold text-gray-900">4.9/5</span> from 10,000+ users
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Image/Preview */}
|
||||
<div className="mt-20 relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent z-10 pointer-events-none" />
|
||||
<div className="relative mx-auto max-w-5xl">
|
||||
{/* Browser Mockup */}
|
||||
<div className="bg-gray-900 rounded-2xl p-1.5 shadow-2xl shadow-gray-900/20">
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
{/* Browser Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-gray-900/50">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<div className="flex-1 mx-4">
|
||||
<div className="bg-gray-700 rounded-lg px-4 py-1.5 text-gray-400 text-sm text-center">
|
||||
links.arkylx.com/yourbrand
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview Content */}
|
||||
<div className="bg-gradient-to-br from-blue-600 to-purple-700 p-8 min-h-[400px] flex items-center justify-center">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-3xl p-8 w-full max-w-sm text-center">
|
||||
<div className="w-24 h-24 bg-white rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||
<span className="text-3xl font-bold text-gray-900">YB</span>
|
||||
</div>
|
||||
<h3 className="text-white text-xl font-bold mb-2">Your Brand</h3>
|
||||
<p className="text-white/80 text-sm mb-6">Your awesome bio goes here</p>
|
||||
<div className="space-y-3">
|
||||
{['Website', 'Shop', 'YouTube', 'Instagram'].map((link) => (
|
||||
<div
|
||||
key={link}
|
||||
className="bg-white rounded-xl py-3 px-4 text-gray-900 font-medium hover:scale-105 transition-transform cursor-pointer"
|
||||
>
|
||||
{link}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-24 px-4 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-purple-50 rounded-full text-purple-600 text-sm font-medium mb-4">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>Powerful Features</span>
|
||||
</div>
|
||||
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-4">
|
||||
Everything You Need
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Build your perfect link page with our comprehensive set of tools
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group bg-white p-8 rounded-2xl border border-gray-100 hover:border-blue-200 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300"
|
||||
>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
<feature.icon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">{feature.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section id="pricing" className="py-24 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-50 rounded-full text-green-600 text-sm font-medium mb-4">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Simple Pricing</span>
|
||||
</div>
|
||||
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-4">
|
||||
Choose Your Plan
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Start free and scale as you grow. No hidden fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{plans.map((plan, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`relative bg-white rounded-3xl p-8 ${
|
||||
plan.popular
|
||||
? 'border-2 border-blue-500 shadow-2xl shadow-blue-500/20 scale-105'
|
||||
: 'border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1.5 bg-gradient-to-r from-blue-600 to-purple-600 text-white text-sm font-medium rounded-full">
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">{plan.name}</h3>
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className="text-5xl font-bold text-gray-900">{plan.price}</span>
|
||||
<span className="text-gray-500">{plan.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8">
|
||||
{plan.features.map((feature, i) => (
|
||||
<li key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
</div>
|
||||
<span className="text-gray-600">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
to="/signup"
|
||||
className={`block w-full py-3.5 rounded-xl font-semibold text-center transition-all duration-300 ${
|
||||
plan.popular
|
||||
? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:shadow-lg hover:shadow-blue-500/25'
|
||||
: 'bg-gray-100 text-gray-900 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-24 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 rounded-3xl p-12 text-center relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-0 left-0 w-72 h-72 bg-white rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-white rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-4xl sm:text-5xl font-bold text-white mb-6">
|
||||
Ready to Get Started?
|
||||
</h2>
|
||||
<p className="text-xl text-white/90 mb-8 max-w-2xl mx-auto">
|
||||
Join thousands of creators and businesses who trust ArkLinks to power their online presence.
|
||||
</p>
|
||||
<Link
|
||||
to="/signup"
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-white text-gray-900 rounded-full font-semibold text-lg hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
Create Your Free Page
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-4 border-t border-gray-100">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<Link2 className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">ArkLinks</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 text-gray-600">
|
||||
<a href="#features" className="hover:text-gray-900 transition-colors">Features</a>
|
||||
<a href="#pricing" className="hover:text-gray-900 transition-colors">Pricing</a>
|
||||
<Link to="/login" className="hover:text-gray-900 transition-colors">Login</Link>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} ArkLinks. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
src/pages/Login.jsx
Normal file
176
src/pages/Login.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Link2, ArrowRight, Lock, AtSign, Eye, EyeOff } from 'lucide-react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [formData, setFormData] = useState({
|
||||
identifier: '', // Can be slug or email
|
||||
password: ''
|
||||
})
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Try to find user by slug or email
|
||||
let client = null
|
||||
const identifier = formData.identifier.toLowerCase().trim()
|
||||
|
||||
// First try by slug
|
||||
const slugResponse = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CLIENTS,
|
||||
[Query.equal('slug', identifier)]
|
||||
)
|
||||
|
||||
if (slugResponse.documents.length > 0) {
|
||||
client = slugResponse.documents[0]
|
||||
} else {
|
||||
// Try by email
|
||||
const emailResponse = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CLIENTS,
|
||||
[Query.equal('email', identifier)]
|
||||
)
|
||||
if (emailResponse.documents.length > 0) {
|
||||
client = emailResponse.documents[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
setError('Account not found. Check your username or email.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check password
|
||||
if (formData.password !== client.adminPassword) {
|
||||
setError('Invalid password')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Store auth state with client-specific key
|
||||
sessionStorage.setItem(`arklinks_auth_${client.slug}`, 'true')
|
||||
sessionStorage.setItem(`arklinks_auth_time_${client.slug}`, Date.now().toString())
|
||||
|
||||
// Redirect to admin
|
||||
navigate(`/${client.slug}/admin`)
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center p-6">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-blue-500 rounded-full blur-3xl opacity-20" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-purple-500 rounded-full blur-3xl opacity-20" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-white/10 backdrop-blur-sm rounded-2xl flex items-center justify-center border border-white/20">
|
||||
<Link2 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">ArkLinks</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Welcome back</h2>
|
||||
<p className="text-gray-500">Log in to manage your link page</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-100 rounded-xl text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Identifier */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Username or Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<AtSign className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.identifier}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, identifier: e.target.value }))}
|
||||
className="w-full pl-12 pr-4 py-3.5 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-gray-50 focus:bg-white"
|
||||
placeholder="yourbrand or you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
className="w-full pl-12 pr-12 py-3.5 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-gray-50 focus:bg-white"
|
||||
placeholder="Your password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl font-semibold hover:shadow-lg hover:shadow-blue-500/25 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Log In
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm mt-6">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/signup" className="text-blue-600 hover:text-blue-700 font-medium">
|
||||
Sign up free
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
429
src/pages/Signup.jsx
Normal file
429
src/pages/Signup.jsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Link2,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Check,
|
||||
Sparkles,
|
||||
Building2,
|
||||
AtSign,
|
||||
Lock,
|
||||
User
|
||||
} from 'lucide-react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query } from '../lib/appwrite'
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [slugAvailable, setSlugAvailable] = useState(null)
|
||||
const [checkingSlug, setCheckingSlug] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
businessName: '',
|
||||
slug: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// Generate slug from business name
|
||||
const generateSlug = (name) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50)
|
||||
}
|
||||
|
||||
// Check if slug is available
|
||||
const checkSlugAvailability = async (slug) => {
|
||||
if (!slug || slug.length < 3) {
|
||||
setSlugAvailable(null)
|
||||
return
|
||||
}
|
||||
|
||||
setCheckingSlug(true)
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CLIENTS,
|
||||
[Query.equal('slug', slug)]
|
||||
)
|
||||
setSlugAvailable(response.documents.length === 0)
|
||||
} catch (error) {
|
||||
console.error('Slug check error:', error)
|
||||
setSlugAvailable(null)
|
||||
} finally {
|
||||
setCheckingSlug(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle business name change - auto-generate slug
|
||||
const handleBusinessNameChange = (e) => {
|
||||
const name = e.target.value
|
||||
const slug = generateSlug(name)
|
||||
setFormData(prev => ({ ...prev, businessName: name, slug }))
|
||||
checkSlugAvailability(slug)
|
||||
}
|
||||
|
||||
// Handle slug change
|
||||
const handleSlugChange = (e) => {
|
||||
const slug = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')
|
||||
setFormData(prev => ({ ...prev, slug }))
|
||||
checkSlugAvailability(slug)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
// Validation
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
if (!slugAvailable) {
|
||||
setError('Please choose a different URL slug')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.slug.length < 3) {
|
||||
setError('URL slug must be at least 3 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Create client record
|
||||
const clientId = generateId()
|
||||
await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CLIENTS,
|
||||
clientId,
|
||||
{
|
||||
businessName: formData.businessName,
|
||||
slug: formData.slug,
|
||||
email: formData.email || null,
|
||||
adminPassword: formData.password,
|
||||
isSetupComplete: true,
|
||||
description: null,
|
||||
phone: null,
|
||||
location: null,
|
||||
logoFileId: null
|
||||
}
|
||||
)
|
||||
|
||||
// Create default theme
|
||||
await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.THEMES,
|
||||
generateId(),
|
||||
{
|
||||
clientId: clientId,
|
||||
primaryColor: '#3B82F6',
|
||||
secondaryColor: '#1D4ED8',
|
||||
backgroundColor: '#F3F4F6',
|
||||
textColor: '#111827',
|
||||
buttonStyle: 'rounded',
|
||||
buttonColor: '#3B82F6',
|
||||
fontFamily: 'Inter',
|
||||
backgroundType: 'solid'
|
||||
}
|
||||
)
|
||||
|
||||
// Redirect to the new user's admin panel
|
||||
navigate(`/${formData.slug}/admin`)
|
||||
} catch (error) {
|
||||
console.error('Signup error:', error)
|
||||
if (error.message?.includes('slug')) {
|
||||
setError('This URL is already taken. Please choose another.')
|
||||
} else {
|
||||
setError('Something went wrong. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passwordStrength = () => {
|
||||
const password = formData.password
|
||||
if (!password) return { strength: 0, label: '', color: '' }
|
||||
|
||||
let strength = 0
|
||||
if (password.length >= 6) strength++
|
||||
if (password.length >= 8) strength++
|
||||
if (/[A-Z]/.test(password)) strength++
|
||||
if (/[0-9]/.test(password)) strength++
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++
|
||||
|
||||
const labels = ['', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong']
|
||||
const colors = ['', 'bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-emerald-500']
|
||||
|
||||
return { strength, label: labels[strength], color: colors[strength] }
|
||||
}
|
||||
|
||||
const { strength, label, color } = passwordStrength()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex">
|
||||
{/* Left Side - Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 flex-col justify-between p-12 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-blue-500 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-purple-500 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-white/10 backdrop-blur-sm rounded-2xl flex items-center justify-center border border-white/20">
|
||||
<Link2 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">ArkLinks</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Sparkles className="w-5 h-5 text-yellow-400" />
|
||||
<span className="text-yellow-400 font-medium">Join 10,000+ creators</span>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-white leading-tight mb-6">
|
||||
Create your link page<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-400">
|
||||
in seconds
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 leading-relaxed">
|
||||
Share everything you create, curate and sell from a single link in bio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex -space-x-3">
|
||||
{['A', 'B', 'C', 'D'].map((letter, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-700 border-2 border-slate-900 flex items-center justify-center text-white text-sm font-medium"
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
<span className="text-white font-semibold">1,000+</span> signed up this week
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Form */}
|
||||
<div className="flex-1 flex items-center justify-center p-6 lg:p-12">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile Logo */}
|
||||
<div className="lg:hidden flex items-center justify-center gap-3 mb-8">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-white/10 backdrop-blur-sm rounded-2xl flex items-center justify-center border border-white/20">
|
||||
<Link2 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">ArkLinks</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Create your account</h2>
|
||||
<p className="text-gray-500">Start building your link page today</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-100 rounded-xl text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Business Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Business / Brand Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.businessName}
|
||||
onChange={handleBusinessNameChange}
|
||||
className="w-full pl-12 pr-4 py-3.5 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-gray-50 focus:bg-white"
|
||||
placeholder="Your Brand Name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slug / URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Your URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm">
|
||||
links.arkylx.com/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={handleSlugChange}
|
||||
className={`w-full pl-[145px] pr-12 py-3.5 border rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-gray-50 focus:bg-white ${
|
||||
slugAvailable === false ? 'border-red-300' : slugAvailable === true ? 'border-green-300' : 'border-gray-200'
|
||||
}`}
|
||||
placeholder="yourbrand"
|
||||
/>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
{checkingSlug ? (
|
||||
<div className="w-5 h-5 border-2 border-gray-300 border-t-transparent rounded-full animate-spin" />
|
||||
) : slugAvailable === true ? (
|
||||
<Check className="w-5 h-5 text-green-500" />
|
||||
) : slugAvailable === false ? (
|
||||
<span className="text-red-500 text-xs font-medium">Taken</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{formData.slug && slugAvailable === true && (
|
||||
<p className="mt-1.5 text-sm text-green-600 flex items-center gap-1">
|
||||
<Check className="w-4 h-4" />
|
||||
This URL is available!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<AtSign className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full pl-12 pr-4 py-3.5 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-gray-50 focus:bg-white"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
className="w-full pl-12 pr-12 py-3.5 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-gray-50 focus:bg-white"
|
||||
placeholder="Create a password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{formData.password && (
|
||||
<div className="mt-2">
|
||||
<div className="flex gap-1 mb-1">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full ${
|
||||
i <= strength ? color : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
className={`w-full pl-12 pr-12 py-3.5 border rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-gray-50 focus:bg-white ${
|
||||
formData.confirmPassword && formData.password !== formData.confirmPassword
|
||||
? 'border-red-300'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
{formData.confirmPassword && formData.password === formData.confirmPassword && (
|
||||
<Check className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !slugAvailable}
|
||||
className="w-full py-4 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl font-semibold hover:shadow-lg hover:shadow-blue-500/25 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Create My Page
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm mt-6">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-blue-600 hover:text-blue-700 font-medium">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-400 text-xs mt-6">
|
||||
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Download, Copy, Check } from 'lucide-react'
|
||||
import { getFilePreview } from '../../lib/appwrite'
|
||||
|
||||
export default function QRCode({ clientData }) {
|
||||
export default function QRCode({ clientData, slug }) {
|
||||
const qrRef = useRef(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [settings, setSettings] = useState({
|
||||
@@ -14,7 +14,7 @@ export default function QRCode({ clientData }) {
|
||||
includeLogo: true
|
||||
})
|
||||
|
||||
const pageUrl = window.location.origin
|
||||
const pageUrl = `${window.location.origin}/${slug}`
|
||||
|
||||
const downloadQR = () => {
|
||||
const svg = qrRef.current?.querySelector('svg')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Edit2, Trash2, GripVertical } from 'lucide-react'
|
||||
import { Plus, Edit2, Trash2, GripVertical, Upload, X } from 'lucide-react'
|
||||
import {
|
||||
Facebook, Twitter, Instagram, Linkedin, Youtube, Github,
|
||||
MessageCircle, Send, Music2, Globe
|
||||
MessageCircle, Send, Music2, Globe, Sparkles
|
||||
} from 'lucide-react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query } from '../../lib/appwrite'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query, uploadFile, deleteFile, getFilePreview } from '../../lib/appwrite'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'facebook', name: 'Facebook', icon: Facebook, color: '#1877F2' },
|
||||
@@ -17,17 +17,23 @@ const PLATFORMS = [
|
||||
{ id: 'whatsapp', name: 'WhatsApp', icon: MessageCircle, color: '#25D366' },
|
||||
{ id: 'telegram', name: 'Telegram', icon: Send, color: '#26A5E4' },
|
||||
{ id: 'website', name: 'Website', icon: Globe, color: '#4B5563' },
|
||||
{ id: 'custom', name: 'Custom', icon: Sparkles, color: '#8B5CF6' },
|
||||
]
|
||||
|
||||
export default function Social({ clientData }) {
|
||||
const [socialLinks, setSocialLinks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingLink, setEditingLink] = useState(null)
|
||||
const [iconPreview, setIconPreview] = useState(null)
|
||||
const [formData, setFormData] = useState({
|
||||
platform: 'facebook',
|
||||
url: '',
|
||||
isEnabled: true
|
||||
isEnabled: true,
|
||||
customName: '',
|
||||
customIconFile: null,
|
||||
customIconFileId: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,35 +66,87 @@ export default function Social({ clientData }) {
|
||||
setFormData({
|
||||
platform: link.platform,
|
||||
url: link.url,
|
||||
isEnabled: link.isEnabled ?? true
|
||||
isEnabled: link.isEnabled ?? true,
|
||||
customName: link.customName || '',
|
||||
customIconFile: null,
|
||||
customIconFileId: link.customIconFileId || ''
|
||||
})
|
||||
if (link.customIconFileId) {
|
||||
setIconPreview(getFilePreview(link.customIconFileId, 100, 100))
|
||||
} else {
|
||||
setIconPreview(null)
|
||||
}
|
||||
} else {
|
||||
setEditingLink(null)
|
||||
setFormData({
|
||||
platform: 'facebook',
|
||||
url: '',
|
||||
isEnabled: true
|
||||
isEnabled: true,
|
||||
customName: '',
|
||||
customIconFile: null,
|
||||
customIconFileId: ''
|
||||
})
|
||||
setIconPreview(null)
|
||||
}
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleIconChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
setFormData(prev => ({ ...prev, customIconFile: file }))
|
||||
setIconPreview(URL.createObjectURL(file))
|
||||
}
|
||||
}
|
||||
|
||||
const removeIcon = () => {
|
||||
setFormData(prev => ({ ...prev, customIconFile: null, customIconFileId: '' }))
|
||||
setIconPreview(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
let customIconFileId = formData.customIconFileId
|
||||
|
||||
// Upload new custom icon if provided
|
||||
if (formData.customIconFile) {
|
||||
// Delete old icon if exists
|
||||
if (editingLink?.customIconFileId) {
|
||||
try {
|
||||
await deleteFile(editingLink.customIconFileId)
|
||||
} catch (err) {
|
||||
console.log('Could not delete old icon')
|
||||
}
|
||||
}
|
||||
customIconFileId = await uploadFile(formData.customIconFile)
|
||||
} else if (!formData.customIconFileId && editingLink?.customIconFileId) {
|
||||
// Icon was removed
|
||||
try {
|
||||
await deleteFile(editingLink.customIconFileId)
|
||||
} catch (err) {
|
||||
console.log('Could not delete old icon')
|
||||
}
|
||||
customIconFileId = null
|
||||
}
|
||||
|
||||
const data = {
|
||||
platform: formData.platform,
|
||||
url: formData.url,
|
||||
isEnabled: formData.isEnabled,
|
||||
customName: formData.platform === 'custom' ? formData.customName : null,
|
||||
customIconFileId: formData.platform === 'custom' ? customIconFileId : null
|
||||
}
|
||||
|
||||
if (editingLink) {
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, editingLink.$id, {
|
||||
platform: formData.platform,
|
||||
url: formData.url,
|
||||
isEnabled: formData.isEnabled
|
||||
})
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, editingLink.$id, data)
|
||||
} else {
|
||||
await databases.createDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, generateId(), {
|
||||
...data,
|
||||
clientId: clientData.$id,
|
||||
platform: formData.platform,
|
||||
url: formData.url,
|
||||
order: socialLinks.length,
|
||||
isEnabled: formData.isEnabled
|
||||
order: socialLinks.length
|
||||
})
|
||||
}
|
||||
setShowModal(false)
|
||||
@@ -96,13 +154,23 @@ export default function Social({ clientData }) {
|
||||
} catch (error) {
|
||||
console.error('Error saving social link:', error)
|
||||
alert('Error saving social link')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteLink = async (linkId) => {
|
||||
const deleteLink = async (link) => {
|
||||
if (!confirm('Are you sure you want to delete this social link?')) return
|
||||
try {
|
||||
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, linkId)
|
||||
// Delete custom icon if exists
|
||||
if (link.customIconFileId) {
|
||||
try {
|
||||
await deleteFile(link.customIconFileId)
|
||||
} catch (err) {
|
||||
console.log('Could not delete icon')
|
||||
}
|
||||
}
|
||||
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, link.$id)
|
||||
loadSocialLinks()
|
||||
} catch (error) {
|
||||
console.error('Error deleting social link:', error)
|
||||
@@ -161,6 +229,8 @@ export default function Social({ clientData }) {
|
||||
{socialLinks.map((link) => {
|
||||
const platform = getPlatformInfo(link.platform)
|
||||
const Icon = platform.icon
|
||||
const isCustom = link.platform === 'custom'
|
||||
const displayName = isCustom && link.customName ? link.customName : platform.name
|
||||
|
||||
return (
|
||||
<div key={link.$id} className="p-4 flex items-center gap-4">
|
||||
@@ -168,15 +238,26 @@ export default function Social({ clientData }) {
|
||||
<GripVertical size={20} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: platform.color }}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
{isCustom && link.customIconFileId ? (
|
||||
<img
|
||||
src={getFilePreview(link.customIconFileId, 80, 80)}
|
||||
alt={displayName}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: platform.color }}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900">{platform.name}</h3>
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{displayName}
|
||||
{isCustom && <span className="ml-2 text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded-full">Custom</span>}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate">{link.url}</p>
|
||||
</div>
|
||||
|
||||
@@ -200,7 +281,7 @@ export default function Social({ clientData }) {
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => deleteLink(link.$id)}
|
||||
onClick={() => deleteLink(link)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg text-red-500"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
@@ -215,7 +296,7 @@ export default function Social({ clientData }) {
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl w-full max-w-md p-6">
|
||||
<div className="bg-white rounded-xl w-full max-w-md p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
{editingLink ? 'Edit Social Link' : 'Add Social Link'}
|
||||
</h2>
|
||||
@@ -223,28 +304,87 @@ export default function Social({ clientData }) {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Platform</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{PLATFORMS.map((platform) => {
|
||||
const Icon = platform.icon
|
||||
const isCustom = platform.id === 'custom'
|
||||
return (
|
||||
<button
|
||||
key={platform.id}
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, platform: platform.id }))}
|
||||
className={`p-3 rounded-lg border-2 transition-colors ${
|
||||
className={`p-3 rounded-lg border-2 transition-colors flex flex-col items-center gap-1 ${
|
||||
formData.platform === platform.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
} ${isCustom ? 'col-span-4 flex-row justify-center' : ''}`}
|
||||
title={platform.name}
|
||||
>
|
||||
<Icon size={20} className="mx-auto" style={{ color: platform.color }} />
|
||||
<Icon size={20} style={{ color: platform.color }} />
|
||||
{isCustom && <span className="text-xs text-gray-600 ml-2">Custom Icon</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom platform options */}
|
||||
{formData.platform === 'custom' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Custom Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.customName}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, customName: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="e.g., Discord, Threads, Snapchat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Custom Icon
|
||||
</label>
|
||||
{iconPreview ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={iconPreview}
|
||||
alt="Custom icon preview"
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600 mb-2">Icon uploaded</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeIcon}
|
||||
className="text-sm text-red-600 hover:text-red-700 flex items-center gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
Remove icon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-blue-500 hover:bg-blue-50/50 transition-colors">
|
||||
<Upload className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500">Click to upload icon</span>
|
||||
<span className="text-xs text-gray-400 mt-1">PNG, JPG, SVG (max 2MB)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleIconChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">URL *</label>
|
||||
<input
|
||||
@@ -253,7 +393,7 @@ export default function Social({ clientData }) {
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, url: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="https://facebook.com/yourpage"
|
||||
placeholder={formData.platform === 'custom' ? 'https://example.com/yourprofile' : `https://${formData.platform}.com/yourpage`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -277,9 +417,14 @@ export default function Social({ clientData }) {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
disabled={saving}
|
||||
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center"
|
||||
>
|
||||
{editingLink ? 'Update' : 'Add'}
|
||||
{saving ? (
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
editingLink ? 'Update' : 'Add'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user