first commit

This commit is contained in:
Optirx
2026-01-15 11:53:54 +02:00
commit b3cd2d8a11
34 changed files with 9054 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(dir /a)",
"Bash(npm create:*)",
"mcp__appwrite-mcp-server__manage_database",
"Bash(npm init:*)",
"Bash(npx --yes create-vite@latest . --template react)",
"mcp__appwrite-mcp-server__bucket_operations",
"Bash(npm run build:*)",
"mcp__appwrite-mcp-server__attribute_operations",
"mcp__appwrite-mcp-server__collection_operations"
]
}
}

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.git
.gitignore
README.md
.env
.env.*
!.env.example
*.log
.DS_Store
.claude

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies
node_modules
# Build
dist
dist-ssr
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
# Editor
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Debug
*.local

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# Build stage
FROM node:20-alpine as build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build args for environment variables
ARG VITE_APPWRITE_ENDPOINT
ARG VITE_APPWRITE_PROJECT_ID
# Set environment variables for build
ENV VITE_APPWRITE_ENDPOINT=$VITE_APPWRITE_ENDPOINT
ENV VITE_APPWRITE_PROJECT_ID=$VITE_APPWRITE_PROJECT_ID
# Build the app
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

125
README.md Normal file
View File

@@ -0,0 +1,125 @@
# ArkLinks
A modern, customizable link-in-bio solution built with React and Appwrite.
## Features
- **Setup Wizard** - Easy first-time configuration
- **Link Management** - Add, edit, reorder your links
- **Social Media** - Connect all your social profiles
- **Theme Customization** - Colors, fonts, button styles
- **Gallery** - Showcase your past works
- **App Downloads** - iOS/Android app links with Coming Soon badge
- **QR Code Generator** - Custom QR codes with your logo
- **Coming Soon** - Mark links/apps as coming soon
- **Mobile Responsive** - Looks great on all devices
## Quick Start
### 1. Setup Appwrite
Create a project on [Appwrite Cloud](https://cloud.appwrite.io) or self-hosted instance.
The database, collections, and bucket are already created. Note your:
- Endpoint URL (e.g., `https://cloud.appwrite.io/v1`)
- Project ID
### 2. Configure Environment
```bash
cp .env.example .env
```
Edit `.env`:
```
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id
```
### 3. Run Locally
```bash
npm install
npm run dev
```
Open http://localhost:3000
### 4. Deploy with Docker
```bash
# Set environment variables
export VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
export VITE_APPWRITE_PROJECT_ID=your-project-id
# Build and run
docker-compose up -d --build
```
## Project Structure
```
src/
├── components/
│ └── AdminLayout.jsx # Admin sidebar layout
├── lib/
│ └── appwrite.js # Appwrite SDK configuration
├── pages/
│ ├── Home.jsx # Public profile page
│ ├── ComingSoon.jsx # Coming soon page
│ └── admin/
│ ├── Setup.jsx # First-time setup wizard
│ ├── Dashboard.jsx # Admin dashboard
│ ├── Links.jsx # Manage links
│ ├── Social.jsx # Social media links
│ ├── Theme.jsx # Theme customization
│ ├── Gallery.jsx # Gallery management
│ ├── Apps.jsx # App download links
│ └── QRCode.jsx # QR code generator
├── App.jsx # Main router
├── main.jsx # Entry point
└── index.css # Tailwind CSS
```
## Appwrite Database Structure
**Database:** `arklinks`
| Collection | Purpose |
|------------|---------|
| `clients` | Business profile info |
| `links` | Custom links |
| `socialLinks` | Social media profiles |
| `themes` | Color and style settings |
| `gallery` | Portfolio images |
| `apps` | App download links |
**Bucket:** `arklinks-media` - Stores logos and gallery images
## Deployment for Each Client
1. Clone the repo
2. Set environment variables for Appwrite
3. Deploy with Docker:
```bash
docker-compose up -d --build
```
4. Visit the URL - the setup wizard will guide through configuration
5. Each deployment creates its own records in the central Appwrite database
## Tech Stack
- **React 18** - UI framework
- **Vite** - Build tool
- **Tailwind CSS** - Styling
- **React Router** - Navigation
- **Appwrite** - Database & Storage
- **qrcode.react** - QR code generation
- **Lucide React** - Icons
- **Docker + Nginx** - Production deployment
## License
MIT

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3.8'
services:
arklinks:
build:
context: .
args:
- VITE_APPWRITE_ENDPOINT=${VITE_APPWRITE_ENDPOINT}
- VITE_APPWRITE_PROJECT_ID=${VITE_APPWRITE_PROJECT_ID}
ports:
- "${PORT:-3000}:80"
restart: unless-stopped
environment:
- NODE_ENV=production

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ArkLinks</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=Lato:wght@400;700&family=Lora:wght@400;500;600;700&family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&family=Open+Sans:wght@400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Poppins:wght@400;500;600;700&family=Raleway:wght@400;500;600;700&family=Roboto:wght@400;500;700&family=Source+Code+Pro:wght@400;500;600;700&family=Dancing+Script:wght@400;500;600;700&family=Pacifico&family=Bebas+Neue&family=Oswald:wght@400;500;600;700&family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

21
nginx.conf Normal file
View File

@@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Handle SPA routing - redirect all requests to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

2811
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "arklinks",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"appwrite": "^14.0.0",
"qrcode.react": "^3.1.0",
"lucide-react": "^0.294.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"vite": "^5.0.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#3B82F6"/>
<path d="M30 35h40M30 50h40M30 65h40" stroke="white" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

160
src/App.jsx Normal file
View File

@@ -0,0 +1,160 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, getFilePreview } from './lib/appwrite'
// Pages
import Home from './pages/Home'
import ComingSoon from './pages/ComingSoon'
import Setup from './pages/admin/Setup'
import Login from './pages/admin/Login'
import Dashboard from './pages/admin/Dashboard'
import Links from './pages/admin/Links'
import Social from './pages/admin/Social'
import Theme from './pages/admin/Theme'
import Gallery from './pages/admin/Gallery'
import Apps from './pages/admin/Apps'
import QRCode from './pages/admin/QRCode'
import Profile from './pages/admin/Profile'
import Reviews from './pages/admin/Reviews'
import Banners from './pages/admin/Banners'
import AdminLayout from './components/AdminLayout'
// Session timeout: 24 hours
const SESSION_TIMEOUT = 24 * 60 * 60 * 1000
function App() {
const [isSetupComplete, setIsSetupComplete] = useState(null)
const [clientData, setClientData] = useState(null)
const [loading, setLoading] = useState(true)
const [isAuthenticated, setIsAuthenticated] = useState(false)
useEffect(() => {
checkSetup()
checkAuth()
}, [])
// 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) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = faviconUrl
}
}, [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 () => {
try {
// Check if client exists by looking for any client record
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.CLIENTS)
if (response.documents.length > 0) {
const client = response.documents[0]
setClientData(client)
setIsSetupComplete(client.isSetupComplete)
} else {
setIsSetupComplete(false)
}
} catch (error) {
console.error('Setup check error:', error)
setIsSetupComplete(false)
} 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">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
// Check if admin password is set
const hasAdminPassword = clientData?.adminPassword && clientData.adminPassword.length > 0
const needsAuth = hasAdminPassword && !isAuthenticated
return (
<BrowserRouter>
<Routes>
{/* Public Routes */}
<Route path="/" element={
isSetupComplete ? <Home clientData={clientData} /> : <Navigate to="/admin/setup" />
} />
<Route path="/coming-soon" element={<ComingSoon />} />
{/* Admin Login */}
<Route path="/admin/login" element={
isAuthenticated ? (
<Navigate to="/admin" />
) : (
<Login onLogin={handleLogin} clientData={clientData} />
)
} />
{/* 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>
</Routes>
</BrowserRouter>
)
}
export default App

View File

@@ -0,0 +1,145 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import {
LayoutDashboard,
Link2,
Share2,
Palette,
Image,
Smartphone,
QrCode,
ExternalLink,
Menu,
X,
User,
LogOut,
Star,
Layers
} from 'lucide-react'
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 }) {
const [sidebarOpen, setSidebarOpen] = useState(false)
const navigate = useNavigate()
const handleLogout = () => {
if (onLogout) {
onLogout()
navigate('/admin/login')
}
}
return (
<div className="min-h-screen bg-gray-100">
{/* Mobile header */}
<div className="lg:hidden bg-white shadow-sm p-4 flex items-center justify-between">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<Menu size={24} />
</button>
<h1 className="font-bold text-lg">ArkLinks Admin</h1>
<div className="w-10" />
</div>
{/* Sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={`
fixed top-0 left-0 z-50 h-full w-64 bg-white shadow-lg transform transition-transform duration-200
lg:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<div className="p-4 border-b flex items-center justify-between">
<div className="flex items-center gap-3">
{clientData?.logoFileId && (
<img
src={getFilePreview(clientData.logoFileId, 40, 40)}
alt="Logo"
className="w-10 h-10 rounded-full object-cover"
/>
)}
<div>
<h2 className="font-bold text-sm truncate max-w-[140px]">
{clientData?.businessName || 'ArkLinks'}
</h2>
<p className="text-xs text-gray-500">Admin Panel</p>
</div>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 hover:bg-gray-100 rounded"
>
<X size={20} />
</button>
</div>
<nav className="p-4 space-y-1">
{navItems.map(({ path, icon: Icon, label, end }) => (
<NavLink
key={path}
to={path}
end={end}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => `
flex items-center gap-3 px-4 py-3 rounded-lg transition-colors
${isActive
? 'bg-blue-50 text-blue-600 font-medium'
: 'text-gray-600 hover:bg-gray-50'
}
`}
>
<Icon size={20} />
{label}
</NavLink>
))}
</nav>
<div className="absolute bottom-0 left-0 right-0 p-4 border-t space-y-2">
<button
onClick={() => window.open('/', '_blank')}
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} />
View Live Page
</button>
{onLogout && (
<button
onClick={handleLogout}
className="flex items-center justify-center gap-2 w-full py-3 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
>
<LogOut size={18} />
Sign Out
</button>
)}
</div>
</aside>
{/* Main content */}
<main className="lg:ml-64 min-h-screen">
<div className="p-4 lg:p-8">
<Outlet />
</div>
</main>
</div>
)
}

14
src/index.css Normal file
View File

@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
}

69
src/lib/appwrite.js Normal file
View File

@@ -0,0 +1,69 @@
import { Client, Databases, Storage, ID, Query } from 'appwrite'
// Initialize Appwrite Client
const client = new Client()
// Configure with environment variables or defaults
const ENDPOINT = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1'
const PROJECT_ID = import.meta.env.VITE_APPWRITE_PROJECT_ID || ''
client
.setEndpoint(ENDPOINT)
.setProject(PROJECT_ID)
// Database and Storage instances
export const databases = new Databases(client)
export const storage = new Storage(client)
// Constants
export const DATABASE_ID = 'arklinks'
export const BUCKET_ID = 'arklinks-media'
export const COLLECTIONS = {
CLIENTS: 'clients',
LINKS: 'links',
SOCIAL_LINKS: 'socialLinks',
THEMES: 'themes',
GALLERY: 'gallery',
APPS: 'apps',
SECTIONS: 'sections',
REVIEWS: 'reviews',
BANNERS: 'banners'
}
// Helper functions
export const generateId = () => ID.unique()
// File URL helper
export const getFileUrl = (fileId) => {
if (!fileId) return null
return `${ENDPOINT}/storage/buckets/${BUCKET_ID}/files/${fileId}/view?project=${PROJECT_ID}`
}
export const getFilePreview = (fileId, width = 400, height = 400) => {
if (!fileId) return null
return `${ENDPOINT}/storage/buckets/${BUCKET_ID}/files/${fileId}/preview?project=${PROJECT_ID}&width=${width}&height=${height}`
}
// Upload file helper
export const uploadFile = async (file) => {
try {
const response = await storage.createFile(BUCKET_ID, ID.unique(), file)
return response.$id
} catch (error) {
console.error('Upload error:', error)
throw error
}
}
// Delete file helper
export const deleteFile = async (fileId) => {
try {
await storage.deleteFile(BUCKET_ID, fileId)
} catch (error) {
console.error('Delete error:', error)
}
}
export { Query, ID }
export default client

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

