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