first commit
This commit is contained in:
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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
11
.dockerignore
Normal 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
2
.env.example
Normal 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
28
.gitignore
vendored
Normal 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
38
Dockerfile
Normal 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
125
README.md
Normal 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
14
docker-compose.yml
Normal 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
17
index.html
Normal 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
21
nginx.conf
Normal 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
2811
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal 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
160
src/App.jsx
Normal 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
|
||||
145
src/components/AdminLayout.jsx
Normal file
145
src/components/AdminLayout.jsx
Normal 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
14
src/index.css
Normal 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
69
src/lib/appwrite.js
Normal 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
10
src/main.jsx
Normal 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
29
src/pages/ComingSoon.jsx
Normal 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
809
src/pages/Home.jsx
Normal 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
280
src/pages/admin/Apps.jsx
Normal 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
344
src/pages/admin/Banners.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
src/pages/admin/Dashboard.jsx
Normal file
168
src/pages/admin/Dashboard.jsx
Normal 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
280
src/pages/admin/Gallery.jsx
Normal 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
618
src/pages/admin/Links.jsx
Normal 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
99
src/pages/admin/Login.jsx
Normal 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
317
src/pages/admin/Profile.jsx
Normal 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
204
src/pages/admin/QRCode.jsx
Normal 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
358
src/pages/admin/Reviews.jsx
Normal 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
248
src/pages/admin/Setup.jsx
Normal 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
291
src/pages/admin/Social.jsx
Normal 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
1471
src/pages/admin/Theme.jsx
Normal file
File diff suppressed because it is too large
Load Diff
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal 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
9
vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user