29
src/pages/ComingSoon.jsx Normal file
View File

@@ -0,0 +1,29 @@
import { useNavigate } from 'react-router-dom'
import { Clock, ArrowLeft } from 'lucide-react'
export default function ComingSoon() {
const navigate = useNavigate()
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 to-purple-700 flex items-center justify-center p-4">
<div className="text-center text-white">
<div className="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-6">
<Clock className="w-10 h-10" />
</div>
<h1 className="text-4xl font-bold mb-4">Coming Soon</h1>
<p className="text-lg opacity-80 mb-8 max-w-md">
We're working hard to bring you something amazing. Stay tuned!
</p>
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-blue-600 rounded-full font-medium hover:bg-gray-100 transition-colors"
>
<ArrowLeft size={20} />
Go Back
</button>
</div>
</div>
)
}

809
src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,809 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Facebook, Twitter, Instagram, Linkedin, Youtube, Github,
MessageCircle, Send, Music2, Globe, MapPin, Mail, Phone,
ExternalLink, Clock, ChevronDown, ChevronUp, Apple, Smartphone, Monitor,
Star, ChevronLeft, ChevronRight, Eye, User
} from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, Query, getFilePreview } from '../lib/appwrite'
const SOCIAL_ICONS = {
facebook: Facebook,
twitter: Twitter,
instagram: Instagram,
linkedin: Linkedin,
youtube: Youtube,
github: Github,
tiktok: Music2,
whatsapp: MessageCircle,
telegram: Send,
website: Globe
}
const PLATFORM_ICONS = {
ios: Apple,
android: Smartphone,
web: Monitor,
windows: Monitor,
macos: Apple
}
const BUTTON_STYLES = {
rounded: 'rounded-lg',
square: 'rounded-none',
pill: 'rounded-full'
}
const SHADOW_CLASSES = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg'
}
const BUTTON_ANIMATION_CLASSES = {
none: '',
scale: 'hover:scale-[1.02]',
glow: 'hover:shadow-lg hover:shadow-blue-500/50',
slide: 'hover:-translate-y-1'
}
const FONT_SIZE_CLASSES = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl'
}
const TITLE_SIZE_CLASSES = {
sm: 'text-lg',
md: 'text-xl',
lg: 'text-2xl',
xl: 'text-3xl',
'2xl': 'text-4xl'
}
const FONT_WEIGHT_CLASSES = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold'
}
const SECTION_SIZE_CLASSES = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
xl: 'text-lg'
}
const LOGO_SIZES = {
sm: 64,
md: 80,
lg: 96,
xl: 120,
'2xl': 160
}
const SOCIAL_ICON_SIZES = {
sm: { size: 32, iconSize: 16 },
md: { size: 40, iconSize: 20 },
lg: { size: 48, iconSize: 24 },
xl: { size: 56, iconSize: 28 }
}
const SOCIAL_ICON_SHAPES = {
circle: 'rounded-full',
rounded: 'rounded-lg',
square: 'rounded-none',
squircle: 'rounded-xl'
}
const SOCIAL_ICON_SPACING = {
tight: 'gap-1',
normal: 'gap-3',
relaxed: 'gap-4',
loose: 'gap-6'
}
export default function Home({ clientData }) {
const navigate = useNavigate()
const [theme, setTheme] = useState(null)
const [links, setLinks] = useState([])
const [sections, setSections] = useState([])
const [socialLinks, setSocialLinks] = useState([])
const [gallery, setGallery] = useState([])
const [apps, setApps] = useState([])
const [reviews, setReviews] = useState([])
const [banners, setBanners] = useState([])
const [loading, setLoading] = useState(true)
const [showGallery, setShowGallery] = useState(false)
const [currentBanner, setCurrentBanner] = useState(0)
const [visitCount, setVisitCount] = useState(0)
useEffect(() => {
if (clientData && clientData.$id) {
loadData()
}
}, [clientData])
// Banner auto-play effect
useEffect(() => {
if (!theme || banners.length <= 1) return
const autoPlay = theme.bannerAutoPlay !== false
const intervalTime = theme.bannerInterval || 5
if (!autoPlay) return
const timer = setInterval(() => {
setCurrentBanner((prev) => (prev + 1) % banners.length)
}, intervalTime * 1000)
return () => clearInterval(timer)
}, [theme, banners.length])
const loadData = async () => {
try {
// Load core data first
const [themeRes, linksRes, sectionsRes, socialRes, galleryRes, appsRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, COLLECTIONS.THEMES, [
Query.equal('clientId', clientData.$id)
]).catch(() => ({ documents: [] })),
databases.listDocuments(DATABASE_ID, COLLECTIONS.LINKS, [
Query.equal('clientId', clientData.$id),
Query.equal('isEnabled', true),
Query.orderAsc('order')
]).catch(() => ({ documents: [] })),
databases.listDocuments(DATABASE_ID, COLLECTIONS.SECTIONS, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
]).catch(() => ({ documents: [] })),
databases.listDocuments(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, [
Query.equal('clientId', clientData.$id),
Query.equal('isEnabled', true),
Query.orderAsc('order')
]).catch(() => ({ documents: [] })),
databases.listDocuments(DATABASE_ID, COLLECTIONS.GALLERY, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
]).catch(() => ({ documents: [] })),
databases.listDocuments(DATABASE_ID, COLLECTIONS.APPS, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
]).catch(() => ({ documents: [] }))
])
if (themeRes.documents.length > 0) {
setTheme(themeRes.documents[0])
}
setLinks(linksRes.documents)
setSections(sectionsRes.documents)
setSocialLinks(socialRes.documents)
setGallery(galleryRes.documents)
setApps(appsRes.documents)
// Try to load new collections (reviews and banners) - these might not exist
try {
const reviewsRes = await databases.listDocuments(DATABASE_ID, COLLECTIONS.REVIEWS, [
Query.equal('clientId', clientData.$id),
Query.equal('isEnabled', true),
Query.orderAsc('order')
])
setReviews(reviewsRes.documents)
} catch (e) {
console.log('Reviews collection not available')
setReviews([])
}
try {
const bannersRes = await databases.listDocuments(DATABASE_ID, COLLECTIONS.BANNERS, [
Query.equal('clientId', clientData.$id),
Query.equal('isEnabled', true),
Query.orderAsc('order')
])
setBanners(bannersRes.documents)
} catch (e) {
console.log('Banners collection not available')
setBanners([])
}
setVisitCount(clientData.visitCount || 0)
// Try to increment visit counter
try {
const newCount = (clientData.visitCount || 0) + 1
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CLIENTS, clientData.$id, {
visitCount: newCount
})
setVisitCount(newCount)
} catch (e) {
console.log('Could not update visit count')
}
} catch (error) {
console.error('Error loading data:', error)
} finally {
setLoading(false)
}
}
const getCombinedItems = () => {
const all = [
...links.map(l => ({ ...l, itemType: 'link' })),
...sections.map(s => ({ ...s, itemType: 'section' }))
]
return all.sort((a, b) => a.order - b.order)
}
const handleLinkClick = (link) => {
if (link.isComingSoon) {
navigate('/coming-soon')
} else {
window.open(link.url, '_blank')
}
}
const handleAppClick = (app) => {
if (app.isComingSoon) {
navigate('/coming-soon')
} else if (app.url) {
window.open(app.url, '_blank')
}
}
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>
)
}
// Theme values with defaults
const buttonStyle = BUTTON_STYLES[theme?.buttonStyle] || BUTTON_STYLES.rounded
const shadowClass = SHADOW_CLASSES[theme?.buttonShadow] || ''
const buttonAnimationClass = BUTTON_ANIMATION_CLASSES[theme?.buttonAnimation] || BUTTON_ANIMATION_CLASSES.scale
const fontSizeClass = FONT_SIZE_CLASSES[theme?.fontSize] || 'text-base'
const fontWeightClass = FONT_WEIGHT_CLASSES[theme?.fontWeight] || 'font-medium'
const titleSizeClass = TITLE_SIZE_CLASSES[theme?.titleFontSize] || 'text-2xl'
const titleWeightClass = FONT_WEIGHT_CLASSES[theme?.titleFontWeight] || 'font-bold'
const profileLayout = theme?.profileLayout || 'centered'
const socialPosition = theme?.socialPosition || 'top'
const socialStyle = theme?.socialStyle || 'filled'
const galleryPosition = theme?.galleryPosition || 'below_links'
const galleryDisplayMode = theme?.galleryDisplayMode || 'collapsed'
const appsPosition = theme?.appsPosition || 'below_links'
const showDescription = theme?.showDescription !== false
const showContactInfo = theme?.showContactInfo !== false
const buttonBorderWidth = theme?.buttonBorderWidth || 0
const backgroundType = theme?.backgroundType || 'solid'
const backgroundOverlay = theme?.backgroundOverlay || false
const overlayOpacity = theme?.overlayOpacity || 50
const logoSize = LOGO_SIZES[theme?.logoSize] || LOGO_SIZES.lg
// New features settings
const showVisitCounter = theme?.showVisitCounter !== false
const showRating = theme?.showRating !== false
const reviewsPosition = theme?.reviewsPosition || 'below_links'
const bannerPosition = theme?.bannerPosition || 'top'
// Social icon styling
const socialIconSize = SOCIAL_ICON_SIZES[theme?.socialIconSize] || SOCIAL_ICON_SIZES.md
const socialIconShape = SOCIAL_ICON_SHAPES[theme?.socialIconShape] || SOCIAL_ICON_SHAPES.circle
const socialIconSpacing = SOCIAL_ICON_SPACING[theme?.socialIconSpacing] || SOCIAL_ICON_SPACING.normal
const socialIconColor = theme?.socialIconColor || '#FFFFFF'
const socialIconBgColor = theme?.socialIconBgColor || '#3B82F6'
const socialIconBorderWidth = theme?.socialIconBorderWidth || 0
const socialIconBorderColor = theme?.socialIconBorderColor || '#3B82F6'
const socialIconShadow = theme?.socialIconShadow || 'none'
const socialIconAnimation = theme?.socialIconAnimation || 'scale'
const getBackgroundStyle = () => {
if (backgroundType === 'image' && theme?.backgroundImageId) {
return {
backgroundImage: `url(${getFilePreview(theme.backgroundImageId, 1920, 1080)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundAttachment: 'fixed'
}
} else if (backgroundType === 'gradient') {
const direction = theme?.gradientDirection === 'to-b' ? 'to bottom' :
theme?.gradientDirection === 'to-r' ? 'to right' :
theme?.gradientDirection === 'to-br' ? 'to bottom right' : 'to bottom left'
return {
background: `linear-gradient(${direction}, ${theme?.gradientFrom || '#3B82F6'}, ${theme?.gradientTo || '#8B5CF6'})`
}
}
return { backgroundColor: theme?.backgroundColor || '#F3F4F6' }
}
const combinedItems = getCombinedItems()
// Social Icons Component
const SocialIcons = () => {
if (socialLinks.length === 0 || socialPosition === 'hidden') return null
const getShadowClass = () => {
switch (socialIconShadow) {
case 'sm': return 'shadow-sm'
case 'md': return 'shadow-md'
case 'lg': return 'shadow-lg'
case 'glow': return 'shadow-lg shadow-blue-500/50'
default: return ''
}
}
const getAnimationClass = () => {
switch (socialIconAnimation) {
case 'scale': return 'hover:scale-110'
case 'bounce': return 'hover:-translate-y-1'
case 'rotate': return 'hover:rotate-12'
case 'shake': return 'hover:animate-pulse'
default: return ''
}
}
const getBgStyle = () => {
switch (socialStyle) {
case 'filled':
return { backgroundColor: socialIconBgColor }
case 'gradient':
return { background: `linear-gradient(135deg, ${socialIconBgColor}, ${theme?.gradientTo || '#8B5CF6'})` }
case 'glass':
return { backgroundColor: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(10px)' }
case 'outline':
case 'minimal':
return { backgroundColor: 'transparent' }
default:
return { backgroundColor: socialIconBgColor }
}
}
return (
<div className={`flex justify-center mb-6 ${socialIconSpacing}`}>
{socialLinks.map((social) => {
const Icon = SOCIAL_ICONS[social.platform] || Globe
return (
<a
key={social.$id}
href={social.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center transition-all ${socialIconShape} ${getShadowClass()} ${getAnimationClass()}`}
style={{
width: socialIconSize.size,
height: socialIconSize.size,
...getBgStyle(),
borderWidth: socialStyle === 'outline' ? `${socialIconBorderWidth || 2}px` : `${socialIconBorderWidth}px`,
borderColor: socialIconBorderColor,
borderStyle: 'solid',
color: socialStyle === 'minimal' ? socialIconBgColor : socialIconColor
}}
>
<Icon size={socialIconSize.iconSize} />
</a>
)
})}
</div>
)
}
// Gallery Component
const GallerySection = () => {
if (gallery.length === 0 || galleryPosition === 'hidden') return null
if (galleryDisplayMode === 'collapsed') {
return (
<div className="mb-8">
<button
onClick={() => setShowGallery(!showGallery)}
className="w-full flex items-center justify-center gap-2 py-3 text-sm font-medium opacity-70 hover:opacity-100 transition-opacity"
style={{ color: theme?.textColor || '#111827' }}
>
{showGallery ? 'Hide' : 'View'} Gallery ({gallery.length})
{showGallery ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button>
{showGallery && (
<div className="grid grid-cols-2 gap-2 mt-3">
{gallery.map((item) => (
<div key={item.$id} className="aspect-square rounded-lg overflow-hidden">
<img
src={getFilePreview(item.fileId, 400, 400)}
alt={item.title || 'Gallery item'}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
)}
</div>
)
}
if (galleryDisplayMode === 'expanded') {
return (
<div className="mb-8">
<div className="grid grid-cols-2 gap-2">
{gallery.map((item) => (
<div key={item.$id} className="aspect-square rounded-lg overflow-hidden">
<img
src={getFilePreview(item.fileId, 400, 400)}
alt={item.title || 'Gallery item'}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
)
}
return (
<div className="mb-8">
<div className="grid grid-cols-4 gap-1">
{gallery.map((item) => (
<div key={item.$id} className="aspect-square rounded overflow-hidden">
<img
src={getFilePreview(item.fileId, 200, 200)}
alt={item.title || 'Gallery item'}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
)
}
// Apps Component
const AppsSection = () => {
if (apps.length === 0 || appsPosition === 'hidden') return null
return (
<div className="mb-8">
<h3
className="text-sm font-semibold uppercase tracking-wide mb-3 text-center opacity-70"
style={{ color: theme?.textColor || '#111827' }}
>
Get the App
</h3>
<div className="flex justify-center gap-3 flex-wrap">
{apps.map((app) => {
const Icon = PLATFORM_ICONS[app.platform] || Smartphone
return (
<button
key={app.$id}
onClick={() => handleAppClick(app)}
className={`flex items-center gap-2 px-4 py-2 transition-all ${buttonStyle} ${shadowClass} ${buttonAnimationClass}`}
style={{
backgroundColor: theme?.buttonColor || '#3B82F6',
color: theme?.buttonTextColor || '#FFFFFF',
borderWidth: `${buttonBorderWidth}px`,
borderColor: theme?.buttonBorderColor || theme?.buttonColor || '#3B82F6',
borderStyle: 'solid'
}}
>
<Icon size={20} />
<span className="text-sm">
{app.platform === 'ios' ? 'iOS' : app.platform === 'android' ? 'Android' : app.name}
</span>
{app.isComingSoon && <Clock size={14} className="opacity-70" />}
</button>
)
})}
</div>
</div>
)
}
// Banner Slider Component
const BannerSlider = () => {
if (banners.length === 0 || bannerPosition === 'hidden') return null
const currentBannerData = banners[currentBanner]
if (!currentBannerData) return null
const nextBanner = () => setCurrentBanner((prev) => (prev + 1) % banners.length)
const prevBanner = () => setCurrentBanner((prev) => (prev - 1 + banners.length) % banners.length)
return (
<div className="mb-8 relative">
<div className="relative rounded-xl overflow-hidden">
<a
href={currentBannerData.linkUrl || '#'}
target={currentBannerData.linkUrl ? '_blank' : '_self'}
rel="noopener noreferrer"
className={currentBannerData.linkUrl ? 'block' : 'pointer-events-none'}
>
<img
src={getFilePreview(currentBannerData.imageFileId, 800, 400)}
alt={currentBannerData.title || 'Banner'}
className="w-full h-40 sm:h-48 object-cover"
/>
{(currentBannerData.title || currentBannerData.subtitle) && (
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end p-4">
<div className="text-white">
{currentBannerData.title && <h3 className="font-bold text-lg">{currentBannerData.title}</h3>}
{currentBannerData.subtitle && <p className="text-sm opacity-90">{currentBannerData.subtitle}</p>}
</div>
</div>
)}
</a>
{banners.length > 1 && (
<>
<button
onClick={(e) => { e.preventDefault(); prevBanner(); }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-white/80 rounded-full flex items-center justify-center shadow-md hover:bg-white transition-colors"
>
<ChevronLeft size={18} />
</button>
<button
onClick={(e) => { e.preventDefault(); nextBanner(); }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-white/80 rounded-full flex items-center justify-center shadow-md hover:bg-white transition-colors"
>
<ChevronRight size={18} />
</button>
</>
)}
</div>
{banners.length > 1 && (
<div className="flex justify-center gap-2 mt-3">
{banners.map((_, index) => (
<button
key={index}
onClick={() => setCurrentBanner(index)}
className="w-2 h-2 rounded-full transition-colors"
style={{
backgroundColor: index === currentBanner
? (theme?.primaryColor || '#3B82F6')
: `${theme?.textColor || '#111827'}30`
}}
/>
))}
</div>
)}
</div>
)
}
// Reviews Component
const ReviewsSection = () => {
if (reviews.length === 0 || reviewsPosition === 'hidden' || !showRating) return null
const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
return (
<div className="mb-8">
<div className="text-center mb-4">
<div className="flex justify-center gap-1 mb-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={20}
className={star <= Math.round(avgRating) ? 'text-yellow-400 fill-yellow-400' : 'text-gray-300'}
/>
))}
</div>
<p className="text-sm opacity-70" style={{ color: theme?.textColor || '#111827' }}>
{avgRating.toFixed(1)} from {reviews.length} reviews
</p>
</div>
<div className="space-y-3">
{reviews.slice(0, 3).map((review) => (
<div
key={review.$id}
className="bg-white/10 backdrop-blur-sm rounded-xl p-4"
style={{ backgroundColor: `${theme?.textColor || '#111827'}10` }}
>
<div className="flex items-center gap-3 mb-2">
{review.avatarFileId ? (
<img
src={getFilePreview(review.avatarFileId, 80, 80)}
alt={review.name}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div
className="w-10 h-10 rounded-full flex items-center justify-center"
style={{ backgroundColor: `${theme?.textColor || '#111827'}20` }}
>
<User size={18} style={{ color: theme?.textColor || '#111827' }} />
</div>
)}
<div>
<h4 className="font-medium text-sm" style={{ color: theme?.textColor || '#111827' }}>
{review.name}
</h4>
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={12}
className={star <= review.rating ? 'text-yellow-400 fill-yellow-400' : 'text-gray-300'}
/>
))}
</div>
</div>
</div>
{review.comment && (
<p className="text-sm italic opacity-80" style={{ color: theme?.textColor || '#111827' }}>
"{review.comment}"
</p>
)}
</div>
))}
</div>
</div>
)
}
// Visit Counter Component
const VisitCounterDisplay = () => {
if (!showVisitCounter || visitCount === 0) return null
return (
<div
className="flex items-center justify-center gap-2 mb-6 text-sm opacity-60"
style={{ color: theme?.textColor || '#111827' }}
>
<Eye size={16} />
<span>{visitCount.toLocaleString()} visits</span>
</div>
)
}
return (
<div
className="min-h-screen py-12 px-4 relative"
style={{
...getBackgroundStyle(),
fontFamily: `"${theme?.fontFamily || 'Inter'}", sans-serif`
}}
>
{backgroundType === 'image' && backgroundOverlay && (
<div className="absolute inset-0 bg-black" style={{ opacity: overlayOpacity / 100 }} />
)}
<div className="max-w-md mx-auto relative z-10">
{bannerPosition === 'top' && <BannerSlider />}
<div className={`mb-8 ${
profileLayout === 'centered' ? 'text-center' :
profileLayout === 'compact' ? 'flex items-center gap-4' : 'text-left'
}`}>
{clientData?.logoFileId && (
<img
src={getFilePreview(clientData.logoFileId, logoSize * 2, logoSize * 2)}
alt={clientData.businessName}
className={`object-cover shadow-lg rounded-full ${
profileLayout === 'compact' ? '' : profileLayout === 'centered' ? 'mx-auto mb-4' : 'mb-4'
}`}
style={{ width: logoSize, height: logoSize }}
/>
)}
<div className={profileLayout === 'compact' ? 'flex-1' : ''}>
<h1
className={`mb-2 ${titleSizeClass} ${titleWeightClass}`}
style={{ color: theme?.textColor || '#111827' }}
>
{clientData?.businessName}
</h1>
{showDescription && clientData?.description && (
<p
className={`opacity-80 mb-4 ${fontSizeClass} ${fontWeightClass}`}
style={{ color: theme?.textColor || '#111827' }}
>
{clientData.description}
</p>
)}
{showContactInfo && (
<div className={`flex flex-wrap gap-3 text-sm ${profileLayout === 'centered' ? 'justify-center' : ''}`} style={{ color: theme?.textColor || '#111827' }}>
{clientData?.location && (
<span className="flex items-center gap-1 opacity-70">
<MapPin size={14} />
{clientData.location}
</span>
)}
{clientData?.email && (
<a href={`mailto:${clientData.email}`} className="flex items-center gap-1 opacity-70 hover:opacity-100">
<Mail size={14} />
{clientData.email}
</a>
)}
{clientData?.phone && (
<a href={`tel:${clientData.phone}`} className="flex items-center gap-1 opacity-70 hover:opacity-100">
<Phone size={14} />
{clientData.phone}
</a>
)}
</div>
)}
</div>
</div>
<VisitCounterDisplay />
{socialPosition === 'top' && <SocialIcons />}
{bannerPosition === 'above_links' && <BannerSlider />}
{reviewsPosition === 'above_links' && <ReviewsSection />}
{galleryPosition === 'above_links' && <GallerySection />}
{appsPosition === 'above_links' && <AppsSection />}
<div className="space-y-3 mb-8">
{combinedItems.map((item) => {
if (item.itemType === 'section') {
if (item.type === 'title') {
const sectionSizeClass = SECTION_SIZE_CLASSES[item.fontSize] || 'text-sm'
const sectionWeightClass = FONT_WEIGHT_CLASSES[item.fontWeight] || 'font-semibold'
const alignClass = item.textAlign === 'left' ? 'text-left' : item.textAlign === 'right' ? 'text-right' : 'text-center'
return (
<div key={item.$id} className={`py-2 ${alignClass}`}>
<span
className={`uppercase tracking-wide opacity-60 ${sectionSizeClass} ${sectionWeightClass}`}
style={{ color: theme?.textColor || '#111827' }}
>
{item.title}
</span>
</div>
)
} else if (item.type === 'divider') {
return (
<div key={item.$id} className="py-2">
<hr className="opacity-20" style={{ borderColor: theme?.textColor || '#111827' }} />
</div>
)
} else if (item.type === 'spacer') {
return <div key={item.$id} className="h-6" />
}
return null
}
return (
<button
key={item.$id}
onClick={() => handleLinkClick(item)}
className={`w-full py-4 px-6 transition-all flex items-center justify-center gap-2 ${buttonStyle} ${shadowClass} ${buttonAnimationClass} ${fontSizeClass} ${fontWeightClass}`}
style={{
backgroundColor: theme?.buttonColor || '#3B82F6',
color: theme?.buttonTextColor || '#FFFFFF',
borderWidth: `${buttonBorderWidth}px`,
borderColor: theme?.buttonBorderColor || theme?.buttonColor || '#3B82F6',
borderStyle: 'solid'
}}
>
{item.title}
{item.isComingSoon ? (
<span className="flex items-center gap-1 text-xs bg-white/20 px-2 py-0.5 rounded-full">
<Clock size={12} />
Soon
</span>
) : (
<ExternalLink size={16} className="opacity-70" />
)}
</button>
)
})}
</div>
{bannerPosition === 'below_links' && <BannerSlider />}
{reviewsPosition === 'below_links' && <ReviewsSection />}
{galleryPosition === 'below_links' && <GallerySection />}
{appsPosition === 'below_links' && <AppsSection />}
{socialPosition === 'bottom' && <SocialIcons />}
<div className="text-center pt-8 border-t border-black/10">
<p className="text-xs opacity-50" style={{ color: theme?.textColor || '#111827' }}>
Powered by ArkLinks
</p>
</div>
</div>
</div>
)
}

280
src/pages/admin/Apps.jsx Normal file
View File

@@ -0,0 +1,280 @@
import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, Clock, Smartphone, Monitor, Apple } from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query } from '../../lib/appwrite'
const PLATFORMS = [
{ id: 'ios', name: 'iOS', icon: Apple, color: '#000000' },
{ id: 'android', name: 'Android', icon: Smartphone, color: '#3DDC84' },
{ id: 'web', name: 'Web App', icon: Monitor, color: '#4285F4' },
{ id: 'windows', name: 'Windows', icon: Monitor, color: '#0078D6' },
{ id: 'macos', name: 'macOS', icon: Apple, color: '#000000' },
]
export default function Apps({ clientData }) {
const [apps, setApps] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingApp, setEditingApp] = useState(null)
const [formData, setFormData] = useState({
name: '',
platform: 'ios',
url: '',
isComingSoon: false
})
useEffect(() => {
if (clientData?.$id) {
loadApps()
}
}, [clientData])
const loadApps = async () => {
try {
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.APPS, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
])
setApps(response.documents)
} catch (error) {
console.error('Error loading apps:', error)
} finally {
setLoading(false)
}
}
const getPlatformInfo = (platformId) => {
return PLATFORMS.find(p => p.id === platformId) || PLATFORMS[0]
}
const openModal = (app = null) => {
if (app) {
setEditingApp(app)
setFormData({
name: app.name,
platform: app.platform,
url: app.url || '',
isComingSoon: app.isComingSoon ?? false
})
} else {
setEditingApp(null)
setFormData({
name: '',
platform: 'ios',
url: '',
isComingSoon: false
})
}
setShowModal(true)
}
const handleSubmit = async (e) => {
e.preventDefault()
try {
const data = {
name: formData.name,
platform: formData.platform,
url: formData.url || null,
isComingSoon: formData.isComingSoon
}
if (editingApp) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.APPS, editingApp.$id, data)
} else {
await databases.createDocument(DATABASE_ID, COLLECTIONS.APPS, generateId(), {
...data,
clientId: clientData.$id,
order: apps.length
})
}
setShowModal(false)
loadApps()
} catch (error) {
console.error('Error saving app:', error)
alert('Error saving app')
}
}
const deleteApp = async (appId) => {
if (!confirm('Are you sure you want to delete this app?')) return
try {
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.APPS, appId)
loadApps()
} catch (error) {
console.error('Error deleting app:', error)
}
}
if (loading) {
return <div className="flex justify-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">App Downloads</h1>
<button
onClick={() => openModal()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add App
</button>
</div>
{apps.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm p-12 text-center">
<div className="flex justify-center gap-2 mb-4">
<div className="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center">
<Apple className="w-6 h-6 text-gray-400" />
</div>
<div className="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center">
<Smartphone className="w-6 h-6 text-gray-400" />
</div>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No apps yet</h3>
<p className="text-gray-500 mb-4">Add your app download links</p>
<button
onClick={() => openModal()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add First App
</button>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm divide-y">
{apps.map((app) => {
const platform = getPlatformInfo(app.platform)
const Icon = platform.icon
return (
<div key={app.$id} className="p-4 flex items-center gap-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white"
style={{ backgroundColor: platform.color }}
>
<Icon size={24} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{app.name}</h3>
{app.isComingSoon && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-yellow-100 text-yellow-700 text-xs rounded-full">
<Clock size={12} />
Coming Soon
</span>
)}
</div>
<p className="text-sm text-gray-500">{platform.name}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openModal(app)}
className="p-2 hover:bg-gray-100 rounded-lg text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => deleteApp(app.$id)}
className="p-2 hover:bg-red-50 rounded-lg text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
)
})}
</div>
)}
{/* 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">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{editingApp ? 'Edit App' : 'Add App'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">App Name *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: 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="My App"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Platform</label>
<div className="grid grid-cols-3 gap-2">
{PLATFORMS.slice(0, 3).map((platform) => {
const Icon = platform.icon
return (
<button
key={platform.id}
type="button"
onClick={() => setFormData(prev => ({ ...prev, platform: platform.id }))}
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'
}`}
>
<Icon size={20} style={{ color: platform.color }} />
<span className="text-xs">{platform.name}</span>
</button>
)
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Store URL</label>
<input
type="url"
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://apps.apple.com/..."
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isComingSoon}
onChange={(e) => setFormData(prev => ({ ...prev, isComingSoon: e.target.checked }))}
className="w-4 h-4 text-yellow-600 rounded"
/>
<span className="text-sm text-gray-700">Coming Soon (not available yet)</span>
</label>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingApp ? 'Update' : 'Add'} App
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

344
src/pages/admin/Banners.jsx Normal file
View File

@@ -0,0 +1,344 @@
import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, Image, Upload, Link, GripVertical } from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query, uploadFile, deleteFile, getFilePreview } from '../../lib/appwrite'
export default function Banners({ clientData }) {
const [banners, setBanners] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingBanner, setEditingBanner] = useState(null)
const [imageFile, setImageFile] = useState(null)
const [imagePreview, setImagePreview] = useState(null)
const [formData, setFormData] = useState({
title: '',
subtitle: '',
linkUrl: '',
isEnabled: true
})
useEffect(() => {
if (clientData?.$id) {
loadBanners()
}
}, [clientData])
const loadBanners = async () => {
try {
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.BANNERS, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
])
setBanners(response.documents)
} catch (error) {
console.error('Error loading banners:', error)
} finally {
setLoading(false)
}
}
const openModal = (banner = null) => {
if (banner) {
setEditingBanner(banner)
setFormData({
title: banner.title || '',
subtitle: banner.subtitle || '',
linkUrl: banner.linkUrl || '',
isEnabled: banner.isEnabled ?? true
})
setImagePreview(getFilePreview(banner.imageFileId, 600, 300))
} else {
setEditingBanner(null)
setFormData({
title: '',
subtitle: '',
linkUrl: '',
isEnabled: true
})
setImageFile(null)
setImagePreview(null)
}
setShowModal(true)
}
const handleImageChange = (e) => {
const file = e.target.files[0]
if (file) {
setImageFile(file)
setImagePreview(URL.createObjectURL(file))
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!editingBanner && !imageFile) {
alert('Please upload a banner image')
return
}
try {
let imageFileId = editingBanner?.imageFileId || null
if (imageFile) {
if (editingBanner?.imageFileId) {
await deleteFile(editingBanner.imageFileId)
}
imageFileId = await uploadFile(imageFile)
}
if (editingBanner) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.BANNERS, editingBanner.$id, {
title: formData.title || null,
subtitle: formData.subtitle || null,
linkUrl: formData.linkUrl || null,
imageFileId: imageFileId,
isEnabled: formData.isEnabled
})
} else {
await databases.createDocument(DATABASE_ID, COLLECTIONS.BANNERS, generateId(), {
clientId: clientData.$id,
title: formData.title || null,
subtitle: formData.subtitle || null,
linkUrl: formData.linkUrl || null,
imageFileId: imageFileId,
order: banners.length,
isEnabled: formData.isEnabled
})
}
setShowModal(false)
setImageFile(null)
setImagePreview(null)
loadBanners()
} catch (error) {
console.error('Error saving banner:', error)
alert('Error saving banner')
}
}
const deleteBanner = async (banner) => {
if (!confirm('Are you sure you want to delete this banner?')) return
try {
if (banner.imageFileId) {
await deleteFile(banner.imageFileId)
}
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.BANNERS, banner.$id)
loadBanners()
} catch (error) {
console.error('Error deleting banner:', error)
}
}
const toggleEnabled = async (banner) => {
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.BANNERS, banner.$id, {
isEnabled: !banner.isEnabled
})
loadBanners()
} catch (error) {
console.error('Error toggling banner:', error)
}
}
if (loading) {
return <div className="flex justify-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Banner Slider</h1>
<p className="text-sm text-gray-500 mt-1">Add promotional banners to your page</p>
</div>
<button
onClick={() => openModal()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add Banner
</button>
</div>
{banners.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm p-12 text-center">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Image className="w-8 h-8 text-purple-500" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No banners yet</h3>
<p className="text-gray-500 mb-4">Add eye-catching banners to promote offers or announcements</p>
<button
onClick={() => openModal()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add First Banner
</button>
</div>
) : (
<div className="space-y-4">
{banners.map((banner) => (
<div key={banner.$id} className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="flex">
<div className="w-48 h-24 flex-shrink-0">
<img
src={getFilePreview(banner.imageFileId, 400, 200)}
alt={banner.title || 'Banner'}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-gray-400 cursor-move">
<GripVertical size={20} />
</div>
<div>
<h3 className="font-medium text-gray-900">
{banner.title || 'Untitled Banner'}
</h3>
{banner.subtitle && (
<p className="text-sm text-gray-500 truncate max-w-xs">{banner.subtitle}</p>
)}
{banner.linkUrl && (
<div className="flex items-center gap-1 text-xs text-blue-600 mt-1">
<Link size={12} />
<span className="truncate max-w-xs">{banner.linkUrl}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleEnabled(banner)}
className={`px-3 py-1 text-sm rounded-full ${
banner.isEnabled
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{banner.isEnabled ? 'Active' : 'Hidden'}
</button>
<button
onClick={() => openModal(banner)}
className="p-2 hover:bg-gray-100 rounded-lg text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => deleteBanner(banner)}
className="p-2 hover:bg-red-50 rounded-lg text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* 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-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{editingBanner ? 'Edit Banner' : 'Add Banner'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Image Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Banner Image {!editingBanner && '*'}
</label>
{imagePreview ? (
<div className="relative">
<img
src={imagePreview}
alt="Preview"
className="w-full h-40 object-cover rounded-lg"
/>
<label className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity rounded-lg cursor-pointer">
<span className="px-4 py-2 bg-white text-gray-700 rounded-lg text-sm">
Change Image
</span>
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
</label>
</div>
) : (
<label className="block">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500 transition-colors">
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<span className="text-sm text-gray-600">Upload banner image</span>
<p className="text-xs text-gray-400 mt-1">Recommended: 1200x400px</p>
</div>
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
</label>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title (optional)</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: 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="Banner title"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subtitle (optional)</label>
<input
type="text"
value={formData.subtitle}
onChange={(e) => setFormData(prev => ({ ...prev, subtitle: 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="Short description"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Link URL (optional)</label>
<input
type="url"
value={formData.linkUrl}
onChange={(e) => setFormData(prev => ({ ...prev, linkUrl: 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://example.com"
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isEnabled}
onChange={(e) => setFormData(prev => ({ ...prev, isEnabled: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700">Show in slider</span>
</label>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => { setShowModal(false); setImageFile(null); setImagePreview(null); }}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingBanner ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import {
Link2,
Share2,
Palette,
Image,
Smartphone,
QrCode,
Edit3,
MapPin,
Mail,
Phone
} from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, Query, getFilePreview } from '../../lib/appwrite'
const quickActions = [
{ path: '/admin/links', icon: Link2, label: 'Manage Links', color: 'bg-blue-500' },
{ path: '/admin/social', icon: Share2, label: 'Social Media', color: 'bg-pink-500' },
{ path: '/admin/theme', icon: Palette, label: 'Customize Theme', color: 'bg-purple-500' },
{ path: '/admin/gallery', icon: Image, label: 'Gallery', color: 'bg-green-500' },
{ path: '/admin/apps', icon: Smartphone, label: 'App Links', color: 'bg-orange-500' },
{ path: '/admin/qr-code', icon: QrCode, label: 'QR Code', color: 'bg-gray-700' },
]
export default function Dashboard({ clientData }) {
const [stats, setStats] = useState({
links: 0,
socialLinks: 0,
gallery: 0,
apps: 0
})
useEffect(() => {
if (clientData?.$id) {
loadStats()
}
}, [clientData])
const loadStats = async () => {
try {
const [links, social, gallery, apps] = await Promise.all([
databases.listDocuments(DATABASE_ID, COLLECTIONS.LINKS, [
Query.equal('clientId', clientData.$id)
]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, [
Query.equal('clientId', clientData.$id)
]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.GALLERY, [
Query.equal('clientId', clientData.$id)
]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.APPS, [
Query.equal('clientId', clientData.$id)
])
])
setStats({
links: links.total,
socialLinks: social.total,
gallery: gallery.total,
apps: apps.total
})
} catch (error) {
console.error('Error loading stats:', error)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
</div>
{/* Business Card */}
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-start gap-4">
{clientData?.logoFileId ? (
<img
src={getFilePreview(clientData.logoFileId, 100, 100)}
alt="Logo"
className="w-20 h-20 rounded-xl object-cover"
/>
) : (
<div className="w-20 h-20 bg-gray-100 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-gray-400">
{clientData?.businessName?.charAt(0) || 'A'}
</span>
</div>
)}
<div className="flex-1">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-gray-900">{clientData?.businessName}</h2>
<Link to="/admin/theme" className="p-1 hover:bg-gray-100 rounded">
<Edit3 size={16} className="text-gray-400" />
</Link>
</div>
{clientData?.description && (
<p className="text-gray-600 mt-1 line-clamp-2">{clientData.description}</p>
)}
<div className="flex flex-wrap gap-4 mt-3 text-sm text-gray-500">
{clientData?.location && (
<span className="flex items-center gap-1">
<MapPin size={14} />
{clientData.location}
</span>
)}
{clientData?.email && (
<span className="flex items-center gap-1">
<Mail size={14} />
{clientData.email}
</span>
)}
{clientData?.phone && (
<span className="flex items-center gap-1">
<Phone size={14} />
{clientData.phone}
</span>
)}
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="text-3xl font-bold text-blue-600">{stats.links}</div>
<div className="text-sm text-gray-500">Links</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="text-3xl font-bold text-pink-600">{stats.socialLinks}</div>
<div className="text-sm text-gray-500">Social Links</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="text-3xl font-bold text-green-600">{stats.gallery}</div>
<div className="text-sm text-gray-500">Gallery Items</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="text-3xl font-bold text-orange-600">{stats.apps}</div>
<div className="text-sm text-gray-500">App Links</div>
</div>
</div>
{/* Quick Actions */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{quickActions.map(({ path, icon: Icon, label, color }) => (
<Link
key={path}
to={path}
className="bg-white rounded-xl shadow-sm p-4 hover:shadow-md transition-shadow flex items-center gap-3"
>
<div className={`${color} w-10 h-10 rounded-lg flex items-center justify-center text-white`}>
<Icon size={20} />
</div>
<span className="font-medium text-gray-900">{label}</span>
</Link>
))}
</div>
</div>
</div>
)
}

280
src/pages/admin/Gallery.jsx Normal file
View File

@@ -0,0 +1,280 @@
import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, Upload, X, Image as ImageIcon } from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query, uploadFile, deleteFile, getFilePreview } from '../../lib/appwrite'
export default function Gallery({ clientData }) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingItem, setEditingItem] = useState(null)
const [uploading, setUploading] = useState(false)
const [formData, setFormData] = useState({
title: '',
description: '',
file: null,
filePreview: null
})
useEffect(() => {
if (clientData?.$id) {
loadItems()
}
}, [clientData])
const loadItems = async () => {
try {
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.GALLERY, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
])
setItems(response.documents)
} catch (error) {
console.error('Error loading gallery:', error)
} finally {
setLoading(false)
}
}
const openModal = (item = null) => {
if (item) {
setEditingItem(item)
setFormData({
title: item.title || '',
description: item.description || '',
file: null,
filePreview: getFilePreview(item.fileId, 400, 400)
})
} else {
setEditingItem(null)
setFormData({
title: '',
description: '',
file: null,
filePreview: null
})
}
setShowModal(true)
}
const handleFileChange = (e) => {
const file = e.target.files[0]
if (file) {
setFormData(prev => ({
...prev,
file,
filePreview: URL.createObjectURL(file)
}))
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!editingItem && !formData.file) {
alert('Please select an image')
return
}
setUploading(true)
try {
let fileId = editingItem?.fileId
// Upload new file if provided
if (formData.file) {
// Delete old file if updating
if (editingItem?.fileId) {
await deleteFile(editingItem.fileId)
}
fileId = await uploadFile(formData.file)
}
if (editingItem) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.GALLERY, editingItem.$id, {
title: formData.title || null,
description: formData.description || null,
fileId
})
} else {
await databases.createDocument(DATABASE_ID, COLLECTIONS.GALLERY, generateId(), {
clientId: clientData.$id,
title: formData.title || null,
description: formData.description || null,
fileId,
order: items.length
})
}
setShowModal(false)
loadItems()
} catch (error) {
console.error('Error saving gallery item:', error)
alert('Error saving gallery item')
} finally {
setUploading(false)
}
}
const deleteItem = async (item) => {
if (!confirm('Are you sure you want to delete this image?')) return
try {
await deleteFile(item.fileId)
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.GALLERY, item.$id)
loadItems()
} catch (error) {
console.error('Error deleting gallery item:', error)
}
}
if (loading) {
return <div className="flex justify-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Gallery</h1>
<button
onClick={() => openModal()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add Image
</button>
</div>
{items.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm p-12 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<ImageIcon className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No gallery items yet</h3>
<p className="text-gray-500 mb-4">Showcase your past works and projects</p>
<button
onClick={() => openModal()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add First Image
</button>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{items.map((item) => (
<div key={item.$id} className="group relative bg-white rounded-xl shadow-sm overflow-hidden">
<img
src={getFilePreview(item.fileId, 400, 400)}
alt={item.title || 'Gallery item'}
className="w-full aspect-square object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
onClick={() => openModal(item)}
className="p-2 bg-white rounded-lg text-gray-700 hover:bg-gray-100"
>
<Edit2 size={18} />
</button>
<button
onClick={() => deleteItem(item)}
className="p-2 bg-white rounded-lg text-red-500 hover:bg-red-50"
>
<Trash2 size={18} />
</button>
</div>
{item.title && (
<div className="p-3">
<h4 className="font-medium text-gray-900 truncate">{item.title}</h4>
</div>
)}
</div>
))}
</div>
)}
{/* 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="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900">
{editingItem ? 'Edit Image' : 'Add Image'}
</h2>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Image Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Image {!editingItem && '*'}</label>
{formData.filePreview ? (
<div className="relative">
<img
src={formData.filePreview}
alt="Preview"
className="w-full h-48 object-cover rounded-lg"
/>
<label className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity cursor-pointer rounded-lg">
<span className="text-white text-sm">Click to change</span>
<input type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
</label>
</div>
) : (
<label className="block">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500 transition-colors">
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<span className="text-sm text-gray-600">Click to upload image</span>
</div>
<input type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
</label>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title (optional)</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: 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="Project name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: 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="Brief description..."
rows={2}
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={uploading}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center"
>
{uploading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
editingItem ? 'Update' : 'Add'
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

618
src/pages/admin/Links.jsx Normal file
View File

@@ -0,0 +1,618 @@
import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, GripVertical, ExternalLink, Clock, Type, Minus, MoreVertical } from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query } from '../../lib/appwrite'
export default function Links({ clientData }) {
const [links, setLinks] = useState([])
const [sections, setSections] = useState([])
const [loading, setLoading] = useState(true)
const [showLinkModal, setShowLinkModal] = useState(false)
const [showSectionModal, setShowSectionModal] = useState(false)
const [editingLink, setEditingLink] = useState(null)
const [editingSection, setEditingSection] = useState(null)
const [linkFormData, setLinkFormData] = useState({
title: '',
url: '',
icon: '',
isEnabled: true,
isComingSoon: false
})
const [sectionFormData, setSectionFormData] = useState({
title: '',
type: 'title',
fontSize: 'md',
fontWeight: 'semibold',
textAlign: 'center'
})
useEffect(() => {
if (clientData?.$id) {
loadData()
}
}, [clientData])
const loadData = async () => {
try {
const [linksRes, sectionsRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, COLLECTIONS.LINKS, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.SECTIONS, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
])
])
setLinks(linksRes.documents)
setSections(sectionsRes.documents)
} catch (error) {
console.error('Error loading data:', error)
} finally {
setLoading(false)
}
}
// Combine links and sections into ordered list
const getCombinedItems = () => {
const all = [
...links.map(l => ({ ...l, itemType: 'link' })),
...sections.map(s => ({ ...s, itemType: 'section' }))
]
return all.sort((a, b) => a.order - b.order)
}
const openLinkModal = (link = null) => {
if (link) {
setEditingLink(link)
setLinkFormData({
title: link.title,
url: link.url,
icon: link.icon || '',
isEnabled: link.isEnabled ?? true,
isComingSoon: link.isComingSoon ?? false
})
} else {
setEditingLink(null)
setLinkFormData({
title: '',
url: '',
icon: '',
isEnabled: true,
isComingSoon: false
})
}
setShowLinkModal(true)
}
const openSectionModal = (section = null) => {
if (section) {
setEditingSection(section)
setSectionFormData({
title: section.title || '',
type: section.type,
fontSize: section.fontSize || 'md',
fontWeight: section.fontWeight || 'semibold',
textAlign: section.textAlign || 'center'
})
} else {
setEditingSection(null)
setSectionFormData({
title: '',
type: 'title',
fontSize: 'md',
fontWeight: 'semibold',
textAlign: 'center'
})
}
setShowSectionModal(true)
}
const handleLinkSubmit = async (e) => {
e.preventDefault()
try {
const allItems = getCombinedItems()
if (editingLink) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.LINKS, editingLink.$id, {
title: linkFormData.title,
url: linkFormData.url,
icon: linkFormData.icon || null,
isEnabled: linkFormData.isEnabled,
isComingSoon: linkFormData.isComingSoon
})
} else {
await databases.createDocument(DATABASE_ID, COLLECTIONS.LINKS, generateId(), {
clientId: clientData.$id,
title: linkFormData.title,
url: linkFormData.url,
icon: linkFormData.icon || null,
order: allItems.length,
isEnabled: linkFormData.isEnabled,
isComingSoon: linkFormData.isComingSoon
})
}
setShowLinkModal(false)
loadData()
} catch (error) {
console.error('Error saving link:', error)
alert('Error saving link')
}
}
const handleSectionSubmit = async (e) => {
e.preventDefault()
try {
const allItems = getCombinedItems()
if (editingSection) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.SECTIONS, editingSection.$id, {
title: sectionFormData.title || null,
type: sectionFormData.type,
fontSize: sectionFormData.fontSize,
fontWeight: sectionFormData.fontWeight,
textAlign: sectionFormData.textAlign
})
} else {
await databases.createDocument(DATABASE_ID, COLLECTIONS.SECTIONS, generateId(), {
clientId: clientData.$id,
title: sectionFormData.title || null,
type: sectionFormData.type,
order: allItems.length,
fontSize: sectionFormData.fontSize,
fontWeight: sectionFormData.fontWeight,
textAlign: sectionFormData.textAlign
})
}
setShowSectionModal(false)
loadData()
} catch (error) {
console.error('Error saving section:', error)
alert('Error saving section')
}
}
const deleteLink = async (linkId) => {
if (!confirm('Are you sure you want to delete this link?')) return
try {
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.LINKS, linkId)
loadData()
} catch (error) {
console.error('Error deleting link:', error)
}
}
const deleteSection = async (sectionId) => {
if (!confirm('Are you sure you want to delete this section?')) return
try {
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.SECTIONS, sectionId)
loadData()
} catch (error) {
console.error('Error deleting section:', error)
}
}
const toggleEnabled = async (link) => {
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.LINKS, link.$id, {
isEnabled: !link.isEnabled
})
loadData()
} catch (error) {
console.error('Error toggling link:', error)
}
}
const moveItem = async (item, direction) => {
const allItems = getCombinedItems()
const currentIndex = allItems.findIndex(i => i.$id === item.$id)
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
if (newIndex < 0 || newIndex >= allItems.length) return
const swapItem = allItems[newIndex]
try {
// Swap orders
const collection = item.itemType === 'link' ? COLLECTIONS.LINKS : COLLECTIONS.SECTIONS
const swapCollection = swapItem.itemType === 'link' ? COLLECTIONS.LINKS : COLLECTIONS.SECTIONS
await Promise.all([
databases.updateDocument(DATABASE_ID, collection, item.$id, { order: swapItem.order }),
databases.updateDocument(DATABASE_ID, swapCollection, swapItem.$id, { order: item.order })
])
loadData()
} catch (error) {
console.error('Error reordering:', error)
}
}
if (loading) {
return <div className="flex justify-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>
}
const combinedItems = getCombinedItems()
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Links & Sections</h1>
<div className="flex gap-2">
<button
onClick={() => openSectionModal()}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Type size={20} />
Add Title
</button>
<button
onClick={() => openLinkModal()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add Link
</button>
</div>
</div>
{combinedItems.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm p-12 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<ExternalLink className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No links yet</h3>
<p className="text-gray-500 mb-4">Add your first link or section title to get started</p>
<div className="flex justify-center gap-3">
<button
onClick={() => openSectionModal()}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Type size={20} />
Add Title
</button>
<button
onClick={() => openLinkModal()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add Link
</button>
</div>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm divide-y">
{combinedItems.map((item, index) => (
item.itemType === 'link' ? (
// Link Item
<div key={item.$id} className="p-4 flex items-center gap-4">
<div className="flex flex-col gap-1">
<button
onClick={() => moveItem(item, 'up')}
disabled={index === 0}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
>
<GripVertical size={16} className="rotate-180" />
</button>
<button
onClick={() => moveItem(item, 'down')}
disabled={index === combinedItems.length - 1}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
>
<GripVertical size={16} />
</button>
</div>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<ExternalLink size={18} className="text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900 truncate">{item.title}</h3>
{item.isComingSoon && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-yellow-100 text-yellow-700 text-xs rounded-full">
<Clock size={12} />
Coming Soon
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">{item.url}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleEnabled(item)}
className={`px-3 py-1 text-sm rounded-full ${
item.isEnabled
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{item.isEnabled ? 'Active' : 'Hidden'}
</button>
<button
onClick={() => openLinkModal(item)}
className="p-2 hover:bg-gray-100 rounded-lg text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => deleteLink(item.$id)}
className="p-2 hover:bg-red-50 rounded-lg text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
) : (
// Section Item
<div key={item.$id} className="p-4 flex items-center gap-4 bg-gray-50">
<div className="flex flex-col gap-1">
<button
onClick={() => moveItem(item, 'up')}
disabled={index === 0}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
>
<GripVertical size={16} className="rotate-180" />
</button>
<button
onClick={() => moveItem(item, 'down')}
disabled={index === combinedItems.length - 1}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
>
<GripVertical size={16} />
</button>
</div>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
{item.type === 'title' ? (
<Type size={18} className="text-purple-600" />
) : item.type === 'divider' ? (
<Minus size={18} className="text-purple-600" />
) : (
<MoreVertical size={18} className="text-purple-600" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wide text-purple-600 font-semibold">
{item.type === 'title' ? 'Section Title' : item.type === 'divider' ? 'Divider' : 'Spacer'}
</span>
</div>
{item.title && (
<p className="font-medium text-gray-900">{item.title}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openSectionModal(item)}
className="p-2 hover:bg-gray-200 rounded-lg text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => deleteSection(item.$id)}
className="p-2 hover:bg-red-50 rounded-lg text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
)
))}
</div>
)}
{/* Link Modal */}
{showLinkModal && (
<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">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{editingLink ? 'Edit Link' : 'Add Link'}
</h2>
<form onSubmit={handleLinkSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title *</label>
<input
type="text"
required
value={linkFormData.title}
onChange={(e) => setLinkFormData(prev => ({ ...prev, title: 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="My Website"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">URL *</label>
<input
type="url"
required
value={linkFormData.url}
onChange={(e) => setLinkFormData(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://example.com"
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={linkFormData.isEnabled}
onChange={(e) => setLinkFormData(prev => ({ ...prev, isEnabled: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700">Visible</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={linkFormData.isComingSoon}
onChange={(e) => setLinkFormData(prev => ({ ...prev, isComingSoon: e.target.checked }))}
className="w-4 h-4 text-yellow-600 rounded"
/>
<span className="text-sm text-gray-700">Coming Soon</span>
</label>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowLinkModal(false)}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingLink ? 'Update' : 'Add'} Link
</button>
</div>
</form>
</div>
</div>
)}
{/* Section Modal */}
{showSectionModal && (
<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">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{editingSection ? 'Edit Section' : 'Add Section'}
</h2>
<form onSubmit={handleSectionSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Type</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: 'title', label: 'Title', icon: Type },
{ id: 'divider', label: 'Divider', icon: Minus },
{ id: 'spacer', label: 'Spacer', icon: MoreVertical }
].map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setSectionFormData(prev => ({ ...prev, type: id }))}
className={`p-3 rounded-lg border-2 transition-colors flex flex-col items-center gap-1 ${
sectionFormData.type === id
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Icon size={20} className="text-purple-600" />
<span className="text-xs">{label}</span>
</button>
))}
</div>
</div>
{sectionFormData.type === 'title' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title Text</label>
<input
type="text"
value={sectionFormData.title}
onChange={(e) => setSectionFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Section Title"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Size</label>
<div className="flex gap-2">
{['sm', 'md', 'lg', 'xl'].map((size) => (
<button
key={size}
type="button"
onClick={() => setSectionFormData(prev => ({ ...prev, fontSize: size }))}
className={`flex-1 py-2 text-sm rounded-lg border-2 transition-colors ${
sectionFormData.fontSize === size
? 'border-purple-500 bg-purple-50 text-purple-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
{size.toUpperCase()}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Weight</label>
<div className="flex gap-2">
{[
{ id: 'normal', label: 'Normal' },
{ id: 'medium', label: 'Medium' },
{ id: 'semibold', label: 'Semi' },
{ id: 'bold', label: 'Bold' }
].map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => setSectionFormData(prev => ({ ...prev, fontWeight: id }))}
className={`flex-1 py-2 text-sm rounded-lg border-2 transition-colors ${
sectionFormData.fontWeight === id
? 'border-purple-500 bg-purple-50 text-purple-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
{label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Alignment</label>
<div className="flex gap-2">
{['left', 'center', 'right'].map((align) => (
<button
key={align}
type="button"
onClick={() => setSectionFormData(prev => ({ ...prev, textAlign: align }))}
className={`flex-1 py-2 text-sm rounded-lg border-2 transition-colors capitalize ${
sectionFormData.textAlign === align
? 'border-purple-500 bg-purple-50 text-purple-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
{align}
</button>
))}
</div>
</div>
</>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowSectionModal(false)}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
{editingSection ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

99
src/pages/admin/Login.jsx Normal file
View File

@@ -0,0 +1,99 @@
import { useState } from 'react'
import { Lock, Eye, EyeOff, LogIn } from 'lucide-react'
export default function Login({ onLogin, clientData }) {
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
// Simple password check (in production, use proper hashing)
if (password === clientData?.adminPassword) {
// Store auth state in sessionStorage
sessionStorage.setItem('arklinks_auth', 'true')
sessionStorage.setItem('arklinks_auth_time', Date.now().toString())
onLogin()
} else {
setError('Invalid password')
}
} catch (err) {
setError('An error occurred. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 to-purple-700 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="w-8 h-8 text-blue-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Admin Login</h1>
<p className="text-gray-500 mt-2">
Enter your password to access the dashboard
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 pr-12"
placeholder="Enter admin password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 font-medium"
>
{loading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<LogIn size={20} />
Sign In
</>
)}
</button>
</form>
<div className="mt-6 text-center">
<a href="/" className="text-sm text-gray-500 hover:text-gray-700">
Back to public page
</a>
</div>
</div>
</div>
)
}

317
src/pages/admin/Profile.jsx Normal file
View File

@@ -0,0 +1,317 @@
import { useState, useEffect } from 'react'
import { Save, Upload, User, Building, Mail, Phone, MapPin, FileText, Lock, Eye, EyeOff, Shield } from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, uploadFile, deleteFile, getFilePreview } from '../../lib/appwrite'
export default function Profile({ clientData, onUpdate }) {
const [saving, setSaving] = useState(false)
const [logoFile, setLogoFile] = useState(null)
const [logoPreview, setLogoPreview] = useState(null)
const [showPassword, setShowPassword] = useState(false)
const [passwordData, setPasswordData] = useState({
newPassword: '',
confirmPassword: '',
})
const [formData, setFormData] = useState({
businessName: '',
description: '',
email: '',
phone: '',
location: '',
})
useEffect(() => {
if (clientData) {
setFormData({
businessName: clientData.businessName || '',
description: clientData.description || '',
email: clientData.email || '',
phone: clientData.phone || '',
location: clientData.location || '',
})
if (clientData.logoFileId) {
setLogoPreview(getFilePreview(clientData.logoFileId, 200, 200))
}
}
}, [clientData])
const handleLogoChange = (e) => {
const file = e.target.files[0]
if (file) {
setLogoFile(file)
setLogoPreview(URL.createObjectURL(file))
}
}
const handleSave = async () => {
// Validate password if being changed
if (passwordData.newPassword || passwordData.confirmPassword) {
if (passwordData.newPassword !== passwordData.confirmPassword) {
alert('Passwords do not match')
return
}
if (passwordData.newPassword.length < 6) {
alert('Password must be at least 6 characters')
return
}
}
setSaving(true)
try {
let logoFileId = clientData?.logoFileId
// Upload new logo if changed
if (logoFile) {
if (clientData?.logoFileId) {
await deleteFile(clientData.logoFileId)
}
logoFileId = await uploadFile(logoFile)
setLogoFile(null)
}
const updateData = {
businessName: formData.businessName,
description: formData.description,
email: formData.email,
phone: formData.phone,
location: formData.location,
logoFileId: logoFileId,
}
// Only update password if new one provided
if (passwordData.newPassword) {
updateData.adminPassword = passwordData.newPassword
}
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CLIENTS, clientData.$id, updateData)
// Clear password fields after save
setPasswordData({ newPassword: '', confirmPassword: '' })
if (onUpdate) onUpdate()
alert('Profile saved successfully!')
} catch (error) {
console.error('Error saving profile:', error)
alert('Error saving profile')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Profile Settings</h1>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Save size={20} />
)}
Save Changes
</button>
</div>
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="space-y-6">
{/* Logo Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Profile Logo</label>
<div className="flex items-center gap-6">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo"
className="w-24 h-24 rounded-xl object-cover shadow-md"
/>
) : (
<div className="w-24 h-24 bg-gray-100 rounded-xl flex items-center justify-center">
<User className="w-10 h-10 text-gray-400" />
</div>
)}
<div className="flex-1">
<label className="block">
<div className="px-6 py-4 border-2 border-dashed border-gray-300 rounded-lg text-center cursor-pointer hover:border-blue-500 transition-colors">
<Upload className="w-6 h-6 mx-auto text-gray-400 mb-2" />
<span className="text-sm text-gray-600">Upload new logo</span>
<p className="text-xs text-gray-400 mt-1">PNG, JPG up to 5MB</p>
</div>
<input type="file" accept="image/*" onChange={handleLogoChange} className="hidden" />
</label>
</div>
</div>
</div>
<hr className="border-gray-200" />
{/* Business Info */}
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<Building size={16} />
Business Name
</label>
<input
type="text"
value={formData.businessName}
onChange={(e) => setFormData(p => ({ ...p, businessName: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Your Business Name"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<MapPin size={16} />
Location
</label>
<input
type="text"
value={formData.location}
onChange={(e) => setFormData(p => ({ ...p, location: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="City, Country"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<Mail size={16} />
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(p => ({ ...p, email: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="contact@example.com"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<Phone size={16} />
Phone
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData(p => ({ ...p, phone: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="+1 234 567 8900"
/>
</div>
<div className="md:col-span-2">
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<FileText size={16} />
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(p => ({ ...p, description: e.target.value }))}
rows={4}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
placeholder="Tell visitors about your business..."
/>
<p className="text-xs text-gray-500 mt-1">
This will be displayed below your name on your public page
</p>
</div>
</div>
</div>
</div>
{/* Security Section */}
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
<Shield className="w-5 h-5 text-amber-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Security</h2>
<p className="text-sm text-gray-500">Protect your admin dashboard with a password</p>
</div>
</div>
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
{clientData?.adminPassword ? (
<>
<Lock className="inline w-4 h-4 mr-1" />
Password protection is <strong>enabled</strong>. Leave fields empty to keep current password.
</>
) : (
<>
<Lock className="inline w-4 h-4 mr-1" />
Password protection is <strong>disabled</strong>. Set a password to secure your admin panel.
</>
)}
</p>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<Lock size={16} />
{clientData?.adminPassword ? 'New Password' : 'Set Password'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={passwordData.newPassword}
onChange={(e) => setPasswordData(p => ({ ...p, newPassword: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 pr-12"
placeholder={clientData?.adminPassword ? 'Enter new password' : 'Enter password'}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<Lock size={16} />
Confirm Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={passwordData.confirmPassword}
onChange={(e) => setPasswordData(p => ({ ...p, confirmPassword: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 pr-12"
placeholder="Confirm password"
/>
</div>
</div>
</div>
{passwordData.newPassword && passwordData.confirmPassword && (
<div className={`text-sm ${
passwordData.newPassword === passwordData.confirmPassword
? 'text-green-600'
: 'text-red-600'
}`}>
{passwordData.newPassword === passwordData.confirmPassword
? 'Passwords match'
: 'Passwords do not match'}
</div>
)}
<p className="text-xs text-gray-500">
Password must be at least 6 characters. After setting a password, you'll need to sign in to access the admin panel.
</p>
</div>
</div>
</div>
)
}

204
src/pages/admin/QRCode.jsx Normal file
View File

@@ -0,0 +1,204 @@
import { useState, useRef } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Download, Copy, Check } from 'lucide-react'
import { getFilePreview } from '../../lib/appwrite'
export default function QRCode({ clientData }) {
const qrRef = useRef(null)
const [copied, setCopied] = useState(false)
const [settings, setSettings] = useState({
size: 256,
fgColor: '#000000',
bgColor: '#FFFFFF',
includeMargin: true,
includeLogo: true
})
const pageUrl = window.location.origin
const downloadQR = () => {
const svg = qrRef.current?.querySelector('svg')
if (!svg) return
const svgData = new XMLSerializer().serializeToString(svg)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = settings.size
canvas.height = settings.size
ctx.fillStyle = settings.bgColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
const pngUrl = canvas.toDataURL('image/png')
const link = document.createElement('a')
link.download = `${clientData?.businessName || 'arklinks'}-qrcode.png`
link.href = pngUrl
link.click()
}
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)))
}
const copyUrl = () => {
navigator.clipboard.writeText(pageUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const logoUrl = clientData?.logoFileId ? getFilePreview(clientData.logoFileId, 60, 60) : null
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">QR Code</h1>
</div>
<div className="grid lg:grid-cols-2 gap-6">
{/* Settings */}
<div className="space-y-6">
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="font-semibold text-gray-900 mb-4">Settings</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Size</label>
<select
value={settings.size}
onChange={(e) => setSettings(prev => ({ ...prev, size: Number(e.target.value) }))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value={128}>Small (128px)</option>
<option value={256}>Medium (256px)</option>
<option value={512}>Large (512px)</option>
<option value={1024}>Extra Large (1024px)</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">QR Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={settings.fgColor}
onChange={(e) => setSettings(prev => ({ ...prev, fgColor: e.target.value }))}
className="w-10 h-10 rounded cursor-pointer"
/>
<input
type="text"
value={settings.fgColor}
onChange={(e) => setSettings(prev => ({ ...prev, fgColor: e.target.value }))}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Background</label>
<div className="flex items-center gap-2">
<input
type="color"
value={settings.bgColor}
onChange={(e) => setSettings(prev => ({ ...prev, bgColor: e.target.value }))}
className="w-10 h-10 rounded cursor-pointer"
/>
<input
type="text"
value={settings.bgColor}
onChange={(e) => setSettings(prev => ({ ...prev, bgColor: e.target.value }))}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={settings.includeMargin}
onChange={(e) => setSettings(prev => ({ ...prev, includeMargin: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700">Include margin</span>
</label>
{logoUrl && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={settings.includeLogo}
onChange={(e) => setSettings(prev => ({ ...prev, includeLogo: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700">Include logo in center</span>
</label>
)}
</div>
</div>
{/* URL */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="font-semibold text-gray-900 mb-4">Your Link</h3>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={pageUrl}
className="flex-1 px-4 py-2 bg-gray-50 border border-gray-300 rounded-lg text-sm"
/>
<button
onClick={copyUrl}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors flex items-center gap-2"
>
{copied ? <Check size={18} className="text-green-600" /> : <Copy size={18} />}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
</div>
{/* Preview */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="font-semibold text-gray-900 mb-4">Preview</h3>
<div className="flex flex-col items-center">
<div
ref={qrRef}
className="p-4 rounded-lg"
style={{ backgroundColor: settings.bgColor }}
>
<QRCodeSVG
value={pageUrl}
size={Math.min(settings.size, 300)}
fgColor={settings.fgColor}
bgColor={settings.bgColor}
includeMargin={settings.includeMargin}
imageSettings={settings.includeLogo && logoUrl ? {
src: logoUrl,
height: Math.min(settings.size, 300) * 0.2,
width: Math.min(settings.size, 300) * 0.2,
excavate: true
} : undefined}
/>
</div>
<p className="text-sm text-gray-500 mt-4 text-center">
{clientData?.businessName || 'ArkLinks'}
</p>
<button
onClick={downloadQR}
className="mt-6 flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Download size={20} />
Download QR Code
</button>
</div>
</div>
</div>
</div>
)
}

358
src/pages/admin/Reviews.jsx Normal file
View File

@@ -0,0 +1,358 @@
import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, Star, Upload, User } from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query, uploadFile, deleteFile, getFilePreview } from '../../lib/appwrite'
export default function Reviews({ clientData }) {
const [reviews, setReviews] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingReview, setEditingReview] = useState(null)
const [avatarFile, setAvatarFile] = useState(null)
const [avatarPreview, setAvatarPreview] = useState(null)
const [formData, setFormData] = useState({
name: '',
rating: 5,
comment: '',
isEnabled: true
})
useEffect(() => {
if (clientData?.$id) {
loadReviews()
}
}, [clientData])
const loadReviews = async () => {
try {
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.REVIEWS, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
])
setReviews(response.documents)
} catch (error) {
console.error('Error loading reviews:', error)
} finally {
setLoading(false)
}
}
const openModal = (review = null) => {
if (review) {
setEditingReview(review)
setFormData({
name: review.name,
rating: review.rating,
comment: review.comment || '',
isEnabled: review.isEnabled ?? true
})
if (review.avatarFileId) {
setAvatarPreview(getFilePreview(review.avatarFileId, 100, 100))
}
} else {
setEditingReview(null)
setFormData({
name: '',
rating: 5,
comment: '',
isEnabled: true
})
setAvatarFile(null)
setAvatarPreview(null)
}
setShowModal(true)
}
const handleAvatarChange = (e) => {
const file = e.target.files[0]
if (file) {
setAvatarFile(file)
setAvatarPreview(URL.createObjectURL(file))
}
}
const handleSubmit = async (e) => {
e.preventDefault()
try {
let avatarFileId = editingReview?.avatarFileId || null
if (avatarFile) {
if (editingReview?.avatarFileId) {
await deleteFile(editingReview.avatarFileId)
}
avatarFileId = await uploadFile(avatarFile)
}
if (editingReview) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.REVIEWS, editingReview.$id, {
name: formData.name,
rating: formData.rating,
comment: formData.comment,
avatarFileId: avatarFileId,
isEnabled: formData.isEnabled
})
} else {
await databases.createDocument(DATABASE_ID, COLLECTIONS.REVIEWS, generateId(), {
clientId: clientData.$id,
name: formData.name,
rating: formData.rating,
comment: formData.comment,
avatarFileId: avatarFileId,
order: reviews.length,
isEnabled: formData.isEnabled
})
}
setShowModal(false)
setAvatarFile(null)
setAvatarPreview(null)
loadReviews()
} catch (error) {
console.error('Error saving review:', error)
alert('Error saving review')
}
}
const deleteReview = async (review) => {
if (!confirm('Are you sure you want to delete this review?')) return
try {
if (review.avatarFileId) {
await deleteFile(review.avatarFileId)
}
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.REVIEWS, review.$id)
loadReviews()
} catch (error) {
console.error('Error deleting review:', error)
}
}
const toggleEnabled = async (review) => {
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.REVIEWS, review.$id, {
isEnabled: !review.isEnabled
})
loadReviews()
} catch (error) {
console.error('Error toggling review:', error)
}
}
// Calculate average rating
const averageRating = reviews.length > 0
? (reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length).toFixed(1)
: 0
if (loading) {
return <div className="flex justify-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reviews & Ratings</h1>
{reviews.length > 0 && (
<div className="flex items-center gap-2 mt-1">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={16}
className={star <= Math.round(averageRating) ? 'text-yellow-400 fill-yellow-400' : 'text-gray-300'}
/>
))}
</div>
<span className="text-sm text-gray-600">{averageRating} average from {reviews.length} reviews</span>
</div>
)}
</div>
<button
onClick={() => openModal()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add Review
</button>
</div>
{reviews.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm p-12 text-center">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Star className="w-8 h-8 text-yellow-500" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No reviews yet</h3>
<p className="text-gray-500 mb-4">Add customer testimonials to build trust</p>
<button
onClick={() => openModal()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add First Review
</button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{reviews.map((review) => (
<div key={review.$id} className="bg-white rounded-xl shadow-sm p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{review.avatarFileId ? (
<img
src={getFilePreview(review.avatarFileId, 50, 50)}
alt={review.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<User className="w-6 h-6 text-gray-400" />
</div>
)}
<div>
<h3 className="font-medium text-gray-900">{review.name}</h3>
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={14}
className={star <= review.rating ? 'text-yellow-400 fill-yellow-400' : 'text-gray-300'}
/>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleEnabled(review)}
className={`px-2 py-1 text-xs rounded-full ${
review.isEnabled
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{review.isEnabled ? 'Visible' : 'Hidden'}
</button>
<button
onClick={() => openModal(review)}
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"
>
<Edit2 size={16} />
</button>
<button
onClick={() => deleteReview(review)}
className="p-1.5 hover:bg-red-50 rounded-lg text-red-500"
>
<Trash2 size={16} />
</button>
</div>
</div>
{review.comment && (
<p className="text-gray-600 text-sm italic">"{review.comment}"</p>
)}
</div>
))}
</div>
)}
{/* 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">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{editingReview ? 'Edit Review' : 'Add Review'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Avatar */}
<div className="flex items-center gap-4">
{avatarPreview ? (
<img src={avatarPreview} alt="Avatar" className="w-16 h-16 rounded-full object-cover" />
) : (
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
<User className="w-8 h-8 text-gray-400" />
</div>
)}
<label className="flex-1">
<div className="px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg text-center cursor-pointer hover:border-blue-500 transition-colors">
<Upload className="w-4 h-4 mx-auto text-gray-400 mb-1" />
<span className="text-xs text-gray-600">Upload photo (optional)</span>
</div>
<input type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: 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="Customer name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Rating *</label>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setFormData(prev => ({ ...prev, rating: star }))}
className="p-1"
>
<Star
size={32}
className={`transition-colors ${
star <= formData.rating
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-300 hover:text-yellow-200'
}`}
/>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Review (optional)</label>
<textarea
value={formData.comment}
onChange={(e) => setFormData(prev => ({ ...prev, comment: e.target.value }))}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="What did they say about you?"
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isEnabled}
onChange={(e) => setFormData(prev => ({ ...prev, isEnabled: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700">Show on page</span>
</label>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => { setShowModal(false); setAvatarFile(null); setAvatarPreview(null); }}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingReview ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

248
src/pages/admin/Setup.jsx Normal file
View File

@@ -0,0 +1,248 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Upload, ArrowRight, Building2 } from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, generateId, uploadFile, getFilePreview } from '../../lib/appwrite'
export default function Setup({ onSetupComplete }) {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [logoPreview, setLogoPreview] = useState(null)
const [formData, setFormData] = useState({
businessName: '',
description: '',
email: '',
phone: '',
location: '',
logoFile: null,
logoFileId: ''
})
const handleLogoChange = async (e) => {
const file = e.target.files[0]
if (file) {
setFormData(prev => ({ ...prev, logoFile: file }))
setLogoPreview(URL.createObjectURL(file))
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
try {
let logoFileId = ''
// Upload logo if exists
if (formData.logoFile) {
logoFileId = await uploadFile(formData.logoFile)
}
// Create client record
const clientId = generateId()
await databases.createDocument(
DATABASE_ID,
COLLECTIONS.CLIENTS,
clientId,
{
businessName: formData.businessName,
description: formData.description,
email: formData.email || null,
phone: formData.phone || null,
location: formData.location || null,
logoFileId: logoFileId || null,
isSetupComplete: true
}
)
// 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'
}
)
onSetupComplete()
navigate('/admin')
} catch (error) {
console.error('Setup error:', error)
alert('Setup failed. Please check your Appwrite configuration.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 to-purple-700 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg p-8">
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Building2 className="w-8 h-8 text-blue-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Welcome to ArkLinks</h1>
<p className="text-gray-500 mt-2">Let's set up your link page</p>
</div>
{/* Progress */}
<div className="flex items-center justify-center gap-2 mb-8">
{[1, 2].map((s) => (
<div
key={s}
className={`h-2 w-16 rounded-full transition-colors ${
s <= step ? 'bg-blue-600' : 'bg-gray-200'
}`}
/>
))}
</div>
<form onSubmit={handleSubmit}>
{step === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Business Name *
</label>
<input
type="text"
required
value={formData.businessName}
onChange={(e) => setFormData(prev => ({ ...prev, businessName: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Your Business Name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Tell visitors about your business..."
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Logo
</label>
<div className="flex items-center gap-4">
{logoPreview ? (
<img src={logoPreview} alt="Logo preview" className="w-16 h-16 rounded-full object-cover" />
) : (
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
<Upload className="w-6 h-6 text-gray-400" />
</div>
)}
<label className="flex-1">
<div className="px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg text-center cursor-pointer hover:border-blue-500 transition-colors">
<span className="text-sm text-gray-600">Click to upload</span>
</div>
<input
type="file"
accept="image/*"
onChange={handleLogoChange}
className="hidden"
/>
</label>
</div>
</div>
<button
type="button"
onClick={() => setStep(2)}
disabled={!formData.businessName}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Continue
<ArrowRight size={18} />
</button>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="contact@business.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="+1 234 567 8900"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Location
</label>
<input
type="text"
value={formData.location}
onChange={(e) => setFormData(prev => ({ ...prev, location: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="City, Country"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep(1)}
className="flex-1 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50"
>
Back
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 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" />
) : (
<>
Complete Setup
<ArrowRight size={18} />
</>
)}
</button>
</div>
</div>
)}
</form>
</div>
</div>
)
}

291
src/pages/admin/Social.jsx Normal file
View File

@@ -0,0 +1,291 @@
import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, GripVertical } from 'lucide-react'
import {
Facebook, Twitter, Instagram, Linkedin, Youtube, Github,
MessageCircle, Send, Music2, Globe
} from 'lucide-react'
import { databases, DATABASE_ID, COLLECTIONS, generateId, Query } from '../../lib/appwrite'
const PLATFORMS = [
{ id: 'facebook', name: 'Facebook', icon: Facebook, color: '#1877F2' },
{ id: 'twitter', name: 'X (Twitter)', icon: Twitter, color: '#000000' },
{ id: 'instagram', name: 'Instagram', icon: Instagram, color: '#E4405F' },
{ id: 'linkedin', name: 'LinkedIn', icon: Linkedin, color: '#0A66C2' },
{ id: 'youtube', name: 'YouTube', icon: Youtube, color: '#FF0000' },
{ id: 'github', name: 'GitHub', icon: Github, color: '#181717' },
{ id: 'tiktok', name: 'TikTok', icon: Music2, color: '#000000' },
{ id: 'whatsapp', name: 'WhatsApp', icon: MessageCircle, color: '#25D366' },
{ id: 'telegram', name: 'Telegram', icon: Send, color: '#26A5E4' },
{ id: 'website', name: 'Website', icon: Globe, color: '#4B5563' },
]
export default function Social({ clientData }) {
const [socialLinks, setSocialLinks] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingLink, setEditingLink] = useState(null)
const [formData, setFormData] = useState({
platform: 'facebook',
url: '',
isEnabled: true
})
useEffect(() => {
if (clientData?.$id) {
loadSocialLinks()
}
}, [clientData])
const loadSocialLinks = async () => {
try {
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, [
Query.equal('clientId', clientData.$id),
Query.orderAsc('order')
])
setSocialLinks(response.documents)
} catch (error) {
console.error('Error loading social links:', error)
} finally {
setLoading(false)
}
}
const getPlatformInfo = (platformId) => {
return PLATFORMS.find(p => p.id === platformId) || PLATFORMS[PLATFORMS.length - 1]
}
const openModal = (link = null) => {
if (link) {
setEditingLink(link)
setFormData({
platform: link.platform,
url: link.url,
isEnabled: link.isEnabled ?? true
})
} else {
setEditingLink(null)
setFormData({
platform: 'facebook',
url: '',
isEnabled: true
})
}
setShowModal(true)
}
const handleSubmit = async (e) => {
e.preventDefault()
try {
if (editingLink) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, editingLink.$id, {
platform: formData.platform,
url: formData.url,
isEnabled: formData.isEnabled
})
} else {
await databases.createDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, generateId(), {
clientId: clientData.$id,
platform: formData.platform,
url: formData.url,
order: socialLinks.length,
isEnabled: formData.isEnabled
})
}
setShowModal(false)
loadSocialLinks()
} catch (error) {
console.error('Error saving social link:', error)
alert('Error saving social link')
}
}
const deleteLink = async (linkId) => {
if (!confirm('Are you sure you want to delete this social link?')) return
try {
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, linkId)
loadSocialLinks()
} catch (error) {
console.error('Error deleting social link:', error)
}
}
const toggleEnabled = async (link) => {
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.SOCIAL_LINKS, link.$id, {
isEnabled: !link.isEnabled
})
loadSocialLinks()
} catch (error) {
console.error('Error toggling link:', error)
}
}
if (loading) {
return <div className="flex justify-center py-12"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Social Media</h1>
<button
onClick={() => openModal()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add Social
</button>
</div>
{socialLinks.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm p-12 text-center">
<div className="flex justify-center gap-2 mb-4">
{[Facebook, Instagram, Twitter].map((Icon, i) => (
<div key={i} className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<Icon className="w-6 h-6 text-gray-400" />
</div>
))}
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No social links yet</h3>
<p className="text-gray-500 mb-4">Connect your social media profiles</p>
<button
onClick={() => openModal()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={20} />
Add Social Link
</button>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm divide-y">
{socialLinks.map((link) => {
const platform = getPlatformInfo(link.platform)
const Icon = platform.icon
return (
<div key={link.$id} className="p-4 flex items-center gap-4">
<div className="text-gray-400 cursor-move">
<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>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900">{platform.name}</h3>
<p className="text-sm text-gray-500 truncate">{link.url}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleEnabled(link)}
className={`px-3 py-1 text-sm rounded-full ${
link.isEnabled
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{link.isEnabled ? 'Active' : 'Hidden'}
</button>
<button
onClick={() => openModal(link)}
className="p-2 hover:bg-gray-100 rounded-lg text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => deleteLink(link.$id)}
className="p-2 hover:bg-red-50 rounded-lg text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
)
})}
</div>
)}
{/* 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">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{editingLink ? 'Edit Social Link' : 'Add Social Link'}
</h2>
<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">
{PLATFORMS.map((platform) => {
const Icon = platform.icon
return (
<button
key={platform.id}
type="button"
onClick={() => setFormData(prev => ({ ...prev, platform: platform.id }))}
className={`p-3 rounded-lg border-2 transition-colors ${
formData.platform === platform.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
title={platform.name}
>
<Icon size={20} className="mx-auto" style={{ color: platform.color }} />
</button>
)
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">URL *</label>
<input
type="url"
required
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"
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isEnabled}
onChange={(e) => setFormData(prev => ({ ...prev, isEnabled: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700">Visible on page</span>
</label>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingLink ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

1471
src/pages/admin/Theme.jsx Normal file

File diff suppressed because it is too large Load Diff

11
tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

9
vite.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000
}
})