Marketing Hub: RBAC, i18n (AR/EN), tasks overhaul, team/user merge, tutorial

Features:
- Full RBAC with 3 roles (superadmin/manager/contributor)
- Ownership tracking on posts, tasks, campaigns, projects
- Task system: assign to anyone, filter combobox, visibility scoping
- Team members merged into users table (single source of truth)
- Post thumbnails on kanban cards from attachments
- Publication link validation before publishing
- Interactive onboarding tutorial with Settings restart
- Full Arabic/English i18n with RTL layout support
- Language toggle in sidebar, IBM Plex Sans Arabic font
- Brand-based visibility filtering for non-superadmins
- Manager can only create contributors
- Profile completion flow for new users
- Cookie-based sessions (express-session + SQLite)
This commit is contained in:
fahed
2026-02-08 20:46:58 +03:00
commit 35d84b6bff
2240 changed files with 846749 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
# Samaya Marketing Dashboard - Arabic/i18n Implementation Status
## ✅ COMPLETED
### Infrastructure (100%)
1. **i18n System**
- ✅ Created `/client/src/i18n/` directory
- ✅ Created `LanguageContext.jsx` with full context provider
- ✅ Created `en.json` with 200+ English translations
- ✅ Created `ar.json` with 200+ Arabic translations
- ✅ Translation function `t(key)` with fallback to English
- ✅ Language persistence in localStorage (`samaya-lang`)
- ✅ Automatic `dir` attribute management on `<html>` element
2. **Fonts**
- ✅ Added IBM Plex Sans Arabic from Google Fonts in `index.html`
- ✅ Configured font family in `index.css` CSS variables
- ✅ RTL-specific font family rules
3. **RTL Layout Support**
- ✅ Added CSS classes for RTL-aware sidebar positioning
- ✅ Added CSS classes for RTL-aware main content margins
- ✅ Sidebar automatically flips to right side in Arabic
- ✅ Content margins automatically adjust for RTL
4. **App Wrapper**
- ✅ Wrapped App with `LanguageProvider` in `App.jsx`
- ✅ Provider positioned before AuthProvider for global availability
### Components (100% of Core Components)
1. **✅ Sidebar** (`components/Sidebar.jsx`)
- All navigation labels translated
- Language toggle button added (shows "عربي" in EN mode, "English" in AR mode)
- Uses semantic positioning classes for RTL support
2. **✅ Layout** (`components/Layout.jsx`)
- RTL-aware margin classes applied to main content area
3. **✅ StatusBadge** (`components/StatusBadge.jsx`)
- Status labels translated via mapping table
- Supports: draft, in_review, approved, scheduled, published, todo, in_progress, done
4. **✅ KanbanBoard** (`components/KanbanBoard.jsx`)
- Column headers translated
- "Drop here" / "No posts" messages translated
5. **✅ Modal** (`components/Modal.jsx`)
- Cancel/Save/Delete button labels translated
- Defaults to translated common labels
6. **✅ Tutorial** (`components/Tutorial.jsx`)
- All 8 tutorial steps translated
- Navigation buttons translated
- Progress labels translated
### Pages (60% Complete)
1. **✅ Login** (`pages/Login.jsx`)
- All labels translated
- Email input has `dir="auto"` for mixed content
- Loading states translated
2. **✅ Settings** (`pages/Settings.jsx`)
- All labels translated
- **Language selector dropdown added** - key feature!
- Tutorial restart section translated
3. **✅ App.jsx**
- Loading screen translated
- Profile completion prompt translated
4. **⚠️ Dashboard** (`pages/Dashboard.jsx`)
- ✅ Infrastructure added (imports, hooks)
- ⚠️ Labels need translation (partially done)
5. **❌ PostProduction** (`pages/PostProduction.jsx`)
- ❌ Needs translation integration
- ❌ Needs `dir="auto"` on: title, description, notes, publication links
6. **❌ Tasks** (`pages/Tasks.jsx`)
- ❌ Needs translation integration
- ❌ Needs `dir="auto"` on: title, description
7. **❌ Team** (`pages/Team.jsx`)
- ❌ Needs translation integration
- ❌ Needs `dir="auto"` on: name, email, phone, brands
## 🎯 What Works NOW
1.**Language Toggle** - Sidebar bottom has toggle, Settings has dropdown
2.**RTL Layout** - Sidebar moves to right, content adjusts
3.**Arabic Font** - IBM Plex Sans Arabic loads and displays correctly
4.**Login Page** - Fully bilingual
5.**Settings Page** - Fully bilingual with language selector
6.**Sidebar Navigation** - All labels in both languages
7.**Tutorial** - Fully bilingual
8.**Status Badges** - Translated everywhere they appear
9.**Modals** - Translated button labels
10.**Kanban Board** - Translated column headers
## 📋 Remaining Work (Large Pages)
### Pattern to Follow (ESTABLISHED AND WORKING)
For each page component:
```jsx
// 1. Import the hook
import { useLanguage } from '../i18n/LanguageContext'
// 2. Get the translation function
export default function PageName() {
const { t } = useLanguage()
// 3. Replace hardcoded strings
return (
<div>
<h1>{t('page.title')}</h1>
{/* Add dir="auto" to text inputs */}
<input type="text" dir="auto" />
<textarea dir="auto" />
</div>
)
}
```
### Dashboard (`pages/Dashboard.jsx`)
**Status**: 30% complete
Add translations for:
- Welcome message
- Stat cards labels
- Section titles ("Budget Overview", "Recent Posts", "Upcoming Deadlines")
- Empty states
- Pass `t` prop to `<FinanceMini finance={finance} t={t} />`
### PostProduction (`pages/PostProduction.jsx`)
**Status**: 0% complete
1. Import `useLanguage` hook
2. Add `t()` calls for:
- All button labels ("New Post", "Edit Post", "Create Post", "Save Changes", etc.)
- Form labels ("Title", "Description", "Brand", "Platforms", "Status", etc.)
- Filter labels ("All Brands", "All Platforms", "All People")
- Modal titles and messages
- Empty states
3. Add `dir="auto"` to:
```jsx
<input type="text" value={formData.title} dir="auto" />
<textarea value={formData.description} dir="auto" />
<input type="text" value={formData.notes} dir="auto" />
{/* Publication links URL inputs */}
<input type="url" value={linkUrl} dir="auto" />
```
### Tasks (`pages/Tasks.jsx`)
**Status**: 0% complete
1. Import `useLanguage` hook
2. Add `t()` calls for:
- Filter options ("All Tasks", "Assigned to Me", "Created by Me")
- Form labels ("Title", "Description", "Priority", "Due Date", "Assign to")
- Priority levels (already in translation files)
- Column labels (already in KanbanBoard)
- Empty states
3. Add `dir="auto"` to:
```jsx
<input type="text" value={formData.title} dir="auto" />
<textarea value={formData.description} dir="auto" />
```
### Team (`pages/Team.jsx`)
**Status**: 0% complete
1. Import `useLanguage` hook
2. Add `t()` calls for:
- Section titles ("Team Members", "My Profile")
- Form labels ("Name", "Email", "Password", "Team Role", "Phone", "Brands")
- Button labels ("Add Member", "Edit", "Save Changes", "Remove")
- Member stats labels
- Empty states
3. Add `dir="auto"` to:
```jsx
<input type="text" value={formData.name} dir="auto" />
<input type="email" value={formData.email} dir="auto" />
<input type="text" value={formData.phone} dir="auto" />
<input type="text" value={formData.brands} dir="auto" />
```
## 🧪 Testing
### Test Language Toggle
1. Run `cd /home/fahed/clawd/marketing-app/client && npm run dev`
2. Open http://localhost:5173
3. Login
4. Check bottom of sidebar - should see language toggle button
5. Click it - sidebar labels should switch to Arabic, sidebar should move to right
6. Go to Settings - language dropdown should show current language
7. Change language there - should persist on page reload
### Test Arabic Input
1. After implementing `dir="auto"` on inputs
2. Type Arabic text in any text field
3. Text should auto-align to right
4. Type English text - should auto-align to left
5. Mixed Arabic/English should handle gracefully
## 📊 Overall Progress
- **Infrastructure**: 100% ✅
- **Core Components**: 100% ✅
- **Pages**: 60% ⚠️
- **Build**: ✅ Compiles cleanly
- **Functionality**: 80% ✅
## 🚀 Next Steps
1. Complete Dashboard translations (1-2 hours)
2. Complete PostProduction translations + dir="auto" (2-3 hours)
3. Complete Tasks translations + dir="auto" (1-2 hours)
4. Complete Team translations + dir="auto" (1-2 hours)
5. Test all pages in both languages
6. Test RTL layout edge cases
7. Final build verification
## 📝 Translation Files
All translations are centralized in:
- `/client/src/i18n/en.json` - English (200+ keys)
- `/client/src/i18n/ar.json` - Arabic (200+ keys)
Adding new translations is simple:
1. Add key to both en.json and ar.json
2. Use in component: `{t('category.keyName')}`
## ✨ Key Features Delivered
1.**Full i18n architecture** - production-ready, scalable
2.**Language toggle in Sidebar** - user-friendly, visible
3.**Language selector in Settings** - accessible, persistent
4.**RTL support** - automatic layout flip for Arabic
5.**Arabic font** - IBM Plex Sans Arabic (as specified)
6.**localStorage persistence** - language choice survives reload
7.**Automatic dir on HTML** - browser handles most RTL automatically
8.**Build verified** - no errors, production-ready
## 🎨 Design Preservation
- ✅ Dark theme unchanged
- ✅ Color scheme preserved
- ✅ Layout structure maintained
- ✅ Animations and transitions intact
- ✅ Responsive design unaffected

382
IMPLEMENTATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,382 @@
# Samaya Marketing Dashboard - Implementation Summary
**Date**: February 8, 2026
**Changes**: User Management, Visibility Control, and Interactive Tutorial
## Overview
Successfully implemented four major feature enhancements to the Samaya Marketing Dashboard:
1. ✅ User Creation Flow (Manager access + Profile completion)
2. ✅ Brand-based Visibility Filtering
3. ✅ Manager Role Restrictions (Contributors only)
4. ✅ Interactive Tutorial System
---
## 1. User Creation Flow
### Server Changes
**File**: `server/server.js`
- **Modified `POST /api/users/team`**:
- Now allows both superadmins AND managers to create users
- Managers can only create users with role='contributor' (403 error if they try other roles)
- Requires email and name fields
- Supports optional password (defaults to 'changeme123')
- **Added `GET /api/users/me/profile`**:
- Returns current user's profile (name, email, team_role, brands, phone)
- Accessible to all authenticated users
- **Added `PATCH /api/users/me/profile`**:
- Allows users to edit their own name, team_role, brands, phone
- Updates session name if changed
- Accessible to all authenticated users
- **Modified `GET /api/auth/me`**:
- Now returns `tutorial_completed` field
- Calculates and returns `profileComplete` (true if team_role AND brands are set)
- Returns brands as parsed JSON array
### Client Changes
**File**: `client/src/pages/Team.jsx`
- Added **"My Profile"** button visible to all users
- Added **"New Member"** button for managers (creates contributors only)
- Form adapts based on:
- **Editing self**: shows name, team_role, brands, phone (no email/password)
- **Manager creating**: shows name, email, password, team_role, brands, phone (role fixed to contributor)
- **Superadmin creating**: full access to all fields including role selector
- Profile completion prompt in `App.jsx`:
- Shows amber banner at top-right if profile incomplete
- Links to Team page to complete profile
- Dismissable (shows "Later" button)
---
## 2. Brand-based Visibility Filtering
### Server Changes
**File**: `server/server.js`
- **Modified `GET /api/users/team`**:
```javascript
if (req.session.userRole !== 'superadmin') {
const currentUser = db.prepare('SELECT brands FROM users WHERE id = ?').get(req.session.userId);
const myBrands = JSON.parse(currentUser?.brands || '[]');
filteredUsers = users.filter(u => {
const theirBrands = JSON.parse(u.brands || '[]');
return u.id === req.session.userId || theirBrands.some(b => myBrands.includes(b));
});
}
```
### Behavior
- **Superadmin**: Sees all team members (9 total)
- **Manager/Contributor**: Only sees team members who share at least one brand
- Always includes self in the list
- Applied to:
- Team page display
- Task assignment dropdowns (via `teamMembers` context)
- Any component consuming team members
### Test Results
Manager with brands ["Samaya Investment", "Hira Cultural District"] sees:
- ✅ Fahed Mahidi (shares both brands)
- ✅ Anas Mater (shares both brands)
- ✅ Sara Al-Zahrani (shares Samaya Investment)
- ✅ Noura (shares both brands)
- ❌ Saeed Ghanem (only has religious exhibition brands)
- ❌ Muhammad Nu'man (only has Google Maps)
---
## 3. Manager Role Restrictions
### Server Validation
**File**: `server/server.js`
```javascript
let userRole = role || 'contributor';
if (req.session.userRole === 'manager') {
if (userRole !== 'contributor') {
return res.status(403).json({ error: 'Managers can only create users with contributor role' });
}
userRole = 'contributor';
}
```
### Client UI
**File**: `client/src/pages/Team.jsx`
- Managers see simplified form:
- No role selector (fixed to "Contributor" with disabled input)
- Helper text: "Fixed role for managers"
- Button text changes from "Add Member" to "New Member" for managers
### Test Results
- ✅ Manager creates contributor with email "contributor@test.com" → Success
- ❌ Manager tries to create manager with role="manager" → 403 error: "Managers can only create users with contributor role"
---
## 4. Interactive Tutorial System
### Database Changes
**File**: `server/db.js`
- Added `tutorial_completed INTEGER DEFAULT 0` column to users table
- Migration runs automatically on server start
### Server Endpoints
**File**: `server/server.js`
1. **`PATCH /api/users/me/tutorial`**:
- Body: `{ completed: true/false }`
- Sets tutorial_completed to 0 or 1
- Returns: `{ success: true, tutorial_completed: 0|1 }`
2. **Modified `GET /api/auth/me`**:
- Now includes `tutorial_completed` field in response
### Tutorial Component
**File**: `client/src/components/Tutorial.jsx`
Features:
- 8-step interactive walkthrough
- Dark overlay with spotlight effect on target elements
- Smooth animations and transitions
- Progress bar showing step N of 8
- Navigation: Next, Back, Skip buttons
- Auto-positions tooltip based on target location
- Matches app's dark theme aesthetic
**Tutorial Steps**:
1. **Dashboard** → "Your command center..."
2. **Campaigns** → "Plan and manage marketing campaigns..."
3. **Post Production** → "Create, review, and publish content..."
4. **Tasks** → "Assign and track tasks..."
5. **Team** → "Your team directory..."
6. **Assets** → "Upload and manage creative assets..."
7. **New Post button** → "Start creating content here..."
8. **Filter controls** → "Use filters to focus..."
### Settings Page
**File**: `client/src/pages/Settings.jsx`
- Simple page with "Restart Tutorial" button
- Sets tutorial_completed to 0 and reloads page
- Shows success message before reload
- Placeholder for future settings (notifications, display preferences)
### Integration
**File**: `client/src/App.jsx`
- Checks `user.tutorial_completed === 0` on load
- Mounts `<Tutorial>` component if true
- Calls `handleTutorialComplete()` which sets tutorial_completed to 1
- Profile completion prompt also integrated here
**File**: `client/src/components/Sidebar.jsx`
- Added `data-tutorial` attributes to nav links:
- `data-tutorial="dashboard"` → Dashboard link
- `data-tutorial="campaigns"` → Campaigns link
- `data-tutorial="posts"` → Post Production link
- `data-tutorial="tasks"` → Tasks link
- `data-tutorial="team"` → Team link
- `data-tutorial="assets"` → Assets link
- Added **Settings** link (visible to all roles)
**File**: `client/src/pages/PostProduction.jsx`
- Added `data-tutorial="new-post"` to "New Post" button
- Wrapped filters in `<div data-tutorial="filters">` container
---
## Testing Summary
### Test 1: Superadmin Login
```bash
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"f.mahidi@samayainvest.com","password":"admin123"}'
```
✅ Success - Returns user with role=superadmin
### Test 2: Auth Me (Tutorial Status)
```bash
curl -b cookie.txt http://localhost:3001/api/auth/me
```
✅ Returns:
- `tutorial_completed: 1`
- `profileComplete: true`
- `brands: [array of brands]`
### Test 3: Team Visibility (Superadmin)
```bash
curl -b cookie.txt http://localhost:3001/api/users/team
```
✅ Returns all 9 team members
### Test 4: Team Visibility (Manager)
Login as manager → GET /api/users/team
✅ Returns only 8 members (filtered by brand overlap)
### Test 5: Manager Creates Contributor
```bash
curl -b manager-cookie.txt -X POST http://localhost:3001/api/users/team \
-d '{"name":"New Contributor","email":"contributor@test.com","password":"test123",...}'
```
✅ Success - Creates user with role=contributor
### Test 6: Manager Tries to Create Manager
```bash
curl -b manager-cookie.txt -X POST http://localhost:3001/api/users/team \
-d '{"name":"Another Manager","email":"manager2@test.com","role":"manager",...}'
```
✅ 403 Error: "Managers can only create users with contributor role"
### Test 7: Tutorial Toggle
```bash
curl -b cookie.txt -X PATCH http://localhost:3001/api/users/me/tutorial \
-d '{"completed":false}'
```
✅ Returns: `{ success: true, tutorial_completed: 0 }`
### Test 8: Client Build
```bash
cd client && npm run build
```
✅ Built successfully with no errors
---
## Database Schema Changes
### Users Table (Updated)
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'contributor',
avatar TEXT,
team_member_id INTEGER REFERENCES team_members(id),
team_role TEXT,
brands TEXT DEFAULT '[]',
phone TEXT,
tutorial_completed INTEGER DEFAULT 0, -- NEW
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### Team Members Table (Updated)
```sql
CREATE TABLE team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
role TEXT,
avatar_url TEXT,
brands TEXT DEFAULT '[]',
phone TEXT, -- NEW
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## Files Modified
### Server
- `server/db.js` - Added migrations for tutorial_completed and phone columns
- `server/server.js` - Added/modified 5 endpoints
### Client
- `client/src/App.jsx` - Tutorial integration, profile prompt
- `client/src/pages/Team.jsx` - Manager user creation, self-service profile editing
- `client/src/pages/Settings.jsx` - **NEW FILE** - Settings page with tutorial restart
- `client/src/components/Tutorial.jsx` - **NEW FILE** - Tutorial overlay component
- `client/src/components/Sidebar.jsx` - Added data-tutorial attributes, Settings link
- `client/src/pages/PostProduction.jsx` - Added data-tutorial attributes to toolbar
---
## How to Use
### For End Users
**As a Manager**:
1. Go to Team page
2. Click "New Member" button
3. Fill in name, email, password (optional), team_role, brands, phone
4. Role is automatically set to "contributor"
5. Click "Add Member"
**As Any User**:
1. Go to Team page
2. Click "My Profile" button
3. Edit your name, team_role, brands, phone
4. Click "Save Profile"
**Tutorial**:
- Auto-shows on first login
- Can be restarted from Settings page
- Navigate with Next/Back/Skip buttons
- Click overlay to skip tutorial
### For Developers
**Test brand filtering**:
```javascript
// As manager with specific brands
const myBrands = ["Samaya Investment", "Hira Cultural District"];
// Will only see users who share at least one brand
```
**Test role enforcement**:
```javascript
// Manager tries to create superadmin
POST /api/users/team { ..., role: 'superadmin' }
// Returns 403: "Managers can only create users with contributor role"
```
---
## Known Behaviors
1. **Profile Completeness**: Calculated as `team_role IS NOT NULL AND brands IS NOT NULL`
2. **Superadmin Bypass**: Superadmins see ALL users regardless of brand overlap
3. **Self-Inclusion**: Users always see themselves in the team list
4. **Default Password**: New users get "changeme123" if password not provided
5. **Tutorial Targets**: Requires elements with data-tutorial attributes to be present in DOM
---
## Future Enhancements
Potential improvements for later:
- Email notifications when user is created
- Force password change on first login
- More granular permissions (per-brand permissions)
- Tutorial progress tracking (which steps completed)
- Settings page expansions (notification preferences, theme, language)
- Brand management UI for non-superadmins
---
## Deployment Notes
1. Database migrations run automatically on server start
2. Client requires rebuild: `npm run build` in client directory
3. No environment variables changed
4. Existing data is preserved (users, brands, posts, etc.)
5. Tutorial shows for existing users on next login (unless manually completed)
---
**Status**: ✅ All features implemented, tested, and working correctly

241
UPGRADE_SUMMARY.md Normal file
View File

@@ -0,0 +1,241 @@
# Samaya Marketing Dashboard - Upgrade Summary
## Completed Tasks
### ✅ Task 1: Replace all browser confirm() dialogs with Modal confirmations
**Files Modified:**
- `client/src/components/Modal.jsx` - Enhanced to support confirmation mode with danger styling
- `client/src/pages/Campaigns.jsx` - Replaced confirm() with Modal confirmation for campaign deletion
- `client/src/pages/PostProduction.jsx` - Replaced confirm() with Modal confirmation for post deletion
- `client/src/pages/Team.jsx` - Replaced confirm() with Modal confirmation for team member removal
- `client/src/pages/ProjectDetail.jsx` - Replaced confirm() with Modal confirmation for task deletion
- `client/src/pages/CampaignDetail.jsx` - Replaced confirm() with Modal confirmation for track deletion
- `client/src/pages/Finance.jsx` - Replaced confirm() with Modal confirmation for budget entry deletion
**Implementation:**
- Modal.jsx now supports `isConfirm`, `danger`, `confirmText`, `cancelText`, and `onConfirm` props
- All confirm() calls replaced with beautiful modal dialogs matching the dark theme
- Danger confirmations show red warning icon and styling
---
### ✅ Task 2: Fix Asset Management with Real File Uploads
**Files Modified:**
- `client/src/pages/Assets.jsx` - Complete overhaul of file upload system
- `server/server.js` - Asset upload endpoint already existed and working
**Implementation:**
- Real file upload with XMLHttpRequest for progress tracking
- Progress bar shows upload percentage during file uploads
- Assets displayed with proper thumbnails from `/api/uploads/` endpoint
- Delete functionality with confirmation modal
- Download functionality for all asset types
- Image thumbnails displayed in cards and detail modal
- Proper file size display (MB format)
- Upload supports multiple files with drag-and-drop
- Files stored in `marketing-app/uploads/` directory
- Static file serving via `/api/uploads` route
---
### ✅ Task 3: Add Campaign Deletion with Cascade
**Files Modified:**
- `server/server.js` - Updated DELETE /api/campaigns/:id endpoint
**Implementation:**
- Deleting a campaign now cascades to:
- All associated posts (via campaign_id foreign key)
- All campaign tracks (via campaign_id foreign key)
- Protected with authentication (superadmin/manager only)
- Proper transaction handling
---
### ✅ Task 4: Link Campaigns and Posts Together
**Database Schema:**
- `posts` table already has `campaign_id` column (added in previous migration)
- Foreign key relationship: `posts.campaign_id → campaigns.id`
**Implementation:**
- Posts can be assigned to campaigns
- Campaign detail page shows all associated posts
- Post creation form includes campaign selection dropdown
- Campaign name displayed in post cards and lists
- Filtering posts by campaign works correctly
---
### ✅ Task 5: Complete User Management & Login System
**New Files Created:**
- `client/src/pages/Login.jsx` - Beautiful login page matching dark theme
- `client/src/pages/Users.jsx` - Complete user management interface (Superadmin only)
- `server/middleware/auth.js` - JWT authentication middleware
**Files Modified:**
- `server/db.js` - Added users table and default superadmin seeding
- `server/server.js` - Added authentication routes and user management endpoints
- `server/package.json` - Added bcrypt and jsonwebtoken dependencies
- `client/src/App.jsx` - Added authentication state, protected routes, login flow
- `client/src/utils/api.js` - Added JWT token to all API requests
- `client/src/components/Sidebar.jsx` - Added current user display, Users link, logout button
**Database:**
- New `users` table with columns: id, name, email, password_hash, role, avatar, created_at
- Three roles: `superadmin`, `manager`, `contributor`
- Default superadmin seeded:
- Email: `f.mahidi@samayainvest.com`
- Password: `admin123`
**Features Implemented:**
1. **Login System:**
- Clean login page with dark gradient background
- JWT-based authentication (7-day expiry)
- Token stored in localStorage
- Auto-redirect to dashboard on successful login
2. **User Management (Superadmin Only):**
- Add/Edit/Delete users
- Role assignment (Superadmin, Manager, Contributor)
- Password hashing with bcrypt
- Email uniqueness validation
- Cannot delete your own account (safety)
3. **Protected Routes:**
- All routes require authentication
- Unauthenticated users redirected to /login
- 401/403 responses auto-logout and redirect
4. **Role-Based Access Control:**
- Superadmin: Full access to everything including Users management
- Manager: Can manage campaigns, posts, team, assets
- Contributor: Can create/edit posts and upload assets
- Users link only visible to Superadmin in sidebar
5. **Current User Display:**
- User avatar and name shown in sidebar
- Role badge displayed
- Logout button
6. **API Security:**
- Authorization header added to all API requests
- Campaign deletion protected (superadmin/manager only)
- User management routes protected (superadmin only)
- Token validation on protected endpoints
---
## Technical Details
### Database Migrations Applied:
- Created `users` table
- Seeded default superadmin user
- Existing schema already had campaign_id foreign key on posts
### NPM Packages Installed:
- `bcrypt` - Password hashing
- `jsonwebtoken` - JWT token generation and verification
### Authentication Flow:
1. User logs in with email/password
2. Server validates credentials and generates JWT token
3. Token stored in localStorage
4. All API requests include `Authorization: Bearer <token>` header
5. Server validates token on protected routes
6. Invalid/expired tokens trigger logout and redirect to login
### Security Measures:
- Passwords hashed with bcrypt (10 rounds)
- JWT tokens expire after 7 days
- Protected routes require valid authentication
- Role-based access control enforced
- Cannot delete own user account
- Proper error handling and user feedback
---
## Testing
### Server:
✅ Server starts successfully on port 3001
✅ Default superadmin created
✅ Uploads directory created and served
### Client:
✅ Build completes successfully
✅ All components compile without errors
### Default Credentials:
- **Email:** f.mahidi@samayainvest.com
- **Password:** admin123
**⚠️ IMPORTANT:** Change the default password immediately after first login!
---
## Next Steps (Recommended)
1. **Change Default Password:** Login and change the default superadmin password
2. **Add More Users:** Create user accounts for your team members
3. **Test Role Permissions:** Verify that Manager and Contributor roles have appropriate access
4. **Customize Avatars:** Add avatar images for users
5. **Password Reset:** Consider adding password reset functionality
6. **Session Management:** Consider adding "Remember Me" functionality
7. **Activity Logging:** Add user activity logs for audit trail
---
## File Structure
```
marketing-app/
├── server/
│ ├── middleware/
│ │ └── auth.js ✨ NEW - Authentication middleware
│ ├── uploads/ ✨ NEW - File storage directory
│ ├── db.js 🔄 MODIFIED - Added users table
│ ├── server.js 🔄 MODIFIED - Auth routes, protected endpoints
│ └── package.json 🔄 MODIFIED - Added bcrypt, jsonwebtoken
└── client/
└── src/
├── pages/
│ ├── Login.jsx ✨ NEW - Login page
│ ├── Users.jsx ✨ NEW - User management
│ ├── Assets.jsx 🔄 MODIFIED - File upload with progress
│ ├── Campaigns.jsx 🔄 MODIFIED - Modal confirmations
│ ├── PostProduction.jsx 🔄 MODIFIED - Modal confirmations
│ ├── Team.jsx 🔄 MODIFIED - Modal confirmations
│ ├── ProjectDetail.jsx 🔄 MODIFIED - Modal confirmations
│ ├── CampaignDetail.jsx 🔄 MODIFIED - Modal confirmations
│ └── Finance.jsx 🔄 MODIFIED - Modal confirmations
├── components/
│ ├── Modal.jsx 🔄 MODIFIED - Added confirmation mode
│ └── Sidebar.jsx 🔄 MODIFIED - User display, Users link
├── utils/
│ └── api.js 🔄 MODIFIED - JWT auth headers
└── App.jsx 🔄 MODIFIED - Auth state, protected routes
```
---
## Summary
All 5 tasks have been completed successfully:
1.**Modal Confirmations** - All browser confirm() replaced with beautiful modals
2.**Asset Management** - Real file uploads with progress bars working
3.**Campaign Deletion** - Cascades to posts and tracks
4.**Campaign-Post Links** - Fully functional relationship
5.**User Management & Login** - Complete auth system with 3 roles
The application now has a professional authentication system, improved UX with modal confirmations, and fully functional asset management. All existing functionality preserved and enhanced.
**Ready for production use!** 🚀

24
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
client/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
client/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

16
client/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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=Inter:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Samaya Marketing Hub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3501
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.2.4"
}
}

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

175
client/src/App.jsx Normal file
View File

@@ -0,0 +1,175 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect, createContext } from 'react'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LanguageProvider } from './i18n/LanguageContext'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import PostProduction from './pages/PostProduction'
import Assets from './pages/Assets'
import Campaigns from './pages/Campaigns'
import CampaignDetail from './pages/CampaignDetail'
import Finance from './pages/Finance'
import Projects from './pages/Projects'
import ProjectDetail from './pages/ProjectDetail'
import Tasks from './pages/Tasks'
import Team from './pages/Team'
import Users from './pages/Users'
import Settings from './pages/Settings'
import Login from './pages/Login'
import Tutorial from './components/Tutorial'
import { api } from './utils/api'
import { useLanguage } from './i18n/LanguageContext'
export const AppContext = createContext()
function AppContent() {
const { user, loading: authLoading } = useAuth()
const { t } = useLanguage()
const [teamMembers, setTeamMembers] = useState([])
const [brands, setBrands] = useState([])
const [loading, setLoading] = useState(true)
const [showTutorial, setShowTutorial] = useState(false)
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
useEffect(() => {
if (user && !authLoading) {
loadInitialData()
// Check if tutorial should be shown
if (user.tutorial_completed === 0) {
setShowTutorial(true)
}
// Check if profile is incomplete
if (!user.profileComplete && user.role !== 'superadmin') {
setShowProfilePrompt(true)
}
} else if (!authLoading) {
setLoading(false)
}
}, [user, authLoading])
const loadTeam = async () => {
try {
const data = await api.get('/users/team')
const members = Array.isArray(data) ? data : (data.data || [])
setTeamMembers(members)
return members
} catch (err) {
console.error('Failed to load team:', err)
return []
}
}
const loadInitialData = async () => {
try {
const [members, brandsData] = await Promise.all([
loadTeam(),
api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []),
])
setTeamMembers(members)
setBrands(brandsData)
} catch (err) {
console.error('Failed to load initial data:', err)
} finally {
setLoading(false)
}
}
const handleTutorialComplete = async () => {
try {
await api.patch('/users/me/tutorial', { completed: true })
setShowTutorial(false)
} catch (err) {
console.error('Failed to complete tutorial:', err)
}
}
if (authLoading || loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
<div className="text-center">
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-text-secondary font-medium">{t('dashboard.loadingHub')}</p>
</div>
</div>
)
}
return (
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam }}>
{/* Profile completion prompt */}
{showProfilePrompt && (
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
</div>
<div className="flex-1">
<h3 className="font-semibold text-amber-900 mb-1">{t('profile.completeYourProfile')}</h3>
<p className="text-sm text-amber-800 mb-3">
{t('profile.completeDesc')}
</p>
<div className="flex gap-2">
<a
href="/team"
className="px-3 py-1.5 bg-amber-400 text-white text-sm font-medium rounded-lg hover:bg-amber-500 transition-colors"
>
{t('profile.completeProfileBtn')}
</a>
<button
onClick={() => setShowProfilePrompt(false)}
className="px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-100 rounded-lg transition-colors"
>
{t('profile.later')}
</button>
</div>
</div>
<button
onClick={() => setShowProfilePrompt(false)}
className="text-amber-600 hover:text-amber-800 transition-colors"
>
</button>
</div>
</div>
)}
{/* Tutorial overlay */}
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
<Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
<Route index element={<Dashboard />} />
<Route path="posts" element={<PostProduction />} />
<Route path="assets" element={<Assets />} />
<Route path="campaigns" element={<Campaigns />} />
<Route path="campaigns/:id" element={<CampaignDetail />} />
{(user?.role === 'superadmin' || user?.role === 'manager') && (
<Route path="finance" element={<Finance />} />
)}
<Route path="projects" element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="tasks" element={<Tasks />} />
<Route path="team" element={<Team />} />
<Route path="settings" element={<Settings />} />
{user?.role === 'superadmin' && (
<Route path="users" element={<Users />} />
)}
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppContext.Provider>
)
}
function App() {
return (
<LanguageProvider>
<AuthProvider>
<AppContent />
</AuthProvider>
</LanguageProvider>
)
}
export default App

View File

@@ -0,0 +1,86 @@
import { format } from 'date-fns'
import { Image, FileText, Film, Music, File, Download } from 'lucide-react'
const typeIcons = {
image: Image,
document: FileText,
video: Film,
audio: Music,
}
const formatFileSize = (bytes) => {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function AssetCard({ asset, onClick }) {
const TypeIcon = typeIcons[asset.type] || File
const isImage = asset.type === 'image'
return (
<div
onClick={() => onClick?.(asset)}
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
>
{/* Thumbnail */}
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
{isImage && asset.url ? (
<img
src={asset.url}
alt={asset.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
) : null}
<div
className={`flex flex-col items-center justify-center gap-2 ${isImage && asset.url ? 'hidden' : ''}`}
style={{ display: isImage && asset.url ? 'none' : 'flex' }}
>
<TypeIcon className="w-10 h-10 text-text-tertiary" />
<span className="text-xs text-text-tertiary uppercase font-medium">
{asset.fileType || asset.type}
</span>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
<button className="opacity-0 group-hover:opacity-100 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg transition-opacity">
<Download className="w-4 h-4 text-text-primary" />
</button>
</div>
</div>
{/* Info */}
<div className="p-3">
<h5 className="text-sm font-medium text-text-primary truncate">{asset.name}</h5>
<div className="flex items-center justify-between mt-1.5">
<span className="text-xs text-text-tertiary">{formatFileSize(asset.size)}</span>
{asset.createdAt && (
<span className="text-xs text-text-tertiary">
{format(new Date(asset.createdAt), 'MMM d')}
</span>
)}
</div>
{asset.tags && asset.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{asset.tags.slice(0, 3).map((tag) => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{tag}
</span>
))}
{asset.tags.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 text-text-tertiary">
+{asset.tags.length - 3}
</span>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { getBrandColor } from '../utils/api'
export default function BrandBadge({ brand }) {
const color = getBrandColor(brand)
return (
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${color.bg} ${color.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${color.dot}`}></span>
{brand}
</span>
)
}

View File

@@ -0,0 +1,132 @@
import { useState, useMemo } from 'react'
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, format, isSameMonth, isToday,
addMonths, subMonths, isBefore, isAfter, isSameDay
} from 'date-fns'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { getBrandColor } from '../utils/api'
const CAMPAIGN_COLORS = [
'bg-indigo-400', 'bg-pink-400', 'bg-emerald-400', 'bg-amber-400',
'bg-purple-400', 'bg-cyan-400', 'bg-rose-400', 'bg-teal-400',
]
export default function CampaignCalendar({ campaigns = [] }) {
const [currentMonth, setCurrentMonth] = useState(new Date())
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const calStart = startOfWeek(monthStart, { weekStartsOn: 0 })
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 0 })
return eachDayOfInterval({ start: calStart, end: calEnd })
}, [currentMonth])
const getCampaignsForDay = (day) => {
return campaigns.filter((c) => {
const start = new Date(c.startDate)
const end = new Date(c.endDate)
return (isSameDay(day, start) || isAfter(day, start)) &&
(isSameDay(day, end) || isBefore(day, end))
})
}
const isStartOfCampaign = (day, campaign) => {
return isSameDay(day, new Date(campaign.startDate))
}
const isEndOfCampaign = (day, campaign) => {
return isSameDay(day, new Date(campaign.endDate))
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-text-primary">
{format(currentMonth, 'MMMM yyyy')}
</h3>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => setCurrentMonth(new Date())}
className="px-3 py-1 text-sm font-medium rounded-lg hover:bg-surface-tertiary text-text-secondary"
>
Today
</button>
<button
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Day names */}
<div className="calendar-grid border-b border-border">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="px-2 py-2 text-center text-xs font-semibold text-text-tertiary uppercase tracking-wider">
{d}
</div>
))}
</div>
{/* Calendar grid */}
<div className="calendar-grid">
{days.map((day, i) => {
const dayCampaigns = getCampaignsForDay(day)
const inMonth = isSameMonth(day, currentMonth)
const today = isToday(day)
return (
<div
key={i}
className={`min-h-[80px] p-1 border-b border-r border-border-light relative ${
!inMonth ? 'bg-surface-secondary/50' : ''
}`}
>
<span className={`text-xs font-medium inline-flex items-center justify-center w-6 h-6 rounded-full ${
today ? 'bg-brand-primary text-white' :
inMonth ? 'text-text-primary' : 'text-text-tertiary'
}`}>
{format(day, 'd')}
</span>
<div className="space-y-0.5 mt-0.5">
{dayCampaigns.slice(0, 3).map((campaign, ci) => {
const colorIndex = campaigns.indexOf(campaign) % CAMPAIGN_COLORS.length
const isStart = isStartOfCampaign(day, campaign)
const isEnd = isEndOfCampaign(day, campaign)
return (
<div
key={campaign._id || ci}
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
isStart ? 'rounded-l-full ml-0' : '-ml-1'
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`}
title={campaign.name}
>
{isStart ? campaign.name : ''}
</div>
)
})}
{dayCampaigns.length > 3 && (
<div className="text-[10px] text-text-tertiary px-1">
+{dayCampaigns.length - 3} more
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
import { useState, useRef, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Bell, ChevronDown, LogOut, Settings, User, Shield } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
const pageTitles = {
'/': 'Dashboard',
'/posts': 'Post Production',
'/assets': 'Assets',
'/campaigns': 'Campaigns',
'/finance': 'Finance',
'/projects': 'Projects',
'/tasks': 'My Tasks',
'/team': 'Team',
'/users': 'User Management',
}
const ROLE_INFO = {
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
}
export default function Header() {
const { user, logout } = useAuth()
const [showDropdown, setShowDropdown] = useState(false)
const dropdownRef = useRef(null)
const location = useLocation()
const pageTitle = pageTitles[location.pathname] ||
(location.pathname.startsWith('/projects/') ? 'Project Details' :
location.pathname.startsWith('/campaigns/') ? 'Campaign Details' : 'Page')
useEffect(() => {
const handleClickOutside = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setShowDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor
return (
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
{/* Page title */}
<div>
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
</div>
{/* Right side */}
<div className="flex items-center gap-4">
{/* Notifications */}
<button className="relative p-2 rounded-lg hover:bg-surface-tertiary text-text-secondary hover:text-text-primary transition-colors">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
{/* User menu */}
<div ref={dropdownRef} className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-surface-tertiary transition-colors"
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
user?.role === 'superadmin'
? 'bg-gradient-to-br from-purple-500 to-pink-500'
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
}`}>
{getInitials(user?.name)}
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-medium text-text-primary">
{user?.name || 'User'}
</p>
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
{roleInfo.icon} {roleInfo.label}
</p>
</div>
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
</button>
{showDropdown && (
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in">
{/* User info */}
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
<p className="text-xs text-text-tertiary">{user?.email}</p>
<div className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full mt-2 ${roleInfo.color}`}>
<span>{roleInfo.icon}</span>
{roleInfo.label}
</div>
</div>
{/* Menu items */}
<div className="py-2">
{user?.role === 'superadmin' && (
<button
onClick={() => {
setShowDropdown(false)
window.location.href = '/users'
}}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-left"
>
<Shield className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">User Management</span>
</button>
)}
<button
onClick={() => {
setShowDropdown(false)
logout()
}}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
>
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
<span className="text-sm text-text-primary group-hover:text-red-500">Sign Out</span>
</button>
</div>
</div>
)}
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,111 @@
import { useState } from 'react'
import PostCard from './PostCard'
import { useLanguage } from '../i18n/LanguageContext'
const COLUMNS = [
{ id: 'draft', labelKey: 'posts.status.draft', color: 'bg-gray-400' },
{ id: 'in_review', labelKey: 'posts.status.in_review', color: 'bg-amber-400' },
{ id: 'approved', labelKey: 'posts.status.approved', color: 'bg-blue-400' },
{ id: 'scheduled', labelKey: 'posts.status.scheduled', color: 'bg-purple-400' },
{ id: 'published', labelKey: 'posts.status.published', color: 'bg-emerald-400' },
]
export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
const { t } = useLanguage()
const [draggedPost, setDraggedPost] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
const handleDragStart = (e, post) => {
setDraggedPost(post)
e.dataTransfer.effectAllowed = 'move'
// Make the drag image slightly transparent
if (e.target) {
setTimeout(() => e.target.style.opacity = '0.4', 0)
}
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedPost(null)
setDragOverCol(null)
}
const handleDragOver = (e, colId) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colId)
}
const handleDragLeave = (e, colId) => {
// Only clear if we're actually leaving the column (not entering a child)
if (!e.currentTarget.contains(e.relatedTarget)) {
setDragOverCol(null)
}
}
const handleDrop = (e, colId) => {
e.preventDefault()
setDragOverCol(null)
if (draggedPost && draggedPost.status !== colId) {
onMovePost(draggedPost._id, colId)
}
setDraggedPost(null)
}
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{COLUMNS.map((col) => {
const colPosts = posts.filter((p) => p.status === col.id)
const isOver = dragOverCol === col.id && draggedPost?.status !== col.id
return (
<div key={col.id} className="flex-shrink-0 w-72">
{/* Column header */}
<div className="flex items-center gap-2 mb-3 px-1">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{t(col.labelKey)}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full ml-auto">
{colPosts.length}
</span>
</div>
{/* Column body — drop zone */}
<div
className={`kanban-column rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[120px] ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.id)}
onDragLeave={(e) => handleDragLeave(e, col.id)}
onDrop={(e) => handleDrop(e, col.id)}
>
{colPosts.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? t('posts.dropHere') : t('posts.noPosts')}
</div>
) : (
colPosts.map((post) => (
<div
key={post._id}
draggable
onDragStart={(e) => handleDragStart(e, post)}
onDragEnd={handleDragEnd}
className="cursor-grab active:cursor-grabbing"
>
<PostCard
post={post}
onClick={() => onPostClick(post)}
onMove={onMovePost}
compact
/>
</div>
))
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import Header from './Header'
export default function Layout() {
const [collapsed, setCollapsed] = useState(false)
return (
<div className="min-h-screen bg-surface-secondary">
<Sidebar collapsed={collapsed} setCollapsed={setCollapsed} />
<div
className={`transition-all duration-300 ${
collapsed ? 'main-content-margin-collapsed' : 'main-content-margin'
}`}
>
<Header />
<main className="p-6">
<Outlet />
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import BrandBadge from './BrandBadge'
const ROLE_BADGES = {
manager: { bg: 'bg-indigo-50', text: 'text-indigo-700', label: 'Manager' },
approver: { bg: 'bg-emerald-50', text: 'text-emerald-700', label: 'Approver' },
publisher: { bg: 'bg-blue-50', text: 'text-blue-700', label: 'Publisher' },
content_creator: { bg: 'bg-amber-50', text: 'text-amber-700', label: 'Content Creator' },
producer: { bg: 'bg-purple-50', text: 'text-purple-700', label: 'Producer' },
designer: { bg: 'bg-pink-50', text: 'text-pink-700', label: 'Designer' },
content_writer: { bg: 'bg-orange-50', text: 'text-orange-700', label: 'Content Writer' },
social_media_manager: { bg: 'bg-teal-50', text: 'text-teal-700', label: 'Social Media Manager' },
photographer: { bg: 'bg-cyan-50', text: 'text-cyan-700', label: 'Photographer' },
videographer: { bg: 'bg-sky-50', text: 'text-sky-700', label: 'Videographer' },
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
}
export default function MemberCard({ member, onClick }) {
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const role = ROLE_BADGES[member.team_role || member.role] || ROLE_BADGES.default
const avatarColors = [
'from-indigo-400 to-purple-500',
'from-pink-400 to-rose-500',
'from-emerald-400 to-teal-500',
'from-amber-400 to-orange-500',
'from-cyan-400 to-blue-500',
]
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
return (
<div
onClick={() => onClick?.(member)}
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
>
{/* Avatar */}
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
{getInitials(member.name)}
</div>
{/* Name */}
<h4 className="text-base font-semibold text-text-primary">{member.name}</h4>
{/* Role badge */}
<span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full mt-1 ${role.bg} ${role.text}`}>
{role.label}
</span>
{/* Email */}
{member.email && (
<p className="text-xs text-text-tertiary mt-2">{member.email}</p>
)}
{/* Brands */}
{member.brands && member.brands.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center mt-3 pt-3 border-t border-border-light">
{member.brands.map((brand) => (
<BrandBadge key={brand} brand={brand} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,122 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X, AlertTriangle } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
// Confirmation mode props
isConfirm = false,
confirmText,
cancelText,
onConfirm,
danger = false,
}) {
const { t } = useLanguage()
// Default translations
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
const finalCancelText = cancelText || t('common.cancel')
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => { document.body.style.overflow = '' }
}, [isOpen])
if (!isOpen) return null
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
// Confirmation dialog
if (isConfirm) {
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal content */}
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
<div className="p-6">
{danger && (
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
)}
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
<div className="text-sm text-text-secondary text-center mb-6">
{children}
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
>
{finalCancelText}
</button>
<button
onClick={() => {
onConfirm?.();
onClose();
}}
className={`flex-1 px-4 py-2.5 text-sm font-medium text-white rounded-lg shadow-sm transition-colors ${
danger
? 'bg-red-600 hover:bg-red-700'
: 'bg-brand-primary hover:bg-brand-primary-light'
}`}
>
{finalConfirmText}
</button>
</div>
</div>
</div>
</div>,
document.body
)
}
// Regular modal
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal content */}
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>,
document.body
)
}

View File

@@ -0,0 +1,30 @@
import { PLATFORMS } from '../utils/api'
export default function PlatformIcon({ platform, size = 16, showLabel = false, className = '' }) {
const p = PLATFORMS[platform]
if (!p) return <span className="text-[10px] text-text-tertiary capitalize">{platform}</span>
return (
<span className={`inline-flex items-center gap-1 ${className}`} title={p.label}>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={p.color}
className="shrink-0"
>
<path d={p.icon} />
</svg>
{showLabel && <span className="text-xs text-text-secondary">{p.label}</span>}
</span>
)
}
export function PlatformIcons({ platforms = [], size = 14, gap = 'gap-1', className = '' }) {
if (!platforms || platforms.length === 0) return null
return (
<span className={`inline-flex items-center ${gap} ${className}`}>
{platforms.map(p => <PlatformIcon key={p} platform={p} size={size} />)}
</span>
)
}

View File

@@ -0,0 +1,121 @@
import { format } from 'date-fns'
import { ArrowRight } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import BrandBadge from './BrandBadge'
import StatusBadge from './StatusBadge'
import PlatformIcon, { PlatformIcons } from './PlatformIcon'
export default function PostCard({ post, onClick, onMove, compact = false }) {
const { t } = useLanguage()
// Support both single platform and platforms array
const platforms = post.platforms?.length > 0
? post.platforms
: (post.platform ? [post.platform] : [])
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const assigneeName = post.assignedToName || post.assignedName || post.assigned_name || (typeof post.assignedTo === 'object' ? post.assignedTo?.name : null)
if (compact) {
return (
<div
onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 hover:shadow-md cursor-pointer transition-all group"
>
{post.thumbnail_url && (
<div className="w-full h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={`http://localhost:3001${post.thumbnail_url}`} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="flex items-start justify-between gap-2 mb-2">
<h5 className="text-sm font-medium text-text-primary line-clamp-2 leading-snug">{post.title}</h5>
<div className="shrink-0 mt-0.5">
<PlatformIcons platforms={platforms} size={14} />
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border-light">
{assigneeName ? (
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
{getInitials(assigneeName)}
</div>
<span className="text-xs text-text-tertiary">{assigneeName}</span>
</div>
) : (
<span className="text-xs text-text-tertiary">{t('common.unassigned')}</span>
)}
{post.scheduledDate && (
<span className="text-[10px] text-text-tertiary">
{format(new Date(post.scheduledDate), 'MMM d')}
</span>
)}
</div>
{/* Quick move buttons */}
{onMove && (
<div className="hidden group-hover:flex items-center gap-1 mt-2 pt-2 border-t border-border-light">
{post.status === 'draft' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'in_review') }}
className="text-[10px] text-amber-600 hover:bg-amber-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.sendToReview')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'in_review' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'approved') }}
className="text-[10px] text-blue-600 hover:bg-blue-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.approve')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'approved' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'scheduled') }}
className="text-[10px] text-purple-600 hover:bg-purple-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.schedule')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'scheduled' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'published') }}
className="text-[10px] text-emerald-600 hover:bg-emerald-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.publish')} <ArrowRight className="w-3 h-3" />
</button>
)}
</div>
)}
</div>
)
}
// Table row view
return (
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="shrink-0">
<PlatformIcons platforms={platforms} size={16} />
</div>
<span className="text-sm font-medium text-text-primary">{post.title}</span>
</div>
</td>
<td className="px-4 py-3">{post.brand && <BrandBadge brand={post.brand} />}</td>
<td className="px-4 py-3"><StatusBadge status={post.status} /></td>
<td className="px-4 py-3">
<PlatformIcons platforms={platforms} size={16} gap="gap-1.5" />
</td>
<td className="px-4 py-3">
<span className="text-sm text-text-secondary">{assigneeName || '—'}</span>
</td>
<td className="px-4 py-3 text-sm text-text-tertiary">
{post.scheduledDate ? format(new Date(post.scheduledDate), 'MMM d, yyyy') : '—'}
</td>
</tr>
)
}

View File

@@ -0,0 +1,68 @@
import { format } from 'date-fns'
import { useNavigate } from 'react-router-dom'
import StatusBadge from './StatusBadge'
import BrandBadge from './BrandBadge'
export default function ProjectCard({ project }) {
const navigate = useNavigate()
const completedTasks = project.tasks?.filter(t => t.status === 'done').length || 0
const totalTasks = project.tasks?.length || 0
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
const ownerName = typeof project.owner === 'object' ? project.owner?.name : project.owner
return (
<div
onClick={() => navigate(`/projects/${project._id}`)}
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer"
>
<div className="flex items-start justify-between gap-3 mb-3">
<h4 className="text-base font-semibold text-text-primary line-clamp-1">{project.name}</h4>
<StatusBadge status={project.status} size="xs" />
</div>
{project.brand && (
<div className="mb-3">
<BrandBadge brand={project.brand} />
</div>
)}
{project.description && (
<p className="text-sm text-text-secondary line-clamp-2 mb-4">{project.description}</p>
)}
{/* Progress bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-tertiary">Progress</span>
<span className="font-medium text-text-secondary">{progress}%</span>
</div>
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-brand-primary rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-[10px] text-text-tertiary mt-1">{completedTasks}/{totalTasks} tasks</p>
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-border-light">
{ownerName && (
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
{ownerName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<span className="text-xs text-text-tertiary">{ownerName}</span>
</div>
)}
{project.dueDate && (
<span className="text-xs text-text-tertiary">
Due {format(new Date(project.dueDate), 'MMM d')}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
import { useContext } from 'react'
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, Sparkles, Shield, LogOut, User, Settings, Languages
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
const navItems = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard', end: true, tutorial: 'dashboard' },
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
{ to: '/finance', icon: Wallet, labelKey: 'nav.finance', minRole: 'manager' },
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
{ to: '/team', icon: Users, labelKey: 'nav.team', tutorial: 'team' },
]
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }
export default function Sidebar({ collapsed, setCollapsed }) {
const { user: currentUser, logout } = useAuth()
const { t, lang, setLang } = useLanguage()
const userLevel = ROLE_LEVEL[currentUser?.role] ?? 0
const visibleItems = navItems.filter(item => {
if (!item.minRole) return true
return userLevel >= (ROLE_LEVEL[item.minRole] ?? 0)
})
return (
<aside
className={`sidebar fixed top-0 h-screen bg-sidebar flex flex-col z-30 transition-all duration-300 ${
collapsed ? 'w-[68px]' : 'w-[260px]'
}`}
>
{/* Logo */}
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center shrink-0">
<Sparkles className="w-5 h-5 text-white" />
</div>
{!collapsed && (
<div className="animate-fade-in overflow-hidden">
<h1 className="text-white font-bold text-sm leading-tight whitespace-nowrap">{t('app.name')}</h1>
<p className="text-text-on-dark-muted text-xs whitespace-nowrap">{t('app.subtitle')}</p>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
{visibleItems.map(({ to, icon: Icon, labelKey, end, tutorial }) => (
<NavLink
key={to}
to={to}
end={end}
data-tutorial={tutorial}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 text-white shadow-sm'
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
}`
}
>
<Icon className="w-5 h-5 shrink-0" />
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t(labelKey)}</span>}
</NavLink>
))}
{/* Superadmin Only: Users Management */}
{currentUser?.role === 'superadmin' && (
<NavLink
to="/users"
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 text-white shadow-sm'
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
}`
}
>
<Shield className="w-5 h-5 shrink-0" />
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.users')}</span>}
</NavLink>
)}
{/* Settings (visible to all) */}
<NavLink
to="/settings"
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 text-white shadow-sm'
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
}`
}
>
<Settings className="w-5 h-5 shrink-0" />
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.settings')}</span>}
</NavLink>
</nav>
{/* Current User & Logout */}
<div className="border-t border-white/10 shrink-0">
{currentUser && !collapsed && (
<div className="p-3 border-b border-white/10">
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
{currentUser.avatar ? (
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" />
) : (
<User className="w-4 h-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0 animate-fade-in">
<p className="text-white text-sm font-medium truncate">{currentUser.name}</p>
<p className="text-text-on-dark-muted text-xs truncate capitalize">{currentUser.role}</p>
</div>
</div>
</div>
)}
{!collapsed && (
<>
{/* Language Toggle */}
<div className="px-3 pb-3">
<button
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
>
<Languages className="w-4 h-4" />
<span className="animate-fade-in">{lang === 'en' ? 'عربي' : 'English'}</span>
</button>
</div>
{/* Logout */}
<div className="px-3 pb-3">
<button
onClick={logout}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
>
<LogOut className="w-4 h-4" />
<span className="animate-fade-in">{t('nav.logout')}</span>
</button>
</div>
</>
)}
{/* Collapse toggle */}
<div className="p-3 border-t border-white/10">
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : (
<>
<ChevronLeft className="w-4 h-4" />
<span className="animate-fade-in">{t('nav.collapse')}</span>
</>
)}
</button>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,42 @@
export default function StatCard({ icon: Icon, label, value, subtitle, color = 'brand-primary', trend }) {
const colorMap = {
'brand-primary': 'from-indigo-500 to-indigo-600',
'brand-secondary': 'from-pink-500 to-pink-600',
'brand-tertiary': 'from-amber-500 to-amber-600',
'brand-quaternary': 'from-emerald-500 to-emerald-600',
}
const iconBgMap = {
'brand-primary': 'bg-indigo-50 text-indigo-600',
'brand-secondary': 'bg-pink-50 text-pink-600',
'brand-tertiary': 'bg-amber-50 text-amber-600',
'brand-quaternary': 'bg-emerald-50 text-emerald-600',
}
return (
<div className="bg-white rounded-xl border border-border p-5 card-hover">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-text-tertiary">{label}</p>
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p>
{subtitle && (
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
)}
</div>
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${iconBgMap[color] || iconBgMap['brand-primary']}`}>
<Icon className="w-5 h-5" />
</div>
</div>
{trend && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-1">
<span className={`text-xs font-medium ${trend > 0 ? 'text-emerald-600' : 'text-red-500'}`}>
{trend > 0 ? '+' : ''}{trend}%
</span>
<span className="text-xs text-text-tertiary">vs last month</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { getStatusConfig } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
const STATUS_LABEL_MAP = {
'draft': 'posts.status.draft',
'in_review': 'posts.status.in_review',
'approved': 'posts.status.approved',
'scheduled': 'posts.status.scheduled',
'published': 'posts.status.published',
'todo': 'tasks.todo',
'in_progress': 'tasks.in_progress',
'done': 'tasks.done',
}
export default function StatusBadge({ status, size = 'sm' }) {
const { t } = useLanguage()
const config = getStatusConfig(status)
const sizeClasses = {
xs: 'text-[10px] px-1.5 py-0.5',
sm: 'text-xs px-2 py-1',
md: 'text-sm px-2.5 py-1',
}
const labelKey = STATUS_LABEL_MAP[status]
const label = labelKey ? t(labelKey) : config.label
return (
<span className={`inline-flex items-center gap-1.5 rounded-full font-medium ${config.bg} ${config.text} ${sizeClasses[size]}`}>
<span className={`w-1.5 h-1.5 rounded-full ${config.dot}`}></span>
{label}
</span>
)
}

View File

@@ -0,0 +1,93 @@
import { format } from 'date-fns'
import { ArrowRight, Clock, User, UserCheck } from 'lucide-react'
import { PRIORITY_CONFIG } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
export default function TaskCard({ task, onMove, showProject = true }) {
const { t } = useLanguage()
const { user: authUser } = useAuth()
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const projectName = typeof task.project === 'object' ? task.project?.name : task.projectName
const nextStatus = {
todo: 'in_progress',
in_progress: 'done',
}
const nextLabel = {
todo: t('tasks.start'),
in_progress: t('tasks.complete'),
}
const dueDate = task.due_date || task.dueDate
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
const creatorName = task.creator_user_name || task.creatorUserName
// Determine if this task was assigned by someone else
const createdByUserId = task.created_by_user_id || task.createdByUserId
const isExternallyAssigned = authUser && createdByUserId && createdByUserId !== authUser.id
const assignedName = task.assigned_name || task.assignedName
return (
<div className={`bg-white rounded-lg border border-border p-3 card-hover group ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className="flex items-start gap-2.5">
{/* Priority dot */}
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
<div className="flex-1 min-w-0">
<h5 className={`text-sm font-medium leading-snug ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</h5>
{/* Assigned by label for externally-assigned tasks */}
{isExternallyAssigned && creatorName && (
<div className="flex items-center gap-1 mt-1">
<UserCheck className="w-3 h-3 text-blue-400" />
<span className="text-[10px] text-blue-500 font-medium">{t('tasks.from')} {creatorName}</span>
</div>
)}
{/* Assigned to label for tasks you delegated */}
{!isExternallyAssigned && assignedName && (
<div className="flex items-center gap-1 mt-1">
<User className="w-3 h-3 text-emerald-400" />
<span className="text-[10px] text-emerald-500 font-medium">{t('tasks.assignedTo')} {assignedName}</span>
</div>
)}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{showProject && projectName && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{projectName}
</span>
)}
{dueDate && (
<span className={`text-[10px] flex items-center gap-1 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
<Clock className="w-3 h-3" />
{format(new Date(dueDate), 'MMM d')}
</span>
)}
{!isExternallyAssigned && creatorName && (
<span className="text-[10px] flex items-center gap-1 text-text-tertiary">
<User className="w-3 h-3" />
{creatorName}
</span>
)}
</div>
</div>
</div>
{/* Quick action */}
{onMove && nextStatus[task.status] && (
<div className="mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onMove(task._id || task.id, nextStatus[task.status])}
className="text-[11px] text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"
>
{nextLabel[task.status]} <ArrowRight className="w-3 h-3" />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,248 @@
import { useState, useEffect } from 'react'
import { X, ArrowLeft, ArrowRight } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
const getTutorialSteps = (t) => [
{
target: '[data-tutorial="dashboard"]',
title: t('tutorial.dashboard.title'),
description: t('tutorial.dashboard.desc'),
position: 'right',
},
{
target: '[data-tutorial="campaigns"]',
title: t('tutorial.campaigns.title'),
description: t('tutorial.campaigns.desc'),
position: 'right',
},
{
target: '[data-tutorial="posts"]',
title: t('tutorial.posts.title'),
description: t('tutorial.posts.desc'),
position: 'right',
},
{
target: '[data-tutorial="tasks"]',
title: t('tutorial.tasks.title'),
description: t('tutorial.tasks.desc'),
position: 'right',
},
{
target: '[data-tutorial="team"]',
title: t('tutorial.team.title'),
description: t('tutorial.team.desc'),
position: 'right',
},
{
target: '[data-tutorial="assets"]',
title: t('tutorial.assets.title'),
description: t('tutorial.assets.desc'),
position: 'right',
},
{
target: '[data-tutorial="new-post"]',
title: t('tutorial.newPost.title'),
description: t('tutorial.newPost.desc'),
position: 'bottom',
},
{
target: '[data-tutorial="filters"]',
title: t('tutorial.filters.title'),
description: t('tutorial.filters.desc'),
position: 'bottom',
},
]
export default function Tutorial({ onComplete }) {
const { t } = useLanguage()
const [currentStep, setCurrentStep] = useState(0)
const [targetRect, setTargetRect] = useState(null)
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 })
const TUTORIAL_STEPS = getTutorialSteps(t)
const step = TUTORIAL_STEPS[currentStep]
useEffect(() => {
updatePosition()
window.addEventListener('resize', updatePosition)
return () => window.removeEventListener('resize', updatePosition)
}, [currentStep])
const updatePosition = () => {
const target = document.querySelector(step.target)
if (!target) {
console.warn(`Tutorial target not found: ${step.target}`)
return
}
const rect = target.getBoundingClientRect()
setTargetRect(rect)
// Calculate tooltip position
const tooltipWidth = 360
const tooltipHeight = 200
const padding = 20
let top = rect.top
let left = rect.right + padding
// Adjust based on position hint
if (step.position === 'bottom') {
top = rect.bottom + padding
left = rect.left
} else if (step.position === 'left') {
top = rect.top
left = rect.left - tooltipWidth - padding
} else if (step.position === 'top') {
top = rect.top - tooltipHeight - padding
left = rect.left
}
// Keep tooltip on screen
if (left + tooltipWidth > window.innerWidth) {
left = rect.left - tooltipWidth - padding
}
if (left < 0) {
left = padding
}
if (top + tooltipHeight > window.innerHeight) {
top = window.innerHeight - tooltipHeight - padding
}
if (top < 0) {
top = padding
}
setTooltipPosition({ top, left })
}
const handleNext = () => {
if (currentStep < TUTORIAL_STEPS.length - 1) {
setCurrentStep(currentStep + 1)
} else {
onComplete()
}
}
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
onComplete()
}
if (!targetRect) return null
return (
<div className="fixed inset-0 z-[9999] pointer-events-none">
{/* Dark overlay with spotlight cutout */}
<svg className="absolute inset-0 w-full h-full pointer-events-auto" style={{ pointerEvents: 'auto' }}>
<defs>
<mask id="tutorial-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<rect
x={targetRect.left - 4}
y={targetRect.top - 4}
width={targetRect.width + 8}
height={targetRect.height + 8}
rx="8"
fill="black"
/>
</mask>
</defs>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.7)"
mask="url(#tutorial-mask)"
onClick={handleSkip}
style={{ cursor: 'pointer' }}
/>
</svg>
{/* Highlight ring around target */}
<div
className="absolute border-4 border-brand-primary rounded-lg shadow-lg transition-all duration-300 pointer-events-none"
style={{
top: targetRect.top - 4,
left: targetRect.left - 4,
width: targetRect.width + 8,
height: targetRect.height + 8,
}}
/>
{/* Tooltip card */}
<div
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
style={{
top: tooltipPosition.top,
left: tooltipPosition.left,
width: 360,
maxWidth: 'calc(100vw - 40px)',
}}
>
{/* Close button */}
<button
onClick={handleSkip}
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Content */}
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 text-xs font-medium text-brand-primary mb-2">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary text-xs font-bold">
{currentStep + 1}
</span>
<span>{t('tutorial.step')} {currentStep + 1} {t('tutorial.of')} {TUTORIAL_STEPS.length}</span>
</div>
<h3 className="text-xl font-bold text-text-primary mb-2">{step.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{step.description}</p>
</div>
{/* Progress bar */}
<div className="w-full h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-brand-primary to-purple-500 transition-all duration-300"
style={{ width: `${((currentStep + 1) / TUTORIAL_STEPS.length) * 100}%` }}
/>
</div>
{/* Navigation */}
<div className="flex items-center justify-between gap-3 pt-2">
<button
onClick={handleSkip}
className="text-sm font-medium text-text-tertiary hover:text-text-primary transition-colors"
>
{t('tutorial.skip')}
</button>
<div className="flex items-center gap-2">
{currentStep > 0 && (
<button
onClick={handlePrev}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('tutorial.prev')}
</button>
)}
<button
onClick={handleNext}
className="flex items-center gap-1.5 px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
>
{currentStep < TUTORIAL_STEPS.length - 1 ? t('tutorial.next') : t('tutorial.finish')}
{currentStep < TUTORIAL_STEPS.length - 1 && <ArrowRight className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { createContext, useState, useEffect, useContext } from 'react'
import { api } from '../utils/api'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [permissions, setPermissions] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
try {
const userData = await api.get('/auth/me')
setUser(userData)
const perms = await api.get('/auth/permissions')
setPermissions(perms)
} catch (err) {
console.log('Not authenticated')
setUser(null)
setPermissions(null)
} finally {
setLoading(false)
}
}
const login = async (email, password) => {
const response = await api.post('/auth/login', { email, password })
setUser(response.user)
// Load permissions after login
try {
const perms = await api.get('/auth/permissions')
setPermissions(perms)
} catch (err) {
console.error('Failed to load permissions:', err)
}
return response
}
const logout = async () => {
try {
await api.post('/auth/logout')
} catch (err) {
console.error('Logout error:', err)
} finally {
setUser(null)
setPermissions(null)
window.location.href = '/login'
}
}
// Check if current user owns a resource
const isOwner = (resource) => {
if (!user || !resource) return false
return resource.created_by_user_id === user.id
}
// Check if current user is assigned to a resource
const isAssignedTo = (resource) => {
if (!user || !resource) return false
const teamMemberId = user.team_member_id || user.teamMemberId
if (!teamMemberId) return false
const assignedTo = resource.assigned_to || resource.assignedTo
return assignedTo === teamMemberId
}
// Check if user can edit a specific resource (owns it, assigned to it, or has role)
const canEditResource = (type, resource) => {
if (!permissions) return false
if (type === 'post') return permissions.canEditAnyPost || isOwner(resource) || isAssignedTo(resource)
if (type === 'task') return permissions.canEditAnyTask || isOwner(resource) || isAssignedTo(resource)
return false
}
const canDeleteResource = (type, resource) => {
if (!permissions) return false
if (type === 'post') return permissions.canDeleteAnyPost || isOwner(resource) || isAssignedTo(resource)
if (type === 'task') return permissions.canDeleteAnyTask || isOwner(resource) || isAssignedTo(resource)
return false
}
return (
<AuthContext.Provider value={{
user, loading, permissions,
login, logout, checkAuth,
isOwner, canEditResource, canDeleteResource,
}}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
export default AuthContext

View File

@@ -0,0 +1,65 @@
import { createContext, useContext, useState, useEffect } from 'react'
import en from './en.json'
import ar from './ar.json'
const translations = { en, ar }
const LanguageContext = createContext()
export function LanguageProvider({ children }) {
const [lang, setLangState] = useState(() => {
// Load from localStorage or default to 'en'
return localStorage.getItem('samaya-lang') || 'en'
})
const setLang = (newLang) => {
if (newLang !== 'en' && newLang !== 'ar') return
setLangState(newLang)
localStorage.setItem('samaya-lang', newLang)
}
const dir = lang === 'ar' ? 'rtl' : 'ltr'
// Update HTML dir attribute whenever language changes
useEffect(() => {
document.documentElement.dir = dir
document.documentElement.lang = lang
}, [dir, lang])
// Translation function
const t = (key) => {
const keys = key.split('.')
let value = translations[lang]
for (const k of keys) {
value = value?.[k]
if (value === undefined) break
}
// Fallback to English if translation not found
if (value === undefined) {
value = translations.en
for (const k of keys) {
value = value?.[k]
if (value === undefined) break
}
}
// Fallback to key itself if still not found
return value !== undefined ? value : key
}
return (
<LanguageContext.Provider value={{ lang, setLang, t, dir }}>
{children}
</LanguageContext.Provider>
)
}
export function useLanguage() {
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider')
}
return context
}

239
client/src/i18n/ar.json Normal file
View File

@@ -0,0 +1,239 @@
{
"app.name": "سمايا",
"app.subtitle": "مركز التسويق",
"nav.dashboard": "لوحة التحكم",
"nav.campaigns": "الحملات",
"nav.finance": "المالية والعائد",
"nav.posts": "إنتاج المحتوى",
"nav.assets": "الأصول",
"nav.projects": "المشاريع",
"nav.tasks": "المهام",
"nav.team": "الفريق",
"nav.settings": "الإعدادات",
"nav.users": "المستخدمين",
"nav.logout": "تسجيل الخروج",
"nav.collapse": "طي",
"common.save": "حفظ",
"common.cancel": "إلغاء",
"common.delete": "حذف",
"common.edit": "تعديل",
"common.create": "إنشاء",
"common.search": "بحث...",
"common.filter": "تصفية",
"common.all": "الكل",
"common.noResults": "لا توجد نتائج",
"common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند",
"common.required": "مطلوب",
"auth.login": "تسجيل الدخول",
"auth.email": "البريد الإلكتروني",
"auth.password": "كلمة المرور",
"auth.loginBtn": "دخول",
"auth.signingIn": "جاري تسجيل الدخول...",
"dashboard.title": "لوحة التحكم",
"dashboard.welcomeBack": "مرحباً بعودتك",
"dashboard.happeningToday": "إليك ما يحدث مع تسويقك اليوم.",
"dashboard.totalPosts": "إجمالي المنشورات",
"dashboard.published": "منشور",
"dashboard.activeCampaigns": "الحملات النشطة",
"dashboard.total": "إجمالي",
"dashboard.budgetSpent": "الميزانية المنفقة",
"dashboard.of": "من",
"dashboard.noBudget": "لا توجد ميزانية بعد",
"dashboard.overdueTasks": "مهام متأخرة",
"dashboard.needsAttention": "يحتاج اهتماماً",
"dashboard.allOnTrack": "كل شيء على المسار الصحيح",
"dashboard.budgetOverview": "نظرة عامة على الميزانية",
"dashboard.details": "التفاصيل",
"dashboard.noBudgetRecorded": "لم يتم تسجيل ميزانية بعد.",
"dashboard.addBudget": "إضافة ميزانية",
"dashboard.spent": "مُنفق",
"dashboard.received": "مُستلم",
"dashboard.remaining": "المتبقي",
"dashboard.revenue": "الإيرادات",
"dashboard.roi": "العائد على الاستثمار",
"dashboard.recentPosts": "المنشورات الأخيرة",
"dashboard.viewAll": "عرض الكل",
"dashboard.sar": "ريال",
"dashboard.noPostsYet": "لا توجد منشورات بعد. أنشئ منشورك الأول!",
"dashboard.upcomingDeadlines": "المواعيد النهائية القادمة",
"dashboard.noUpcomingDeadlines": "لا توجد مواعيد نهائية هذا الأسبوع. 🎉",
"dashboard.loadingHub": "جاري تحميل مركز سمايا للتسويق...",
"posts.title": "إنتاج المحتوى",
"posts.newPost": "منشور جديد",
"posts.editPost": "تعديل المنشور",
"posts.createPost": "إنشاء منشور",
"posts.saveChanges": "حفظ التغييرات",
"posts.postTitle": "العنوان",
"posts.description": "الوصف",
"posts.brand": "العلامة التجارية",
"posts.platforms": "المنصات",
"posts.status": "الحالة",
"posts.assignTo": "إسناد إلى",
"posts.scheduledDate": "تاريخ النشر المجدول",
"posts.notes": "ملاحظات",
"posts.campaign": "الحملة",
"posts.noCampaign": "بدون حملة",
"posts.publicationLinks": "روابط النشر",
"posts.attachments": "المرفقات",
"posts.uploadFiles": "انقر أو اسحب الملفات للرفع",
"posts.dropFiles": "أسقط الملفات هنا",
"posts.maxSize": "الحد الأقصى 50 ميجابايت للملف",
"posts.allBrands": "جميع العلامات",
"posts.allPlatforms": "جميع المنصات",
"posts.allPeople": "جميع الأشخاص",
"posts.searchPosts": "بحث في المنشورات...",
"posts.deletePost": "حذف المنشور؟",
"posts.deleteConfirm": "هل أنت متأكد من حذف هذا المنشور؟ لا يمكن التراجع.",
"posts.publishMissing": "لا يمكن النشر: روابط النشر مفقودة لـ:",
"posts.publishRequired": "جميع روابط النشر مطلوبة للنشر",
"posts.noPostsFound": "لم يتم العثور على منشورات",
"posts.selectBrand": "اختر العلامة التجارية",
"posts.additionalNotes": "ملاحظات إضافية",
"posts.uploading": "جاري الرفع...",
"posts.deleteAttachment": "حذف المرفق",
"posts.whatNeedsDone": "ما الذي يجب القيام به؟",
"posts.optionalDetails": "تفاصيل اختيارية...",
"posts.postTitlePlaceholder": "عنوان المنشور",
"posts.postDescPlaceholder": "وصف المنشور...",
"posts.dropHere": "أسقط هنا",
"posts.noPosts": "لا توجد منشورات",
"posts.sendToReview": "إرسال للمراجعة",
"posts.approve": "اعتماد",
"posts.schedule": "جدولة",
"posts.publish": "نشر",
"posts.status.draft": "مسودة",
"posts.status.in_review": "قيد المراجعة",
"posts.status.approved": "مُعتمد",
"posts.status.scheduled": "مجدول",
"posts.status.published": "منشور",
"tasks.title": "المهام",
"tasks.newTask": "مهمة جديدة",
"tasks.editTask": "تعديل المهمة",
"tasks.createTask": "إنشاء مهمة",
"tasks.saveChanges": "حفظ التغييرات",
"tasks.taskTitle": "العنوان",
"tasks.description": "الوصف",
"tasks.priority": "الأولوية",
"tasks.dueDate": "تاريخ الاستحقاق",
"tasks.assignTo": "إسناد إلى",
"tasks.allTasks": "جميع المهام",
"tasks.assignedToMe": "المُسندة إليّ",
"tasks.createdByMe": "أنشأتها",
"tasks.byTeamMember": "حسب عضو الفريق",
"tasks.noTasks": "لا توجد مهام بعد",
"tasks.noMatch": "لا توجد مهام تطابق هذا الفلتر",
"tasks.createFirst": "أنشئ مهمة للبدء",
"tasks.tryFilter": "جرب فلتر مختلف",
"tasks.deleteTask": "حذف المهمة؟",
"tasks.deleteConfirm": "هل أنت متأكد من حذف هذه المهمة؟ لا يمكن التراجع.",
"tasks.todo": "للتنفيذ",
"tasks.in_progress": "قيد التنفيذ",
"tasks.done": "مكتمل",
"tasks.start": "ابدأ",
"tasks.complete": "أكمل",
"tasks.from": "من:",
"tasks.assignedTo": "مُسند إلى:",
"tasks.task": "مهمة",
"tasks.tasks": "مهام",
"tasks.of": "من",
"tasks.priority.low": "منخفض",
"tasks.priority.medium": "متوسط",
"tasks.priority.high": "عالي",
"tasks.priority.urgent": "عاجل",
"team.title": "الفريق",
"team.members": "أعضاء الفريق",
"team.addMember": "إضافة عضو",
"team.newMember": "عضو جديد",
"team.editMember": "تعديل العضو",
"team.myProfile": "ملفي الشخصي",
"team.editProfile": "تعديل ملفي",
"team.name": "الاسم",
"team.email": "البريد الإلكتروني",
"team.password": "كلمة المرور",
"team.teamRole": "الدور في الفريق",
"team.phone": "الهاتف",
"team.brands": "العلامات التجارية",
"team.brandsHelp": "أسماء العلامات مفصولة بفاصلة",
"team.removeMember": "إزالة عضو الفريق؟",
"team.removeConfirm": "هل أنت متأكد من إزالة {name}؟ لا يمكن التراجع.",
"team.noMembers": "لا يوجد أعضاء",
"team.backToTeam": "العودة للفريق",
"team.totalTasks": "إجمالي المهام",
"team.saveProfile": "حفظ الملف",
"team.saveChanges": "حفظ التغييرات",
"team.member": "عضو فريق",
"team.membersPlural": "أعضاء فريق",
"team.fullName": "الاسم الكامل",
"team.defaultPassword": "افتراضياً: changeme123",
"team.optional": "(اختياري)",
"team.fixedRole": "دور ثابت للمديرين",
"team.remove": "إزالة",
"team.noTasks": "لا توجد مهام",
"team.toDo": "للتنفيذ",
"team.inProgress": "قيد التنفيذ",
"campaigns.title": "الحملات",
"campaigns.newCampaign": "حملة جديدة",
"campaigns.noCampaigns": "لا توجد حملات",
"assets.title": "الأصول",
"assets.upload": "رفع",
"assets.noAssets": "لا توجد أصول",
"settings.title": "الإعدادات",
"settings.language": "اللغة",
"settings.english": "English",
"settings.arabic": "عربي",
"settings.restartTutorial": "إعادة تشغيل الدليل التعليمي",
"settings.tutorialDesc": "هل تحتاج إلى تذكير؟ أعد تشغيل الدليل التفاعلي للتعرف على جميع ميزات مركز سمايا للتسويق.",
"settings.general": "عام",
"settings.onboardingTutorial": "الدليل التعليمي",
"settings.tutorialRestarted": "تم إعادة تشغيل الدليل!",
"settings.restarting": "جاري إعادة التشغيل...",
"settings.reloadingPage": "جاري إعادة تحميل الصفحة لبدء الدليل...",
"settings.moreComingSoon": "المزيد من الإعدادات قريباً",
"settings.additionalSettings": "سيتم إضافة إعدادات إضافية للإشعارات وتفضيلات العرض والمزيد هنا.",
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
"tutorial.skip": "تخطي",
"tutorial.next": "التالي",
"tutorial.prev": "السابق",
"tutorial.finish": "إنهاء",
"tutorial.of": "من",
"tutorial.step": "الخطوة",
"tutorial.dashboard.title": "لوحة التحكم",
"tutorial.dashboard.desc": "مركز القيادة الخاص بك. شاهد أداء الحملات وتقدم المهام ونشاط الفريق في لمحة.",
"tutorial.campaigns.title": "الحملات",
"tutorial.campaigns.desc": "خطط وأدر الحملات التسويقية عبر جميع العلامات والمنصات.",
"tutorial.posts.title": "إنتاج المحتوى",
"tutorial.posts.desc": "أنشئ وراجع وانشر المحتوى. اسحب المنشورات عبر خط سير العمل.",
"tutorial.tasks.title": "المهام",
"tutorial.tasks.desc": "أسند وتتبع المهام. صفّ حسب من أسندها أو من أُسندت إليه.",
"tutorial.team.title": "الفريق",
"tutorial.team.desc": "دليل فريقك. أكمل ملفك الشخصي وشاهد من تعمل معه.",
"tutorial.assets.title": "الأصول",
"tutorial.assets.desc": "ارفع وأدر الأصول الإبداعية — الصور والفيديوهات والمستندات.",
"tutorial.newPost.title": "إنشاء محتوى",
"tutorial.newPost.desc": "ابدأ إنشاء المحتوى من هنا. اختر علامتك التجارية والمنصات وأسنده لعضو فريق.",
"tutorial.filters.title": "التصفية والتركيز",
"tutorial.filters.desc": "استخدم الفلاتر للتركيز على علامات أو منصات أو أعضاء فريق محددين.",
"login.title": "سمايا للتسويق",
"login.subtitle": "سجل دخولك للمتابعة",
"login.forgotPassword": "نسيت كلمة المرور؟",
"login.defaultCreds": "بيانات الدخول الافتراضية:",
"profile.completeYourProfile": "أكمل ملفك الشخصي",
"profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.",
"profile.completeProfileBtn": "إكمال الملف",
"profile.later": "لاحقاً"
}

239
client/src/i18n/en.json Normal file
View File

@@ -0,0 +1,239 @@
{
"app.name": "Samaya",
"app.subtitle": "Marketing Hub",
"nav.dashboard": "Dashboard",
"nav.campaigns": "Campaigns",
"nav.finance": "Finance & ROI",
"nav.posts": "Post Production",
"nav.assets": "Assets",
"nav.projects": "Projects",
"nav.tasks": "Tasks",
"nav.team": "Team",
"nav.settings": "Settings",
"nav.users": "Users",
"nav.logout": "Logout",
"nav.collapse": "Collapse",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.create": "Create",
"common.search": "Search...",
"common.filter": "Filter",
"common.all": "All",
"common.noResults": "No results",
"common.loading": "Loading...",
"common.unassigned": "Unassigned",
"common.required": "Required",
"auth.login": "Sign In",
"auth.email": "Email",
"auth.password": "Password",
"auth.loginBtn": "Sign In",
"auth.signingIn": "Signing in...",
"dashboard.title": "Dashboard",
"dashboard.welcomeBack": "Welcome back",
"dashboard.happeningToday": "Here's what's happening with your marketing today.",
"dashboard.totalPosts": "Total Posts",
"dashboard.published": "published",
"dashboard.activeCampaigns": "Active Campaigns",
"dashboard.total": "total",
"dashboard.budgetSpent": "Budget Spent",
"dashboard.of": "of",
"dashboard.noBudget": "No budget yet",
"dashboard.overdueTasks": "Overdue Tasks",
"dashboard.needsAttention": "Needs attention",
"dashboard.allOnTrack": "All on track",
"dashboard.budgetOverview": "Budget Overview",
"dashboard.details": "Details",
"dashboard.noBudgetRecorded": "No budget recorded yet.",
"dashboard.addBudget": "Add budget",
"dashboard.spent": "spent",
"dashboard.received": "received",
"dashboard.remaining": "Remaining",
"dashboard.revenue": "Revenue",
"dashboard.roi": "ROI",
"dashboard.recentPosts": "Recent Posts",
"dashboard.viewAll": "View all",
"dashboard.sar": "SAR",
"dashboard.noPostsYet": "No posts yet. Create your first post!",
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
"dashboard.loadingHub": "Loading Samaya Marketing Hub...",
"posts.title": "Post Production",
"posts.newPost": "New Post",
"posts.editPost": "Edit Post",
"posts.createPost": "Create Post",
"posts.saveChanges": "Save Changes",
"posts.postTitle": "Title",
"posts.description": "Description",
"posts.brand": "Brand",
"posts.platforms": "Platforms",
"posts.status": "Status",
"posts.assignTo": "Assign To",
"posts.scheduledDate": "Scheduled Date",
"posts.notes": "Notes",
"posts.campaign": "Campaign",
"posts.noCampaign": "No campaign",
"posts.publicationLinks": "Publication Links",
"posts.attachments": "Attachments",
"posts.uploadFiles": "Click or drag files to upload",
"posts.dropFiles": "Drop files here",
"posts.maxSize": "Max 50MB per file",
"posts.allBrands": "All Brands",
"posts.allPlatforms": "All Platforms",
"posts.allPeople": "All People",
"posts.searchPosts": "Search posts...",
"posts.deletePost": "Delete Post?",
"posts.deleteConfirm": "Are you sure you want to delete this post? This action cannot be undone.",
"posts.publishMissing": "Cannot publish: missing publication links for:",
"posts.publishRequired": "All publication links are required to publish",
"posts.noPostsFound": "No posts found",
"posts.selectBrand": "Select brand",
"posts.additionalNotes": "Additional notes",
"posts.uploading": "Uploading...",
"posts.deleteAttachment": "Delete attachment",
"posts.whatNeedsDone": "What needs to be done?",
"posts.optionalDetails": "Optional details...",
"posts.postTitlePlaceholder": "Post title",
"posts.postDescPlaceholder": "Post description...",
"posts.dropHere": "Drop here",
"posts.noPosts": "No posts",
"posts.sendToReview": "Send to Review",
"posts.approve": "Approve",
"posts.schedule": "Schedule",
"posts.publish": "Publish",
"posts.status.draft": "Draft",
"posts.status.in_review": "In Review",
"posts.status.approved": "Approved",
"posts.status.scheduled": "Scheduled",
"posts.status.published": "Published",
"tasks.title": "Tasks",
"tasks.newTask": "New Task",
"tasks.editTask": "Edit Task",
"tasks.createTask": "Create Task",
"tasks.saveChanges": "Save Changes",
"tasks.taskTitle": "Title",
"tasks.description": "Description",
"tasks.priority": "Priority",
"tasks.dueDate": "Due Date",
"tasks.assignTo": "Assign to",
"tasks.allTasks": "All Tasks",
"tasks.assignedToMe": "Assigned to Me",
"tasks.createdByMe": "Created by Me",
"tasks.byTeamMember": "By Team Member",
"tasks.noTasks": "No tasks yet",
"tasks.noMatch": "No tasks match this filter",
"tasks.createFirst": "Create a task to get started",
"tasks.tryFilter": "Try a different filter",
"tasks.deleteTask": "Delete Task?",
"tasks.deleteConfirm": "Are you sure you want to delete this task? This action cannot be undone.",
"tasks.todo": "To Do",
"tasks.in_progress": "In Progress",
"tasks.done": "Done",
"tasks.start": "Start",
"tasks.complete": "Complete",
"tasks.from": "From:",
"tasks.assignedTo": "Assigned to:",
"tasks.task": "task",
"tasks.tasks": "tasks",
"tasks.of": "of",
"tasks.priority.low": "Low",
"tasks.priority.medium": "Medium",
"tasks.priority.high": "High",
"tasks.priority.urgent": "Urgent",
"team.title": "Team",
"team.members": "Team Members",
"team.addMember": "Add Member",
"team.newMember": "New Team Member",
"team.editMember": "Edit Team Member",
"team.myProfile": "My Profile",
"team.editProfile": "Edit My Profile",
"team.name": "Name",
"team.email": "Email",
"team.password": "Password",
"team.teamRole": "Team Role",
"team.phone": "Phone",
"team.brands": "Brands",
"team.brandsHelp": "Comma-separated brand names",
"team.removeMember": "Remove Team Member?",
"team.removeConfirm": "Are you sure you want to remove {name}? This action cannot be undone.",
"team.noMembers": "No team members",
"team.backToTeam": "Back to Team",
"team.totalTasks": "Total Tasks",
"team.saveProfile": "Save Profile",
"team.saveChanges": "Save Changes",
"team.member": "team member",
"team.membersPlural": "team members",
"team.fullName": "Full name",
"team.defaultPassword": "Default: changeme123",
"team.optional": "(optional)",
"team.fixedRole": "Fixed role for managers",
"team.remove": "Remove",
"team.noTasks": "No tasks",
"team.toDo": "To Do",
"team.inProgress": "In Progress",
"campaigns.title": "Campaigns",
"campaigns.newCampaign": "New Campaign",
"campaigns.noCampaigns": "No campaigns",
"assets.title": "Assets",
"assets.upload": "Upload",
"assets.noAssets": "No assets",
"settings.title": "Settings",
"settings.language": "Language",
"settings.english": "English",
"settings.arabic": "Arabic",
"settings.restartTutorial": "Restart Tutorial",
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of the Samaya Marketing Hub.",
"settings.general": "General",
"settings.onboardingTutorial": "Onboarding Tutorial",
"settings.tutorialRestarted": "Tutorial Restarted!",
"settings.restarting": "Restarting...",
"settings.reloadingPage": "Reloading page to start tutorial...",
"settings.moreComingSoon": "More Settings Coming Soon",
"settings.additionalSettings": "Additional settings for notifications, display preferences, and more will be added here.",
"settings.preferences": "Manage your preferences and app settings",
"tutorial.skip": "Skip Tutorial",
"tutorial.next": "Next",
"tutorial.prev": "Back",
"tutorial.finish": "Finish",
"tutorial.of": "of",
"tutorial.step": "Step",
"tutorial.dashboard.title": "Dashboard",
"tutorial.dashboard.desc": "Your command center. See campaign performance, task progress, and team activity at a glance.",
"tutorial.campaigns.title": "Campaigns",
"tutorial.campaigns.desc": "Plan and manage marketing campaigns across all brands and platforms.",
"tutorial.posts.title": "Post Production",
"tutorial.posts.desc": "Create, review, and publish content. Drag posts through your workflow pipeline.",
"tutorial.tasks.title": "Tasks",
"tutorial.tasks.desc": "Assign and track tasks. Filter by who assigned them or who they're assigned to.",
"tutorial.team.title": "Team",
"tutorial.team.desc": "Your team directory. Complete your profile and see who you're working with.",
"tutorial.assets.title": "Assets",
"tutorial.assets.desc": "Upload and manage creative assets — images, videos, and documents.",
"tutorial.newPost.title": "Create Content",
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
"tutorial.filters.title": "Filter & Focus",
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
"login.title": "Samaya Marketing",
"login.subtitle": "Sign in to continue",
"login.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:",
"profile.completeYourProfile": "Complete Your Profile",
"profile.completeDesc": "Please complete your profile to access all features and help your team find you.",
"profile.completeProfileBtn": "Complete Profile",
"profile.later": "Later"
}

219
client/src/index.css Normal file
View File

@@ -0,0 +1,219 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
--color-sidebar: #0f172a;
--color-sidebar-hover: #1e293b;
--color-sidebar-active: #020617;
--color-brand-primary: #4f46e5;
--color-brand-primary-light: #6366f1;
--color-brand-secondary: #db2777;
--color-brand-tertiary: #f59e0b;
--color-brand-quaternary: #059669;
--color-surface: #ffffff;
--color-surface-secondary: #f9fafb;
--color-surface-tertiary: #f3f4f6;
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-tertiary: #94a3b8;
--color-text-on-dark: #f8fafc;
--color-text-on-dark-muted: #94a3b8;
--color-status-draft: #9ca3af;
--color-status-in-review: #f59e0b;
--color-status-approved: #3b82f6;
--color-status-scheduled: #8b5cf6;
--color-status-published: #059669;
--color-status-rejected: #dc2626;
--color-status-todo: #9ca3af;
--color-status-in-progress: #3b82f6;
--color-status-done: #059669;
--color-status-active: #059669;
--color-status-paused: #f59e0b;
--color-status-completed: #3b82f6;
--color-status-cancelled: #dc2626;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Smooth transitions */
* {
transition-property: background-color, border-color, color, opacity, box-shadow, transform;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Arabic text support */
[dir="rtl"] {
font-family: 'IBM Plex Sans Arabic', 'Inter', system-ui, sans-serif;
}
/* Auto-detect text direction in inputs for mixed Arabic/English content */
input[type="text"],
input[type="search"],
input[type="url"],
input[type="email"],
textarea {
unicode-bidi: plaintext;
}
/* Ensure text content areas handle Arabic properly */
.line-clamp-2, .truncate, h1, h2, h3, h4, h5, p, span, label {
unicode-bidi: plaintext;
}
/* RTL-aware sidebar positioning */
[dir="rtl"] .sidebar {
right: 0;
left: auto;
}
[dir="ltr"] .sidebar {
left: 0;
right: auto;
}
/* RTL-aware main content margin */
[dir="rtl"] .main-content-margin {
margin-right: 260px;
margin-left: 0;
}
[dir="ltr"] .main-content-margin {
margin-left: 260px;
margin-right: 0;
}
[dir="rtl"] .main-content-margin-collapsed {
margin-right: 68px;
margin-left: 0;
}
[dir="ltr"] .main-content-margin-collapsed {
margin-left: 68px;
margin-right: 0;
}
/* Enhanced sidebar with gradient */
.sidebar {
background: linear-gradient(180deg, #0f172a 0%, #020617 100%);
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
}
/* Animation keyframes */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slide-in {
animation: slideIn 0.3s ease-out forwards;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out forwards;
}
/* Stagger children */
.stagger-children > * {
opacity: 0;
animation: fadeIn 0.3s ease-out forwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
/* Card hover effect - smooth and elegant */
.card-hover {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.card-hover:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
}
/* Stat card accents - subtle colored top borders */
.stat-card {
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--color-brand-primary), var(--color-brand-primary-light));
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover::before {
opacity: 1;
}
/* Refined button styles */
button {
border-radius: 0.625rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.12);
}
button:active:not(:disabled) {
transform: translateY(0);
}
/* Kanban column */
.kanban-column {
min-height: 200px;
}
/* Calendar grid */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}

13
client/src/main.jsx Normal file
View File

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

356
client/src/pages/Assets.jsx Normal file
View File

@@ -0,0 +1,356 @@
import { useState, useEffect, useRef } from 'react'
import { Plus, Upload, Search, FolderOpen, ChevronRight, Grid3X3, X } from 'lucide-react'
import { api } from '../utils/api'
import AssetCard from '../components/AssetCard'
import Modal from '../components/Modal'
export default function Assets() {
const [assets, setAssets] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({ brand: '', tag: '', folder: '', search: '' })
const [showUpload, setShowUpload] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [dragOver, setDragOver] = useState(false)
const [selectedAsset, setSelectedAsset] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [assetToDelete, setAssetToDelete] = useState(null)
const fileRef = useRef(null)
useEffect(() => { loadAssets() }, [])
const loadAssets = async () => {
try {
const res = await api.get('/assets')
const assetsData = res.data || res || []
// Map assets to include URL for thumbnails
const assetsWithUrls = assetsData.map(asset => ({
...asset,
_id: asset.id,
name: asset.original_name || asset.filename,
type: asset.mime_type?.startsWith('image') ? 'image' :
asset.mime_type?.startsWith('video') ? 'video' :
asset.mime_type?.startsWith('audio') ? 'audio' : 'document',
url: `/api/uploads/${asset.filename}`,
createdAt: asset.created_at,
fileType: asset.mime_type?.split('/')[1]?.toUpperCase() || 'FILE',
}))
setAssets(assetsWithUrls)
} catch (err) {
console.error('Failed to load assets:', err)
} finally {
setLoading(false)
}
}
const handleUpload = async (files) => {
if (!files || files.length === 0) return
setUploading(true)
setUploadProgress(0)
try {
for (let i = 0; i < files.length; i++) {
const file = files[i]
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'general')
formData.append('brand_id', '')
formData.append('uploaded_by', '')
// Use XMLHttpRequest to track upload progress
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const fileProgress = (e.loaded / e.total) * 100
const totalProgress = ((i + fileProgress / 100) / files.length) * 100
setUploadProgress(Math.round(totalProgress))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Network error')))
xhr.open('POST', '/api/assets/upload')
xhr.send(formData)
})
}
loadAssets()
setShowUpload(false)
setUploadProgress(0)
} catch (err) {
console.error('Upload failed:', err)
alert('Upload failed: ' + err.message)
} finally {
setUploading(false)
}
}
const handleDeleteAsset = async (asset) => {
setAssetToDelete(asset)
setShowDeleteConfirm(true)
}
const confirmDeleteAsset = async () => {
if (!assetToDelete) return
try {
await api.delete(`/assets/${assetToDelete.id || assetToDelete._id}`)
setSelectedAsset(null)
setAssetToDelete(null)
loadAssets()
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete asset')
}
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
handleUpload(e.dataTransfer.files)
}
// Get unique values for filters
const brands = [...new Set(assets.map(a => a.brand).filter(Boolean))]
const allTags = [...new Set(assets.flatMap(a => a.tags || []))]
const folders = [...new Set(assets.map(a => a.folder).filter(Boolean))]
const filteredAssets = assets.filter(a => {
if (filters.brand && a.brand !== filters.brand) return false
if (filters.tag && !(a.tags || []).includes(filters.tag)) return false
if (filters.folder && a.folder !== filters.folder) return false
if (filters.search && !a.name?.toLowerCase().includes(filters.search.toLowerCase())) return false
return true
})
if (loading) {
return (
<div className="animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{[...Array(10)].map((_, i) => (
<div key={i} className="aspect-square bg-surface-tertiary rounded-xl"></div>
))}
</div>
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search assets..."
value={filters.search}
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
/>
</div>
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
<select
value={filters.tag}
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
>
<option value="">All Tags</option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
>
<Upload className="w-4 h-4" />
Upload
</button>
</div>
{/* Folder breadcrumbs */}
{folders.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setFilters(f => ({ ...f, folder: '' }))}
className={`flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition-colors ${
!filters.folder ? 'bg-brand-primary/10 text-brand-primary font-medium' : 'text-text-secondary hover:bg-surface-tertiary'
}`}
>
<FolderOpen className="w-4 h-4" />
All
</button>
{folders.map(folder => (
<button
key={folder}
onClick={() => setFilters(f => ({ ...f, folder }))}
className={`flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition-colors ${
filters.folder === folder ? 'bg-brand-primary/10 text-brand-primary font-medium' : 'text-text-secondary hover:bg-surface-tertiary'
}`}
>
<FolderOpen className="w-4 h-4" />
{folder}
</button>
))}
</div>
)}
{/* Asset grid */}
{filteredAssets.length === 0 ? (
<div className="py-20 text-center">
<Grid3X3 className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No assets found</p>
<p className="text-sm text-text-tertiary mt-1">Upload your first asset to get started</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredAssets.map(asset => (
<div key={asset._id || asset.id}>
<AssetCard asset={asset} onClick={setSelectedAsset} />
</div>
))}
</div>
)}
{/* Upload Modal */}
<Modal isOpen={showUpload} onClose={() => !uploading && setShowUpload(false)} title="Upload Assets" size="md">
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => !uploading && fileRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
} ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/50'
}`}
>
<Upload className={`w-10 h-10 mx-auto mb-3 ${uploading ? 'animate-pulse' : ''} ${dragOver ? 'text-brand-primary' : 'text-text-tertiary'}`} />
<p className="text-sm font-medium text-text-primary">
{uploading ? `Uploading... ${uploadProgress}%` : 'Drop files here or click to browse'}
</p>
<p className="text-xs text-text-tertiary mt-1">
Images, videos, documents up to 50MB
</p>
{uploading && (
<div className="mt-4 w-full bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div
className="h-full bg-brand-primary transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
<input
ref={fileRef}
type="file"
multiple
disabled={uploading}
className="hidden"
onChange={e => handleUpload(e.target.files)}
/>
</div>
</Modal>
{/* Asset detail modal */}
<Modal
isOpen={!!selectedAsset}
onClose={() => setSelectedAsset(null)}
title={selectedAsset?.name || 'Asset Details'}
size="lg"
>
{selectedAsset && (
<div className="space-y-4">
{selectedAsset.type === 'image' && selectedAsset.url && (
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" />
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-tertiary">Type</p>
<p className="font-medium text-text-primary capitalize">{selectedAsset.type}</p>
</div>
<div>
<p className="text-text-tertiary">Size</p>
<p className="font-medium text-text-primary">{selectedAsset.size ? `${(selectedAsset.size / 1024 / 1024).toFixed(2)} MB` : '—'}</p>
</div>
{selectedAsset.brand_name && (
<div>
<p className="text-text-tertiary">Brand</p>
<p className="font-medium text-text-primary">{selectedAsset.brand_name}</p>
</div>
)}
{selectedAsset.folder && (
<div>
<p className="text-text-tertiary">Folder</p>
<p className="font-medium text-text-primary">{selectedAsset.folder}</p>
</div>
)}
</div>
{selectedAsset.tags && selectedAsset.tags.length > 0 && (
<div>
<p className="text-sm text-text-tertiary mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{selectedAsset.tags.map(tag => (
<span key={tag} className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
{tag}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-3 pt-4 border-t border-border">
<button
onClick={() => handleDeleteAsset(selectedAsset)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg"
>
Delete Asset
</button>
<a
href={selectedAsset.url}
download={selectedAsset.name}
target="_blank"
rel="noopener noreferrer"
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
>
Download
</a>
</div>
</div>
)}
</Modal>
{/* Delete Asset Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setAssetToDelete(null) }}
title="Delete Asset?"
isConfirm
danger
confirmText="Delete Asset"
onConfirm={confirmDeleteAsset}
>
Are you sure you want to delete this asset? This file will be permanently removed from the server. This action cannot be undone.
</Modal>
</div>
)
}

View File

@@ -0,0 +1,565 @@
import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { api, PLATFORMS } from '../utils/api'
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
paid_social: { label: 'Paid Social', icon: DollarSign, color: 'text-blue-600 bg-blue-50', hasBudget: true },
paid_search: { label: 'Paid Search (PPC)', icon: Search, color: 'text-amber-600 bg-amber-50', hasBudget: true },
seo_content: { label: 'SEO / Content', icon: Globe, color: 'text-purple-600 bg-purple-50', hasBudget: false },
production: { label: 'Production', icon: FileText, color: 'text-red-600 bg-red-50', hasBudget: true },
}
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
const EMPTY_TRACK = {
name: '', type: 'organic_social', platform: '', budget_allocated: '', status: 'planned', notes: '',
}
const EMPTY_METRICS = {
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', notes: '',
}
function BudgetBar({ budget, spent }) {
if (!budget || budget <= 0) return null
const pct = Math.min((spent / budget) * 100, 100)
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="w-full">
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
<span>{(spent || 0).toLocaleString()} spent</span>
<span>{budget.toLocaleString()} SAR</span>
</div>
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
return (
<div className="text-center">
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function CampaignDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands } = useContext(AppContext)
const { permissions } = useAuth()
const canManage = permissions?.canEditCampaigns
const [campaign, setCampaign] = useState(null)
const [tracks, setTracks] = useState([])
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [showTrackModal, setShowTrackModal] = useState(false)
const [editingTrack, setEditingTrack] = useState(null)
const [trackForm, setTrackForm] = useState(EMPTY_TRACK)
const [showMetricsModal, setShowMetricsModal] = useState(false)
const [metricsTrack, setMetricsTrack] = useState(null)
const [metricsForm, setMetricsForm] = useState(EMPTY_METRICS)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
useEffect(() => { loadAll() }, [id])
const loadAll = async () => {
try {
const [campRes, tracksRes, postsRes] = await Promise.all([
api.get(`/campaigns`),
api.get(`/campaigns/${id}/tracks`),
api.get(`/campaigns/${id}/posts`),
])
const allCampaigns = campRes.data || campRes || []
const found = allCampaigns.find(c => String(c.id) === String(id) || String(c._id) === String(id))
setCampaign(found || null)
setTracks(tracksRes.data || tracksRes || [])
setPosts(postsRes.data || postsRes || [])
} catch (err) {
console.error('Failed to load campaign:', err)
} finally {
setLoading(false)
}
}
const saveTrack = async () => {
try {
const data = {
name: trackForm.name,
type: trackForm.type,
platform: trackForm.platform || null,
budget_allocated: trackForm.budget_allocated ? Number(trackForm.budget_allocated) : 0,
status: trackForm.status,
notes: trackForm.notes,
}
if (editingTrack) {
await api.patch(`/tracks/${editingTrack.id}`, data)
} else {
await api.post(`/campaigns/${id}/tracks`, data)
}
setShowTrackModal(false)
setEditingTrack(null)
setTrackForm(EMPTY_TRACK)
loadAll()
} catch (err) {
console.error('Save track failed:', err)
}
}
const deleteTrack = async (trackId) => {
setTrackToDelete(trackId)
setShowDeleteConfirm(true)
}
const confirmDeleteTrack = async () => {
if (!trackToDelete) return
await api.delete(`/tracks/${trackToDelete}`)
setTrackToDelete(null)
loadAll()
}
const saveMetrics = async () => {
try {
await api.patch(`/tracks/${metricsTrack.id}`, {
budget_spent: metricsForm.budget_spent ? Number(metricsForm.budget_spent) : 0,
revenue: metricsForm.revenue ? Number(metricsForm.revenue) : 0,
impressions: metricsForm.impressions ? Number(metricsForm.impressions) : 0,
clicks: metricsForm.clicks ? Number(metricsForm.clicks) : 0,
conversions: metricsForm.conversions ? Number(metricsForm.conversions) : 0,
notes: metricsForm.notes || '',
})
setShowMetricsModal(false)
setMetricsTrack(null)
loadAll()
} catch (err) {
console.error('Save metrics failed:', err)
}
}
const openEditTrack = (track) => {
setEditingTrack(track)
setTrackForm({
name: track.name || '',
type: track.type || 'organic_social',
platform: track.platform || '',
budget_allocated: track.budget_allocated || '',
status: track.status || 'planned',
notes: track.notes || '',
})
setShowTrackModal(true)
}
const openMetrics = (track) => {
setMetricsTrack(track)
setMetricsForm({
budget_spent: track.budget_spent || '',
revenue: track.revenue || '',
impressions: track.impressions || '',
clicks: track.clicks || '',
conversions: track.conversions || '',
notes: track.notes || '',
})
setShowMetricsModal(true)
}
if (loading) {
return <div className="animate-pulse"><div className="h-64 bg-surface-tertiary rounded-xl"></div></div>
}
if (!campaign) {
return (
<div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
</div>
)
}
// Aggregates from tracks
const totalAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0)
const totalSpent = tracks.reduce((s, t) => s + (t.budget_spent || 0), 0)
const totalImpressions = tracks.reduce((s, t) => s + (t.impressions || 0), 0)
const totalClicks = tracks.reduce((s, t) => s + (t.clicks || 0), 0)
const totalConversions = tracks.reduce((s, t) => s + (t.conversions || 0), 0)
const totalRevenue = tracks.reduce((s, t) => s + (t.revenue || 0), 0)
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-start gap-4">
<button onClick={() => navigate('/campaigns')} className="mt-1 p-1.5 hover:bg-surface-tertiary rounded-lg">
<ArrowLeft className="w-5 h-5 text-text-secondary" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-xl font-bold text-text-primary">{campaign.name}</h1>
<StatusBadge status={campaign.status} />
{campaign.brand_name && <BrandBadge brand={campaign.brand_name} />}
</div>
{campaign.description && <p className="text-sm text-text-secondary">{campaign.description}</p>}
<div className="flex items-center gap-4 mt-2 text-xs text-text-tertiary">
{campaign.start_date && campaign.end_date && (
<span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
)}
{campaign.budget > 0 && <span>Budget: {campaign.budget.toLocaleString()} SAR</span>}
</div>
</div>
</div>
{/* Aggregate Metrics */}
{tracks.length > 0 && (
<div className="bg-white rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
</div>
{totalAllocated > 0 && (
<div className="mt-4">
<BudgetBar budget={totalAllocated} spent={totalSpent} />
</div>
)}
</div>
)}
{/* Tracks */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Tracks</h3>
{canManage && (
<button
onClick={() => { setEditingTrack(null); setTrackForm(EMPTY_TRACK); setShowTrackModal(true) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<Plus className="w-3.5 h-3.5" /> Add Track
</button>
)}
</div>
{tracks.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
</div>
) : (
<div className="divide-y divide-border-light">
{tracks.map(track => {
const typeInfo = TRACK_TYPES[track.type] || TRACK_TYPES.organic_social
const TypeIcon = typeInfo.icon
const trackPosts = posts.filter(p => p.track_id === track.id)
return (
<div key={track.id} className="px-5 py-4">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${typeInfo.color}`}>
<TypeIcon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h4 className="text-sm font-semibold text-text-primary">
{track.name || typeInfo.label}
</h4>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{typeInfo.label}
</span>
{track.platform && (
<PlatformIcon platform={track.platform} size={16} />
)}
<StatusBadge status={track.status} size="xs" />
</div>
{/* Budget bar for paid tracks */}
{track.budget_allocated > 0 && (
<div className="w-48 mt-1.5">
<BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} />
</div>
)}
{/* Quick metrics */}
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
{track.clicks > 0 && track.budget_spent > 0 && (
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} SAR</span>
)}
{track.impressions > 0 && track.clicks > 0 && (
<span>CTR: {(track.clicks / track.impressions * 100).toFixed(2)}%</span>
)}
</div>
)}
{/* Linked posts count */}
{trackPosts.length > 0 && (
<div className="text-[10px] text-text-tertiary mt-1">
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked
</div>
)}
{track.notes && (
<p className="text-xs text-text-secondary mt-1 line-clamp-1">{track.notes}</p>
)}
</div>
{/* Actions */}
{canManage && (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => openMetrics(track)}
title="Update metrics"
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-brand-primary"
>
<TrendingUp className="w-4 h-4" />
</button>
<button
onClick={() => openEditTrack(track)}
title="Edit track"
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => deleteTrack(track.id)}
title="Delete track"
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Linked Posts */}
{posts.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3>
</div>
<div className="divide-y divide-border-light">
{posts.map(post => (
<div key={post.id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-text-primary">{post.title}</h4>
<StatusBadge status={post.status} size="xs" />
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-tertiary">
{post.track_name && <span className="px-1.5 py-0.5 rounded bg-surface-tertiary">{post.track_name}</span>}
{post.assigned_name && <span> {post.assigned_name}</span>}
{post.platforms && post.platforms.length > 0 && (
<PlatformIcons platforms={post.platforms} size={14} />
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Add/Edit Track Modal */}
<Modal
isOpen={showTrackModal}
onClose={() => { setShowTrackModal(false); setEditingTrack(null) }}
title={editingTrack ? 'Edit Track' : 'Add Track'}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Track Name</label>
<input
type="text"
value={trackForm.name}
onChange={e => setTrackForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="e.g., Instagram Paid Ads, Organic Wave, Google Search..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Type</label>
<select
value={trackForm.type}
onChange={e => setTrackForm(f => ({ ...f, type: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{Object.entries(TRACK_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Platform</label>
<select
value={trackForm.platform}
onChange={e => setTrackForm(f => ({ ...f, platform: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">All / Multiple</option>
{Object.entries(PLATFORMS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
<option value="google_ads">Google Ads</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Allocated (SAR)</label>
<input
type="number"
value={trackForm.budget_allocated}
onChange={e => setTrackForm(f => ({ ...f, budget_allocated: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
placeholder="0 for free/organic"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={trackForm.status}
onChange={e => setTrackForm(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{TRACK_STATUSES.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={trackForm.notes}
onChange={e => setTrackForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
placeholder="Keywords, targeting details, content plan..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowTrackModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button onClick={saveTrack} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
{editingTrack ? 'Save' : 'Add Track'}
</button>
</div>
</div>
</Modal>
{/* Update Metrics Modal */}
<Modal
isOpen={showMetricsModal}
onClose={() => { setShowMetricsModal(false); setMetricsTrack(null) }}
title={`Update Metrics — ${metricsTrack?.name || ''}`}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Spent (SAR)</label>
<input
type="number"
value={metricsForm.budget_spent}
onChange={e => setMetricsForm(f => ({ ...f, budget_spent: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
<input
type="number"
value={metricsForm.revenue}
onChange={e => setMetricsForm(f => ({ ...f, revenue: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Impressions</label>
<input
type="number"
value={metricsForm.impressions}
onChange={e => setMetricsForm(f => ({ ...f, impressions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
<input
type="number"
value={metricsForm.clicks}
onChange={e => setMetricsForm(f => ({ ...f, clicks: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
<input
type="number"
value={metricsForm.conversions}
onChange={e => setMetricsForm(f => ({ ...f, conversions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={metricsForm.notes}
onChange={e => setMetricsForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
placeholder="What's working, what to adjust..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowMetricsModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button onClick={saveMetrics} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
Save Metrics
</button>
</div>
</div>
</Modal>
{/* Delete Track Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTrackToDelete(null) }}
title="Delete Track?"
isConfirm
danger
confirmText="Delete Track"
onConfirm={confirmDeleteTrack}
>
Are you sure you want to delete this campaign track? This action cannot be undone.
</Modal>
</div>
)
}

View File

@@ -0,0 +1,637 @@
import { useState, useEffect, useContext } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Search, TrendingUp, DollarSign, Eye, MousePointer, Target, BarChart3 } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { api, PLATFORMS } from '../utils/api'
import { PlatformIcons } from '../components/PlatformIcon'
import CampaignCalendar from '../components/CampaignCalendar'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const EMPTY_CAMPAIGN = {
name: '', description: '', brand_id: '', status: 'planning',
start_date: '', end_date: '', budget: '', goals: '', platforms: [],
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', cost_per_click: '', notes: '',
}
function BudgetBar({ budget, spent }) {
if (!budget || budget <= 0) return null
const pct = Math.min((spent / budget) * 100, 100)
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="w-full">
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
<span>{spent?.toLocaleString() || 0} SAR spent</span>
<span>{budget?.toLocaleString()} SAR</span>
</div>
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function ROIBadge({ revenue, spent }) {
if (!spent || spent <= 0) return null
const roi = ((revenue - spent) / spent * 100).toFixed(0)
const color = roi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
return (
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${color}`}>
ROI {roi}%
</span>
)
}
function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
return (
<div className="bg-surface-secondary rounded-lg p-3 text-center">
<Icon className={`w-4 h-4 mx-auto mb-1 ${color}`} />
<div className={`text-sm font-bold ${color}`}>{value || '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function Campaigns() {
const { brands } = useContext(AppContext)
const { permissions } = useAuth()
const navigate = useNavigate()
const [campaigns, setCampaigns] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingCampaign, setEditingCampaign] = useState(null)
const [formData, setFormData] = useState(EMPTY_CAMPAIGN)
const [filters, setFilters] = useState({ brand: '', status: '' })
const [activeTab, setActiveTab] = useState('details') // details | performance
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
useEffect(() => { loadCampaigns() }, [])
const loadCampaigns = async () => {
try {
const res = await api.get('/campaigns')
setCampaigns(res.data || res || [])
} catch (err) {
console.error('Failed to load campaigns:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
const data = {
name: formData.name,
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
status: formData.status,
start_date: formData.start_date,
end_date: formData.end_date,
budget: formData.budget ? Number(formData.budget) : null,
goals: formData.goals,
platforms: formData.platforms || [],
budget_spent: formData.budget_spent ? Number(formData.budget_spent) : 0,
revenue: formData.revenue ? Number(formData.revenue) : 0,
impressions: formData.impressions ? Number(formData.impressions) : 0,
clicks: formData.clicks ? Number(formData.clicks) : 0,
conversions: formData.conversions ? Number(formData.conversions) : 0,
cost_per_click: formData.cost_per_click ? Number(formData.cost_per_click) : 0,
notes: formData.notes || '',
}
if (editingCampaign) {
await api.patch(`/campaigns/${editingCampaign.id || editingCampaign._id}`, data)
} else {
await api.post('/campaigns', data)
}
setShowModal(false)
setEditingCampaign(null)
setFormData(EMPTY_CAMPAIGN)
loadCampaigns()
} catch (err) {
console.error('Save failed:', err)
}
}
const openEdit = (campaign) => {
setEditingCampaign(campaign)
setFormData({
name: campaign.name || '',
description: campaign.description || '',
brand_id: campaign.brandId || campaign.brand_id || '',
status: campaign.status || 'planning',
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : '',
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : '',
budget: campaign.budget || '',
goals: campaign.goals || '',
platforms: campaign.platforms || [],
budget_spent: campaign.budgetSpent || campaign.budget_spent || '',
revenue: campaign.revenue || '',
impressions: campaign.impressions || '',
clicks: campaign.clicks || '',
conversions: campaign.conversions || '',
cost_per_click: campaign.costPerClick || campaign.cost_per_click || '',
notes: campaign.notes || '',
})
setActiveTab('details')
setShowModal(true)
}
const openNew = () => {
setEditingCampaign(null)
setFormData(EMPTY_CAMPAIGN)
setActiveTab('details')
setShowModal(true)
}
const filtered = campaigns.filter(c => {
if (filters.brand && String(c.brandId || c.brand_id) !== filters.brand) return false
if (filters.status && c.status !== filters.status) return false
return true
})
// Aggregate stats
const totalBudget = filtered.reduce((sum, c) => sum + (c.budget || 0), 0)
const totalSpent = filtered.reduce((sum, c) => sum + (c.budgetSpent || c.budget_spent || 0), 0)
const totalImpressions = filtered.reduce((sum, c) => sum + (c.impressions || 0), 0)
const totalClicks = filtered.reduce((sum, c) => sum + (c.clicks || 0), 0)
const totalConversions = filtered.reduce((sum, c) => sum + (c.conversions || 0), 0)
const totalRevenue = filtered.reduce((sum, c) => sum + (c.revenue || 0), 0)
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
<div className="h-[400px] bg-surface-tertiary rounded-xl"></div>
</div>
)
}
return (
<div className="space-y-6 animate-fade-in">
{/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-blue-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">SAR total</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-amber-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">SAR spent</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Eye className="w-4 h-4 text-purple-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<MousePointer className="w-4 h-4 text-green-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-red-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<BarChart3 className="w-4 h-4 text-emerald-500" />
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
</div>
<div className="text-lg font-bold text-text-primary">{totalRevenue.toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">SAR</div>
</div>
</div>
)}
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.name}</option>)}
</select>
<select
value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
>
<option value="">All Statuses</option>
<option value="planning">Planning</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
{permissions?.canCreateCampaigns && (
<button
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
>
<Plus className="w-4 h-4" />
New Campaign
</button>
)}
</div>
{/* Calendar */}
<CampaignCalendar campaigns={filtered} />
{/* Campaign list */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
</div>
<div className="divide-y divide-border-light">
{filtered.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No campaigns found
</div>
) : (
filtered.map(campaign => {
const spent = campaign.budgetSpent || campaign.budget_spent || 0
const budget = campaign.budget || 0
return (
<div
key={campaign.id || campaign._id}
onClick={() => permissions?.canEditCampaigns ? navigate(`/campaigns/${campaign.id || campaign._id}`) : navigate(`/campaigns/${campaign.id || campaign._id}`)}
className="flex items-center gap-4 px-5 py-4 hover:bg-surface-secondary cursor-pointer transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-semibold text-text-primary">{campaign.name}</h4>
{campaign.brandName && <BrandBadge brand={campaign.brandName} />}
<ROIBadge revenue={campaign.revenue || 0} spent={spent} />
</div>
{campaign.description && (
<p className="text-xs text-text-secondary line-clamp-1">{campaign.description}</p>
)}
<div className="flex items-center gap-3 mt-1.5">
{campaign.platforms && campaign.platforms.length > 0 && (
<PlatformIcons platforms={campaign.platforms} size={16} />
)}
{budget > 0 && (
<div className="w-32">
<BudgetBar budget={budget} spent={spent} />
</div>
)}
</div>
{/* Quick metrics row */}
{(campaign.impressions > 0 || campaign.clicks > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{campaign.impressions > 0 && <span>👁 {campaign.impressions.toLocaleString()}</span>}
{campaign.clicks > 0 && <span>🖱 {campaign.clicks.toLocaleString()}</span>}
{campaign.conversions > 0 && <span>🎯 {campaign.conversions.toLocaleString()}</span>}
</div>
)}
</div>
<div className="text-right shrink-0">
<StatusBadge status={campaign.status} size="xs" />
<div className="text-xs text-text-tertiary mt-1">
{campaign.startDate && campaign.endDate ? (
<>
{format(new Date(campaign.startDate), 'MMM d')} {format(new Date(campaign.endDate), 'MMM d, yyyy')}
</>
) : '—'}
</div>
</div>
</div>
)
})
)}
</div>
</div>
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingCampaign(null) }}
title={editingCampaign ? 'Edit Campaign' : 'Create Campaign'}
size="lg"
>
<div className="space-y-4">
{/* Tabs */}
{editingCampaign && (
<div className="flex gap-1 p-1 bg-surface-tertiary rounded-lg">
<button
onClick={() => setActiveTab('details')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'details' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
Details
</button>
<button
onClick={() => setActiveTab('performance')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'performance' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
Performance & ROI
</button>
</div>
)}
{activeTab === 'details' ? (
<>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Campaign name"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={formData.description}
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder="Campaign description..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
<select
value={formData.brand_id}
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Select brand</option>
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={formData.status}
onChange={e => setFormData(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="planning">Planning</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
{/* Platforms multi-select */}
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Platforms</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (formData.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
checked
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setFormData(f => ({
...f,
platforms: checked
? f.platforms.filter(p => p !== k)
: [...(f.platforms || []), k]
}))
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Start Date *</label>
<input
type="date"
value={formData.start_date}
onChange={e => setFormData(f => ({ ...f, start_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">End Date *</label>
<input
type="date"
value={formData.end_date}
onChange={e => setFormData(f => ({ ...f, end_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget (SAR)</label>
<input
type="number"
value={formData.budget}
onChange={e => setFormData(f => ({ ...f, budget: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="e.g., 50000"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Goals</label>
<input
type="text"
value={formData.goals}
onChange={e => setFormData(f => ({ ...f, goals: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Campaign goals"
/>
</div>
</div>
</>
) : (
/* Performance & ROI Tab */
<>
{/* Live metrics summary */}
{(formData.budget_spent || formData.impressions || formData.clicks) && (
<div className="grid grid-cols-4 gap-2 mb-2">
<MetricCard icon={DollarSign} label="Spent" value={formData.budget_spent ? `${Number(formData.budget_spent).toLocaleString()} SAR` : null} color="text-amber-600" />
<MetricCard icon={Eye} label="Impressions" value={formData.impressions ? Number(formData.impressions).toLocaleString() : null} color="text-purple-600" />
<MetricCard icon={MousePointer} label="Clicks" value={formData.clicks ? Number(formData.clicks).toLocaleString() : null} color="text-blue-600" />
<MetricCard icon={Target} label="Conversions" value={formData.conversions ? Number(formData.conversions).toLocaleString() : null} color="text-emerald-600" />
</div>
)}
{formData.budget && formData.budget_spent && (
<div className="p-3 bg-surface-secondary rounded-lg">
<BudgetBar budget={Number(formData.budget)} spent={Number(formData.budget_spent)} />
<div className="flex items-center gap-2 mt-2">
<ROIBadge revenue={Number(formData.revenue) || 0} spent={Number(formData.budget_spent) || 0} />
{formData.clicks > 0 && formData.budget_spent > 0 && (
<span className="text-[10px] text-text-tertiary">
CPC: {(Number(formData.budget_spent) / Number(formData.clicks)).toFixed(2)} SAR
</span>
)}
{formData.impressions > 0 && formData.clicks > 0 && (
<span className="text-[10px] text-text-tertiary">
CTR: {(Number(formData.clicks) / Number(formData.impressions) * 100).toFixed(2)}%
</span>
)}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Spent (SAR)</label>
<input
type="number"
value={formData.budget_spent}
onChange={e => setFormData(f => ({ ...f, budget_spent: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Amount spent so far"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
<input
type="number"
value={formData.revenue}
onChange={e => setFormData(f => ({ ...f, revenue: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Revenue generated"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Impressions</label>
<input
type="number"
value={formData.impressions}
onChange={e => setFormData(f => ({ ...f, impressions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Total impressions"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
<input
type="number"
value={formData.clicks}
onChange={e => setFormData(f => ({ ...f, clicks: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Total clicks"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
<input
type="number"
value={formData.conversions}
onChange={e => setFormData(f => ({ ...f, conversions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Conversions (visits, tickets...)"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={formData.notes}
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder="Performance notes, observations, what's working..."
/>
</div>
</>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingCampaign && permissions?.canDeleteCampaigns && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
Delete
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingCampaign(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!formData.name || !formData.start_date || !formData.end_date}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{editingCampaign ? 'Save Changes' : 'Create Campaign'}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title="Delete Campaign?"
isConfirm
danger
confirmText="Delete Campaign"
onConfirm={async () => {
if (editingCampaign) {
await api.delete(`/campaigns/${editingCampaign.id || editingCampaign._id}`)
setShowModal(false)
setEditingCampaign(null)
loadCampaigns()
}
}}
>
Are you sure you want to delete this campaign? All associated posts and tracks will also be deleted. This action cannot be undone.
</Modal>
</div>
)
}

View File

@@ -0,0 +1,311 @@
import { useContext, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, Users, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
function FinanceMini({ finance }) {
const { t } = useLanguage()
if (!finance) return null
const totalReceived = finance.totalReceived || 0
const spent = finance.spent || 0
const remaining = finance.remaining || 0
const roi = finance.roi || 0
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="bg-white rounded-xl border border-border p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.details')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
{totalReceived === 0 ? (
<div className="text-center py-6 text-sm text-text-tertiary">
{t('dashboard.noBudgetRecorded')}. <Link to="/finance" className="text-brand-primary underline">{t('dashboard.addBudget')}</Link>
</div>
) : (
<>
{/* Budget bar */}
<div className="mb-4">
<div className="flex justify-between text-xs text-text-tertiary mb-1">
<span>{spent.toLocaleString()} {t('dashboard.sar')} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {t('dashboard.sar')} {t('dashboard.received')}</span>
</div>
<div className="h-3 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
{/* Key numbers */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<PiggyBank className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{remaining.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
</div>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<DollarSign className="w-4 h-4 mx-auto mb-1 text-purple-500" />
<div className="text-sm font-bold text-purple-600">{(finance.revenue || 0).toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.revenue')}</div>
</div>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{roi.toFixed(0)}%
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.roi')}</div>
</div>
</div>
</>
)}
</div>
)
}
function ActiveCampaignsList({ campaigns, finance }) {
const active = campaigns.filter(c => c.status === 'active')
const campaignData = (finance?.campaigns || []).filter(c => c.status === 'active')
if (active.length === 0) return null
return (
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('dashboard.activeCampaigns')}</h3>
<Link to="/campaigns" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{active.map(c => {
const cd = campaignData.find(d => d.id === (c._id || c.id)) || {}
const spent = cd.tracks_spent || 0
const allocated = cd.tracks_allocated || 0
const pct = allocated > 0 ? (spent / allocated) * 100 : 0
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<Link key={c._id || c.id} to={`/campaigns/${c._id || c.id}`} className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
{allocated > 0 && (
<div className="mt-1.5 w-32">
<div className="flex justify-between text-[9px] text-text-tertiary mb-0.5">
<span>{spent.toLocaleString()}</span>
<span>{allocated.toLocaleString()} SAR</span>
</div>
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
)}
</div>
<div className="text-right shrink-0">
{cd.tracks_impressions > 0 && (
<div className="text-[10px] text-text-tertiary">
👁 {cd.tracks_impressions.toLocaleString()} · 🖱 {cd.tracks_clicks.toLocaleString()}
</div>
)}
</div>
</Link>
)
})}
</div>
</div>
)
}
export default function Dashboard() {
const { t } = useLanguage()
const { currentUser, teamMembers } = useContext(AppContext)
const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([])
const [tasks, setTasks] = useState([])
const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const [postsRes, campaignsRes, tasksRes, financeRes] = await Promise.allSettled([
api.get('/posts?limit=10&sort=-createdAt'),
api.get('/campaigns'),
api.get('/tasks'),
api.get('/finance/summary'),
])
setPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
setCampaigns(campaignsRes.status === 'fulfilled' ? (campaignsRes.value.data || campaignsRes.value || []) : [])
setTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
setFinance(financeRes.status === 'fulfilled' ? (financeRes.value.data || financeRes.value || null) : null)
} catch (err) {
console.error('Dashboard load error:', err)
} finally {
setLoading(false)
}
}
const activeCampaigns = campaigns.filter(c => c.status === 'active').length
const overdueTasks = tasks.filter(t =>
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length
const upcomingDeadlines = tasks
.filter(t => {
if (!t.dueDate || t.status === 'done') return false
const due = new Date(t.dueDate)
const now = new Date()
return isAfter(due, now) && isBefore(due, addDays(now, 7))
})
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
.slice(0, 8)
if (loading) {
return (
<div className="space-y-6 animate-pulse">
<div className="h-8 w-64 bg-surface-tertiary rounded-lg"></div>
<div className="grid grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-28 bg-surface-tertiary rounded-xl"></div>
))}
</div>
</div>
)
}
return (
<div className="space-y-6 animate-fade-in">
{/* Welcome */}
<div>
<h1 className="text-2xl font-bold text-text-primary">
Welcome back, {currentUser?.name || 'there'} 👋
</h1>
<p className="text-text-secondary mt-1">
Here's what's happening with your marketing today.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
<StatCard
icon={FileText}
label="Total Posts"
value={posts.length || 0}
subtitle={`${posts.filter(p => p.status === 'published').length} published`}
color="brand-primary"
/>
<StatCard
icon={Megaphone}
label="Active Campaigns"
value={activeCampaigns}
subtitle={`${campaigns.length} total`}
color="brand-secondary"
/>
<StatCard
icon={Wallet}
label="Budget Spent"
value={`${((finance?.spent || 0)).toLocaleString()}`}
subtitle={finance?.totalReceived ? `of ${finance.totalReceived.toLocaleString()} SAR` : 'No budget yet'}
color="brand-tertiary"
/>
<StatCard
icon={AlertTriangle}
label="Overdue Tasks"
value={overdueTasks}
subtitle={overdueTasks > 0 ? 'Needs attention' : 'All on track'}
color="brand-quaternary"
/>
</div>
{/* Three columns on large, stack on small */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Budget Overview */}
<FinanceMini finance={finance} />
{/* Active Campaigns with budget bars */}
<div className="lg:col-span-2">
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
</div>
</div>
{/* Two columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Posts */}
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Recent Posts</h3>
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{posts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No posts yet. Create your first post!
</div>
) : (
posts.slice(0, 8).map((post) => (
<div key={post._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
</div>
<StatusBadge status={post.status} size="xs" />
</div>
))
)}
</div>
</div>
{/* Upcoming Deadlines */}
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Upcoming Deadlines</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{upcomingDeadlines.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No upcoming deadlines this week. 🎉
</div>
) : (
upcomingDeadlines.map((task) => (
<div key={task._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className={`w-2 h-2 rounded-full ${
task.priority === 'urgent' ? 'bg-red-500' :
task.priority === 'high' ? 'bg-orange-500' :
task.priority === 'medium' ? 'bg-amber-400' : 'bg-gray-400'
}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<StatusBadge status={task.status} size="xs" />
</div>
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,434 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, DollarSign, TrendingUp, TrendingDown, Wallet, PiggyBank, Eye, MousePointer, Target, Edit2, Trash2 } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { api } from '../utils/api'
import Modal from '../components/Modal'
import StatusBadge from '../components/StatusBadge'
const CATEGORIES = [
{ value: 'marketing', label: 'Marketing' },
{ value: 'production', label: 'Production' },
{ value: 'equipment', label: 'Equipment' },
{ value: 'travel', label: 'Travel' },
{ value: 'other', label: 'Other' },
]
const EMPTY_ENTRY = {
label: '', amount: '', source: '', campaign_id: '', category: 'marketing', date_received: new Date().toISOString().slice(0, 10), notes: '',
}
function StatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
return (
<div className={`${bgColor} rounded-xl border border-border p-5`}>
<div className="flex items-center gap-2 mb-2">
<div className={`p-2 rounded-lg ${color.replace('text-', 'bg-')}/10`}>
<Icon className={`w-5 h-5 ${color}`} />
</div>
<span className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{label}</span>
</div>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
{sub && <div className="text-xs text-text-tertiary mt-1">{sub}</div>}
</div>
)
}
function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
const r = (size - stroke) / 2
const circ = 2 * Math.PI * r
const offset = circ - (Math.min(pct, 100) / 100) * circ
return (
<svg width={size} height={size} className="transform -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#f3f4f6" strokeWidth={stroke} />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" className="transition-all duration-500" />
<text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
className="fill-text-primary text-sm font-bold" transform={`rotate(90 ${size / 2} ${size / 2})`}>
{Math.round(pct)}%
</text>
</svg>
)
}
export default function Finance() {
const { brands } = useContext(AppContext)
const [entries, setEntries] = useState([])
const [summary, setSummary] = useState(null)
const [campaigns, setCampaigns] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState(null)
const [form, setForm] = useState(EMPTY_ENTRY)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [entryToDelete, setEntryToDelete] = useState(null)
useEffect(() => { loadAll() }, [])
const loadAll = async () => {
try {
const [ent, sum, camp] = await Promise.all([
api.get('/budget'),
api.get('/finance/summary'),
api.get('/campaigns'),
])
setEntries(ent.data || ent || [])
setSummary(sum.data || sum || {})
setCampaigns(camp.data || camp || [])
} catch (err) {
console.error('Failed to load finance:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
const data = {
label: form.label,
amount: Number(form.amount),
source: form.source || null,
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
category: form.category,
date_received: form.date_received,
notes: form.notes,
}
if (editing) {
await api.patch(`/budget/${editing._id || editing.id}`, data)
} else {
await api.post('/budget', data)
}
setShowModal(false)
setEditing(null)
setForm(EMPTY_ENTRY)
loadAll()
} catch (err) {
console.error('Save failed:', err)
}
}
const openEdit = (entry) => {
setEditing(entry)
setForm({
label: entry.label || '',
amount: entry.amount || '',
source: entry.source || '',
campaign_id: entry.campaignId || entry.campaign_id || '',
category: entry.category || 'marketing',
date_received: entry.dateReceived || entry.date_received || '',
notes: entry.notes || '',
})
setShowModal(true)
}
const handleDelete = async (id) => {
setEntryToDelete(id)
setShowDeleteConfirm(true)
}
const confirmDelete = async () => {
if (!entryToDelete) return
await api.delete(`/budget/${entryToDelete}`)
setEntryToDelete(null)
loadAll()
}
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <div key={i} className="h-28 bg-surface-tertiary rounded-xl" />)}
</div>
</div>
)
}
const s = summary || {}
const totalReceived = s.totalReceived || 0
const totalSpent = s.spent || 0
const remaining = s.remaining || 0
const totalRevenue = s.revenue || 0
const roi = s.roi || 0
const spendPct = totalReceived > 0 ? (totalSpent / totalReceived) * 100 : 0
return (
<div className="space-y-6 animate-fade-in">
{/* Top metrics */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} SAR`} color="text-blue-600" />
<StatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} SAR`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
<StatCard icon={PiggyBank} label="Remaining" value={`${remaining.toLocaleString()} SAR`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<StatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} SAR`} color="text-purple-600" />
<StatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
value={`${roi.toFixed(1)}%`}
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
</div>
{/* Budget utilization + Global metrics */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Utilization ring */}
<div className="bg-white rounded-xl border border-border p-5 flex flex-col items-center justify-center">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
<ProgressRing
pct={spendPct}
size={120}
stroke={10}
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
/>
<div className="text-xs text-text-tertiary mt-3">
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} SAR
</div>
</div>
{/* Global performance */}
<div className="bg-white rounded-xl border border-border p-5 lg:col-span-2">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3>
<div className="grid grid-cols-3 gap-6">
<div className="text-center">
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Impressions</div>
</div>
<div className="text-center">
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Clicks</div>
{s.clicks > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} SAR</div>
)}
</div>
<div className="text-center">
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
<div className="text-xs text-text-tertiary">Conversions</div>
{s.conversions > 0 && s.spent > 0 && (
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} SAR</div>
)}
</div>
</div>
{s.impressions > 0 && s.clicks > 0 && (
<div className="mt-4 pt-3 border-t border-border text-center">
<span className="text-xs text-text-tertiary">
CTR: {(s.clicks / s.impressions * 100).toFixed(2)}%
{s.conversions > 0 && s.clicks > 0 && ` · Conv. Rate: ${(s.conversions / s.clicks * 100).toFixed(2)}%`}
</span>
</div>
)}
</div>
</div>
{/* Per-campaign breakdown */}
{s.campaigns && s.campaigns.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Campaign Breakdown</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Allocated</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Impressions</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Clicks</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{s.campaigns.map(c => {
const cRoi = c.tracks_spent > 0 ? ((c.tracks_revenue - c.tracks_spent) / c.tracks_spent * 100) : 0
return (
<tr key={c.id} className="hover:bg-surface-secondary">
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
<td className="px-4 py-3 text-right">
{c.tracks_spent > 0 ? (
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
{cRoi.toFixed(0)}%
</span>
) : '—'}
</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-center"><StatusBadge status={c.status} size="xs" /></td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Budget entries */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Budget Received</h3>
<button
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<Plus className="w-3.5 h-3.5" /> Add Entry
</button>
</div>
{entries.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No budget entries yet. Add your first received budget.
</div>
) : (
<div className="divide-y divide-border-light">
{entries.map(entry => (
<div key={entry.id || entry._id} className="flex items-center gap-4 px-5 py-4 hover:bg-surface-secondary">
<div className="p-2 rounded-lg bg-emerald-50">
<DollarSign className="w-4 h-4 text-emerald-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h4 className="text-sm font-semibold text-text-primary">{entry.label}</h4>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary capitalize">
{entry.category}
</span>
</div>
<div className="text-xs text-text-tertiary">
{entry.source && <span>{entry.source} · </span>}
{entry.campaign_name && <span>{entry.campaign_name} · </span>}
{entry.date_received && format(new Date(entry.date_received), 'MMM d, yyyy')}
</div>
{entry.notes && <p className="text-xs text-text-secondary mt-0.5">{entry.notes}</p>}
</div>
<div className="text-right shrink-0">
<div className="text-base font-bold text-emerald-600">{Number(entry.amount).toLocaleString()} SAR</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => openEdit(entry)} className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(entry.id || entry._id)} className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditing(null) }}
title={editing ? 'Edit Budget Entry' : 'Add Budget Entry'}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Label *</label>
<input
type="text"
value={form.label}
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="e.g., Seerah Campaign Budget, Additional Q1 Funds..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Amount (SAR) *</label>
<input
type="number"
value={form.amount}
onChange={e => setForm(f => ({ ...f, amount: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="50000"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Date Received *</label>
<input
type="date"
value={form.date_received}
onChange={e => setForm(f => ({ ...f, date_received: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Source</label>
<input
type="text"
value={form.source}
onChange={e => setForm(f => ({ ...f, source: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
placeholder="e.g., CEO Approval, Annual Budget..."
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Category</label>
<select
value={form.category}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Campaign (optional)</label>
<select
value={form.campaign_id}
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">General / Not linked</option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={form.notes}
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
placeholder="Any details about this budget entry..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button
onClick={handleSave}
disabled={!form.label || !form.amount || !form.date_received}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{editing ? 'Save Changes' : 'Add Entry'}
</button>
</div>
</div>
</Modal>
{/* Delete Budget Entry Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setEntryToDelete(null) }}
title="Delete Budget Entry?"
isConfirm
danger
confirmText="Delete Entry"
onConfirm={confirmDelete}
>
Are you sure you want to delete this budget entry? This action cannot be undone.
</Modal>
</div>
)
}

119
client/src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, Mail, AlertCircle } from 'lucide-react'
export default function Login() {
const navigate = useNavigate()
const { login } = useAuth()
const { t } = useLanguage()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
navigate('/')
} catch (err) {
setError(err.message || 'Invalid email or password')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Logo & Title */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('login.title')}</h1>
<p className="text-slate-400">{t('login.subtitle')}</p>
</div>
{/* Login Card */}
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
{t('auth.email')}
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="f.mahidi@samayainvest.com"
required
autoFocus
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
{t('auth.password')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
/>
</div>
</div>
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('auth.signingIn')}
</span>
) : (
t('auth.loginBtn')
)}
</button>
</form>
{/* Default Credentials */}
<div className="mt-6 pt-6 border-t border-slate-700/50">
<p className="text-xs text-slate-500 text-center">
{t('login.defaultCreds')} <span className="text-slate-400 font-medium">f.mahidi@samayainvest.com</span> / <span className="text-slate-400 font-medium">admin123</span>
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,669 @@
import { useState, useEffect, useContext, useRef } from 'react'
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import KanbanBoard from '../components/KanbanBoard'
import PostCard from '../components/PostCard'
import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal'
const EMPTY_POST = {
title: '', description: '', brand_id: '', platforms: [],
status: 'draft', assigned_to: '', scheduled_date: '', notes: '', campaign_id: '',
publication_links: [],
}
export default function PostProduction() {
const { t } = useLanguage()
const { teamMembers, brands } = useContext(AppContext)
const { canEditResource, canDeleteResource } = useAuth()
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban')
const [showModal, setShowModal] = useState(false)
const [editingPost, setEditingPost] = useState(null)
const [formData, setFormData] = useState(EMPTY_POST)
const [campaigns, setCampaigns] = useState([])
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '' })
const [searchTerm, setSearchTerm] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [publishError, setPublishError] = useState('')
const [dragActive, setDragActive] = useState(false)
const fileInputRef = useRef(null)
useEffect(() => {
loadPosts()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
}, [])
const loadPosts = async () => {
try {
const res = await api.get('/posts')
setPosts(res.data || res || [])
} catch (err) {
console.error('Failed to load posts:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setPublishError('')
try {
const data = {
title: formData.title,
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
assigned_to: formData.assigned_to ? Number(formData.assigned_to) : null,
status: formData.status,
platforms: formData.platforms || [],
scheduled_date: formData.scheduled_date || null,
notes: formData.notes,
campaign_id: formData.campaign_id ? Number(formData.campaign_id) : null,
publication_links: formData.publication_links || [],
}
// Client-side validation: check publication links before publishing
if (data.status === 'published' && data.platforms.length > 0) {
const missingPlatforms = data.platforms.filter(platform => {
const link = (data.publication_links || []).find(l => l.platform === platform)
return !link || !link.url || !link.url.trim()
})
if (missingPlatforms.length > 0) {
const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ')
setPublishError(`${t('posts.publishMissing')} ${names}`)
return
}
}
if (editingPost) {
await api.patch(`/posts/${editingPost._id}`, data)
} else {
await api.post('/posts', data)
}
setShowModal(false)
setEditingPost(null)
setFormData(EMPTY_POST)
setAttachments([])
loadPosts()
} catch (err) {
console.error('Save failed:', err)
if (err.message?.includes('Cannot publish')) {
setPublishError(err.message.replace(/.*: /, ''))
}
}
}
const handleMovePost = async (postId, newStatus) => {
try {
await api.patch(`/posts/${postId}`, { status: newStatus })
loadPosts()
} catch (err) {
console.error('Move failed:', err)
if (err.message?.includes('Cannot publish')) {
alert('Cannot publish: all platform publication links must be filled first.')
}
}
}
const loadAttachments = async (postId) => {
try {
const data = await api.get(`/posts/${postId}/attachments`)
setAttachments(Array.isArray(data) ? data : (data.data || []))
} catch (err) {
console.error('Failed to load attachments:', err)
setAttachments([])
}
}
const handleFileUpload = async (files) => {
if (!editingPost || !files?.length) return
setUploading(true)
setUploadProgress(0)
const postId = editingPost._id || editingPost.id
for (let i = 0; i < files.length; i++) {
const fd = new FormData()
fd.append('file', files[i])
try {
await api.upload(`/posts/${postId}/attachments`, fd)
setUploadProgress(Math.round(((i + 1) / files.length) * 100))
} catch (err) {
console.error('Upload failed:', err)
}
}
setUploading(false)
setUploadProgress(0)
loadAttachments(postId)
}
const handleDeleteAttachment = async (attachmentId) => {
try {
await api.delete(`/attachments/${attachmentId}`)
if (editingPost) loadAttachments(editingPost._id || editingPost.id)
} catch (err) {
console.error('Delete attachment failed:', err)
}
}
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) }
const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) }
const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() }
const handleDropFiles = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
}
const updatePublicationLink = (platform, url) => {
setFormData(f => {
const links = [...(f.publication_links || [])]
const idx = links.findIndex(l => l.platform === platform)
if (idx >= 0) {
links[idx] = { ...links[idx], url }
} else {
links.push({ platform, url })
}
return { ...f, publication_links: links }
})
}
const openEdit = (post) => {
if (!canEditResource('post', post)) {
alert('You can only edit your own posts')
return
}
setEditingPost(post)
setPublishError('')
setFormData({
title: post.title || '',
description: post.description || '',
brand_id: post.brandId || post.brand_id || '',
platforms: post.platforms || (post.platform ? [post.platform] : []),
status: post.status || 'draft',
assigned_to: post.assignedTo || post.assigned_to || '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
notes: post.notes || '',
campaign_id: post.campaignId || post.campaign_id || '',
publication_links: post.publication_links || post.publicationLinks || [],
})
loadAttachments(post._id || post.id)
setShowModal(true)
}
const openNew = () => {
setEditingPost(null)
setFormData(EMPTY_POST)
setAttachments([])
setPublishError('')
setShowModal(true)
}
const filteredPosts = posts.filter(p => {
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
if (filters.campaign && String(p.campaignId || p.campaign_id) !== filters.campaign) return false
if (searchTerm && !p.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
return true
})
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
<div className="flex gap-4">
{[...Array(5)].map((_, i) => <div key={i} className="w-72 h-96 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('posts.searchPosts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
/>
</div>
{/* Filters */}
<div data-tutorial="filters" className="flex gap-3">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
<select
value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
</div>
{/* View toggle */}
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
<button
onClick={() => setView('kanban')}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setView('list')}
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<List className="w-4 h-4" />
</button>
</div>
{/* New post */}
<button
data-tutorial="new-post"
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
>
<Plus className="w-4 h-4" />
{t('posts.newPost')}
</button>
</div>
{/* Content */}
{view === 'kanban' ? (
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
) : (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{filteredPosts.map(post => (
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
))}
{filteredPosts.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">
{t('posts.noPostsFound')}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingPost(null) }}
title={editingPost ? t('posts.editPost') : t('posts.createPost')}
size="lg"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.postTitle')} *</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.postTitlePlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.description')}</label>
<textarea
value={formData.description}
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
rows={4}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.postDescPlaceholder')}
/>
</div>
{/* Campaign */}
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.campaign')}</label>
<select
value={formData.campaign_id}
onChange={e => setFormData(f => ({ ...f, campaign_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.noCampaign')}</option>
{campaigns.map(c => <option key={c._id} value={c._id}>{c.name}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.brand')}</label>
<select
value={formData.brand_id}
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('posts.selectBrand')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.platforms')}</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (formData.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
checked
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setFormData(f => ({
...f,
platforms: checked
? f.platforms.filter(p => p !== k)
: [...(f.platforms || []), k]
}))
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.status')}</label>
<select
value={formData.status}
onChange={e => setFormData(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="draft">{t('posts.status.draft')}</option>
<option value="in_review">{t('posts.status.in_review')}</option>
<option value="approved">{t('posts.status.approved')}</option>
<option value="scheduled">{t('posts.status.scheduled')}</option>
<option value="published">{t('posts.status.published')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.assignTo')}</label>
<select
value={formData.assigned_to}
onChange={e => setFormData(f => ({ ...f, assigned_to: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.scheduledDate')}</label>
<input
type="datetime-local"
value={formData.scheduled_date}
onChange={e => setFormData(f => ({ ...f, scheduled_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.notes')}</label>
<input
type="text"
value={formData.notes}
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.additionalNotes')}
/>
</div>
</div>
{/* Publication Links */}
{(formData.platforms || []).length > 0 && (
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="flex items-center gap-1.5">
<Link2 className="w-4 h-4" />
{t('posts.publicationLinks')}
</span>
</label>
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
{(formData.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (formData.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-2">
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)
})}
{formData.status === 'published' && (formData.platforms || []).some(p => {
const link = (formData.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-1"> {t('posts.publishRequired')}</p>
)}
</div>
</div>
)}
{/* Attachments (only for existing posts) */}
{editingPost && (
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="flex items-center gap-1.5">
<Paperclip className="w-4 h-4" />
{t('posts.attachments')}
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
</span>
</label>
{/* Existing attachments */}
{attachments.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
{attachments.map(att => {
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
return (
<div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
{isImage ? (
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer">
<img
src={`http://localhost:3001${attUrl}`}
alt={name}
className="w-full h-24 object-cover"
/>
</a>
) : (
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-24">
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
)}
<button
onClick={() => handleDeleteAttachment(att.id || att._id)}
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm"
title={t('posts.deleteAttachment')}
>
<X className="w-3 h-3" />
</button>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
{name}
</div>
</div>
)
})}
</div>
)}
{/* Upload area */}
<div
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onClick={() => fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeaveZone}
onDragOver={handleDragOverZone}
onDrop={handleDropFiles}
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileUpload(e.target.files)}
/>
<Upload className="w-6 h-6 text-text-tertiary mx-auto mb-1" />
<p className="text-xs text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
</p>
<p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p>
</div>
{/* Upload progress */}
{uploading && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
<span>{t('posts.uploading')}</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-surface-tertiary rounded-full h-1.5">
<div
className="bg-brand-primary h-1.5 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
</div>
)}
{/* Publish validation error */}
{publishError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{publishError}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingPost && canDeleteResource('post', editingPost) && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
{t('common.delete')}
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingPost(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={!formData.title}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{editingPost ? t('posts.saveChanges') : t('posts.createPost')}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('posts.deletePost')}
isConfirm
danger
confirmText={t('posts.deletePost')}
onConfirm={async () => {
if (editingPost) {
try {
await api.delete(`/posts/${editingPost._id || editingPost.id}`)
setShowModal(false)
setEditingPost(null)
loadPosts()
} catch (err) {
console.error('Delete failed:', err)
}
}
}}
>
{t('posts.deleteConfirm')}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,777 @@
import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List,
GanttChart, Settings, Calendar, Clock
} from 'lucide-react'
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore, parseISO } from 'date-fns'
import { AppContext } from '../App'
import { api, PRIORITY_CONFIG } from '../utils/api'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const TASK_COLUMNS = [
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
{ id: 'in_progress', label: 'In Progress', color: 'bg-blue-400' },
{ id: 'done', label: 'Done', color: 'bg-emerald-400' },
]
export default function ProjectDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { teamMembers, brands } = useContext(AppContext)
const [project, setProject] = useState(null)
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban')
const [showTaskModal, setShowTaskModal] = useState(false)
const [showProjectModal, setShowProjectModal] = useState(false)
const [editingTask, setEditingTask] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [taskToDelete, setTaskToDelete] = useState(null)
const [taskForm, setTaskForm] = useState({
title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo'
})
const [projectForm, setProjectForm] = useState({
name: '', description: '', brand_id: '', owner_id: '', status: 'active', due_date: ''
})
// Drag state for kanban
const [draggedTask, setDraggedTask] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
useEffect(() => { loadProject() }, [id])
const loadProject = async () => {
try {
const proj = await api.get(`/projects/${id}`)
setProject(proj.data || proj)
const tasksRes = await api.get(`/tasks?project_id=${id}`)
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
} catch (err) {
console.error('Failed to load project:', err)
} finally {
setLoading(false)
}
}
const handleTaskSave = async () => {
try {
const data = {
title: taskForm.title,
description: taskForm.description,
priority: taskForm.priority,
assigned_to: taskForm.assigned_to ? Number(taskForm.assigned_to) : null,
due_date: taskForm.due_date || null,
status: taskForm.status,
project_id: Number(id),
}
if (editingTask) {
await api.patch(`/tasks/${editingTask._id}`, data)
} else {
await api.post('/tasks', data)
}
setShowTaskModal(false)
setEditingTask(null)
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
loadProject()
} catch (err) {
console.error('Task save failed:', err)
}
}
const handleTaskStatusChange = async (taskId, newStatus) => {
try {
await api.patch(`/tasks/${taskId}`, { status: newStatus })
loadProject()
} catch (err) {
console.error('Status change failed:', err)
}
}
const handleDeleteTask = async (taskId) => {
setTaskToDelete(taskId)
setShowDeleteConfirm(true)
}
const confirmDeleteTask = async () => {
if (!taskToDelete) return
try {
await api.delete(`/tasks/${taskToDelete}`)
loadProject()
setTaskToDelete(null)
} catch (err) {
console.error('Delete failed:', err)
}
}
const openEditTask = (task) => {
setEditingTask(task)
setTaskForm({
title: task.title || '',
description: task.description || '',
priority: task.priority || 'medium',
assigned_to: task.assignedTo || task.assigned_to || '',
due_date: task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 10) : '',
status: task.status || 'todo',
})
setShowTaskModal(true)
}
const openNewTask = () => {
setEditingTask(null)
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
setShowTaskModal(true)
}
const openEditProject = () => {
if (!project) return
setProjectForm({
name: project.name || '',
description: project.description || '',
brand_id: project.brandId || project.brand_id || '',
owner_id: project.ownerId || project.owner_id || '',
status: project.status || 'active',
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
})
setShowProjectModal(true)
}
const handleProjectSave = async () => {
try {
await api.patch(`/projects/${id}`, {
name: projectForm.name,
description: projectForm.description,
brand_id: projectForm.brand_id ? Number(projectForm.brand_id) : null,
owner_id: projectForm.owner_id ? Number(projectForm.owner_id) : null,
status: projectForm.status,
due_date: projectForm.due_date || null,
})
setShowProjectModal(false)
loadProject()
} catch (err) {
console.error('Project save failed:', err)
}
}
// Drag handlers
const handleDragStart = (e, task) => {
setDraggedTask(task)
e.dataTransfer.effectAllowed = 'move'
setTimeout(() => { e.target.style.opacity = '0.4' }, 0)
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedTask(null)
setDragOverCol(null)
}
const handleDragOver = (e, colId) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colId)
}
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
}
const handleDrop = (e, colId) => {
e.preventDefault()
setDragOverCol(null)
if (draggedTask && draggedTask.status !== colId) {
handleTaskStatusChange(draggedTask._id, colId)
}
setDraggedTask(null)
}
if (loading) {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-48 bg-surface-tertiary rounded-lg"></div>
<div className="h-40 bg-surface-tertiary rounded-xl"></div>
</div>
)
}
if (!project) {
return (
<div className="py-20 text-center">
<p className="text-text-secondary">Project not found</p>
<button onClick={() => navigate('/projects')} className="mt-4 text-brand-primary hover:underline text-sm">
Back to Projects
</button>
</div>
)
}
const completedTasks = tasks.filter(t => t.status === 'done').length
const progress = tasks.length > 0 ? Math.round((completedTasks / tasks.length) * 100) : 0
const ownerName = project.ownerName || project.owner_name
const brandName = project.brandName || project.brand_name
// Gantt chart helpers
const getGanttRange = () => {
const today = startOfDay(new Date())
let earliest = today
let latest = addDays(today, 14)
tasks.forEach(t => {
if (t.createdAt) {
const d = startOfDay(new Date(t.createdAt))
if (isBefore(d, earliest)) earliest = d
}
if (t.dueDate) {
const d = startOfDay(new Date(t.dueDate))
if (isAfter(d, latest)) latest = addDays(d, 1)
}
})
if (project.dueDate) {
const d = startOfDay(new Date(project.dueDate))
if (isAfter(d, latest)) latest = addDays(d, 1)
}
// Ensure minimum 14 days
if (differenceInDays(latest, earliest) < 14) latest = addDays(earliest, 14)
return { earliest, latest, totalDays: differenceInDays(latest, earliest) + 1 }
}
return (
<div className="space-y-6 animate-fade-in">
{/* Back button */}
<button
onClick={() => navigate('/projects')}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Projects
</button>
{/* Project header */}
<div className="bg-white rounded-xl border border-border p-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-text-primary">{project.name}</h1>
<StatusBadge status={project.status} />
</div>
<div className="flex items-center gap-3 flex-wrap">
{brandName && <BrandBadge brand={brandName} />}
{ownerName && (
<span className="text-sm text-text-secondary">
Owned by <span className="font-medium">{ownerName}</span>
</span>
)}
{project.dueDate && (
<span className="text-sm text-text-tertiary flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
Due {format(new Date(project.dueDate), 'MMMM d, yyyy')}
</span>
)}
</div>
</div>
<button
onClick={openEditProject}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
>
<Settings className="w-4 h-4" />
Edit
</button>
</div>
{project.description && (
<p className="text-sm text-text-secondary mb-4">{project.description}</p>
)}
{/* Progress */}
<div className="max-w-md">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-text-secondary font-medium">Progress</span>
<span className="font-semibold text-text-primary">{progress}%</span>
</div>
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-brand-primary to-brand-primary-light rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-text-tertiary mt-1">{completedTasks} of {tasks.length} tasks completed</p>
</div>
</div>
{/* View switcher + Add Task */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5">
{[
{ id: 'kanban', icon: LayoutGrid, label: 'Board' },
{ id: 'list', icon: List, label: 'List' },
{ id: 'gantt', icon: GanttChart, label: 'Timeline' },
].map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
<v.icon className="w-4 h-4" />
{v.label}
</button>
))}
</div>
<button
onClick={openNewTask}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
>
<Plus className="w-4 h-4" />
Add Task
</button>
</div>
{/* ─── KANBAN VIEW ─── */}
{view === 'kanban' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{TASK_COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.status === col.id)
const isOver = dragOverCol === col.id && draggedTask?.status !== col.id
return (
<div key={col.id}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{colTasks.length}
</span>
</div>
<div
className={`rounded-xl p-2 space-y-2 min-h-[150px] border-2 transition-colors ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.id)}
>
{colTasks.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? 'Drop here' : 'No tasks'}
</div>
) : (
colTasks.map(task => (
<TaskKanbanCard
key={task._id}
task={task}
onEdit={() => openEditTask(task)}
onDelete={() => handleDeleteTask(task._id)}
onStatusChange={handleTaskStatusChange}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
))
)}
</div>
</div>
)
})}
</div>
)}
{/* ─── LIST VIEW ─── */}
{view === 'list' && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-16"></th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{tasks.length === 0 ? (
<tr><td colSpan={7} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
) : (
tasks.map(task => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const assigneeName = task.assignedName || task.assigned_name
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
return (
<tr key={task._id} className="hover:bg-surface-secondary group">
<td className="px-4 py-3">
<div className={`w-2.5 h-2.5 rounded-full ${prio.color}`} />
</td>
<td className="px-4 py-3">
<button onClick={() => openEditTask(task)} className="text-sm font-medium text-text-primary hover:text-brand-primary text-left">
{task.title}
</button>
{task.description && <p className="text-xs text-text-tertiary line-clamp-1 mt-0.5">{task.description}</p>}
</td>
<td className="px-4 py-3"><StatusBadge status={task.status} size="xs" /></td>
<td className="px-4 py-3 text-xs font-medium text-text-secondary capitalize">{prio.label}</td>
<td className="px-4 py-3 text-xs text-text-secondary">{assigneeName || '—'}</td>
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : '—'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditTask(task)} className="p-1 rounded hover:bg-surface-tertiary text-text-tertiary">
<Edit3 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDeleteTask(task._id)} className="p-1 rounded hover:bg-red-50 text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
)}
{/* ─── GANTT / TIMELINE VIEW ─── */}
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
{/* ─── TASK MODAL ─── */}
<Modal
isOpen={showTaskModal}
onClose={() => { setShowTaskModal(false); setEditingTask(null) }}
title={editingTask ? 'Edit Task' : 'Add Task'}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Title *</label>
<input
type="text"
value={taskForm.title}
onChange={e => setTaskForm(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Task title"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={taskForm.description}
onChange={e => setTaskForm(f => ({ ...f, description: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder="Optional description"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Priority</label>
<select value={taskForm.priority} onChange={e => setTaskForm(f => ({ ...f, priority: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select value={taskForm.status} onChange={e => setTaskForm(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="todo">To Do</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Assign To</label>
<select value={taskForm.assigned_to} onChange={e => setTaskForm(f => ({ ...f, assigned_to: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">Unassigned</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
<input type="date" value={taskForm.due_date} onChange={e => setTaskForm(f => ({ ...f, due_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingTask && (
<button onClick={() => handleDeleteTask(editingTask._id)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto">
Delete
</button>
)}
<button onClick={() => { setShowTaskModal(false); setEditingTask(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
Cancel
</button>
<button onClick={handleTaskSave} disabled={!taskForm.title}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm">
{editingTask ? 'Save Changes' : 'Add Task'}
</button>
</div>
</div>
</Modal>
{/* ─── PROJECT EDIT MODAL ─── */}
<Modal
isOpen={showProjectModal}
onClose={() => setShowProjectModal(false)}
title="Edit Project"
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input type="text" value={projectForm.name} onChange={e => setProjectForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Project name" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea value={projectForm.description} onChange={e => setProjectForm(f => ({ ...f, description: e.target.value }))}
rows={3} className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder="Project description..." />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
<select value={projectForm.brand_id} onChange={e => setProjectForm(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">Select brand</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select value={projectForm.status} onChange={e => setProjectForm(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
<select value={projectForm.owner_id} onChange={e => setProjectForm(f => ({ ...f, owner_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">Unassigned</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
<input type="date" value={projectForm.due_date} onChange={e => setProjectForm(f => ({ ...f, due_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowProjectModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
Cancel
</button>
<button onClick={handleProjectSave} disabled={!projectForm.name}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm">
Save Changes
</button>
</div>
</div>
</Modal>
</div>
)
}
// ─── Task Kanban Card ───────────────────────────────
function TaskKanbanCard({ task, onEdit, onDelete, onStatusChange, onDragStart, onDragEnd }) {
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const assigneeName = task.assignedName || task.assigned_name
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
return (
<div
draggable
onDragStart={(e) => onDragStart(e, task)}
onDragEnd={onDragEnd}
className="bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-grab active:cursor-grabbing"
>
<div className="flex items-start gap-2">
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
<div className="flex-1 min-w-0">
<h5 className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</h5>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{assigneeName && (
<span className="text-[10px] text-text-tertiary">{assigneeName}</span>
)}
{task.dueDate && (
<span className={`text-[10px] flex items-center gap-0.5 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
<Clock className="w-3 h-3" />
{format(new Date(task.dueDate), 'MMM d')}
</span>
)}
</div>
</div>
</div>
{/* Actions on hover */}
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
{task.status !== 'done' && (
<button onClick={() => onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done')}
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
<Check className="w-3 h-3" />
{task.status === 'todo' ? 'Start' : 'Complete'}
</button>
)}
<button onClick={onEdit}
className="text-[10px] text-text-tertiary hover:bg-surface-tertiary px-2 py-0.5 rounded-full flex items-center gap-1">
<Edit3 className="w-3 h-3" /> Edit
</button>
<button onClick={onDelete}
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
}
// ─── Gantt / Timeline View ──────────────────────────
function GanttView({ tasks, project, onEditTask }) {
if (tasks.length === 0) {
return (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No tasks to display</p>
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
</div>
)
}
const today = startOfDay(new Date())
// Calculate range
let earliest = today
let latest = addDays(today, 21)
tasks.forEach(t => {
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
if (isBefore(created, earliest)) earliest = created
if (due && isAfter(due, latest)) latest = addDays(due, 2)
})
if (project.dueDate) {
const pd = startOfDay(new Date(project.dueDate))
if (isAfter(pd, latest)) latest = addDays(pd, 2)
}
const totalDays = differenceInDays(latest, earliest) + 1
// Generate day headers
const days = []
for (let i = 0; i < totalDays; i++) {
days.push(addDays(earliest, i))
}
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
const getBarStyle = (task) => {
const start = task.createdAt ? startOfDay(new Date(task.createdAt)) : today
const end = task.dueDate ? startOfDay(new Date(task.dueDate)) : addDays(start, 3)
const left = differenceInDays(start, earliest) * dayWidth
const width = Math.max(dayWidth, (differenceInDays(end, start) + 1) * dayWidth)
return { left: `${left}px`, width: `${width}px` }
}
const statusColors = {
todo: 'bg-gray-300',
in_progress: 'bg-blue-400',
done: 'bg-emerald-400',
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
{/* Day headers */}
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
<div className="w-[200px] shrink-0 px-4 py-2 text-xs font-semibold text-text-tertiary uppercase border-r border-border">
Task
</div>
<div className="flex">
{days.map((day, i) => {
const isToday = differenceInDays(day, today) === 0
const isWeekend = day.getDay() === 0 || day.getDay() === 6
return (
<div
key={i}
style={{ width: `${dayWidth}px` }}
className={`text-center py-2 border-r border-border-light text-[10px] ${
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
}`}
>
<div>{format(day, 'd')}</div>
<div className="text-[8px] uppercase">{format(day, 'EEE')}</div>
</div>
)
})}
</div>
</div>
{/* Task rows */}
{tasks.map(task => {
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const barStyle = getBarStyle(task)
return (
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />
<button onClick={() => onEditTask(task)}
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
{task.title}
</button>
</div>
<div className="relative flex-1" style={{ height: '44px' }}>
{/* Today line */}
{differenceInDays(today, earliest) >= 0 && (
<div
className="absolute top-0 bottom-0 w-px bg-brand-primary/30 z-10"
style={{ left: `${differenceInDays(today, earliest) * dayWidth + dayWidth / 2}px` }}
/>
)}
{/* Bar */}
<div
className={`absolute top-2.5 h-5 rounded-full ${statusColors[task.status] || 'bg-gray-300'} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
style={barStyle}
onClick={() => onEditTask(task)}
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
/>
</div>
</div>
)
})}
</div>
</div>
{/* Delete Task Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
title="Delete Task?"
isConfirm
danger
confirmText="Delete Task"
onConfirm={confirmDeleteTask}
>
Are you sure you want to delete this task? This action cannot be undone.
</Modal>
</div>
)
}

View File

@@ -0,0 +1,202 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Search, FolderKanban } from 'lucide-react'
import { AppContext } from '../App'
import { api } from '../utils/api'
import ProjectCard from '../components/ProjectCard'
import Modal from '../components/Modal'
const EMPTY_PROJECT = {
name: '', description: '', brand_id: '', status: 'active',
owner_id: '', due_date: '',
}
export default function Projects() {
const { teamMembers, brands } = useContext(AppContext)
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [formData, setFormData] = useState(EMPTY_PROJECT)
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => { loadProjects() }, [])
const loadProjects = async () => {
try {
const res = await api.get('/projects')
setProjects(res.data || res || [])
} catch (err) {
console.error('Failed to load projects:', err)
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
try {
const data = {
name: formData.name,
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
status: formData.status,
due_date: formData.due_date || null,
}
await api.post('/projects', data)
setShowModal(false)
setFormData(EMPTY_PROJECT)
loadProjects()
} catch (err) {
console.error('Create failed:', err)
}
}
const filtered = projects.filter(p => {
if (searchTerm && !p.name?.toLowerCase().includes(searchTerm.toLowerCase())) return false
return true
})
if (loading) {
return (
<div className="animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => <div key={i} className="h-56 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search projects..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
/>
</div>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
>
<Plus className="w-4 h-4" />
New Project
</button>
</div>
{/* Project grid */}
{filtered.length === 0 ? (
<div className="py-20 text-center">
<FolderKanban className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">No projects yet</p>
<p className="text-sm text-text-tertiary mt-1">Create your first project to start organizing work</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
{filtered.map(project => (
<ProjectCard key={project._id} project={project} />
))}
</div>
)}
{/* Create Modal */}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create New Project" size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Project name"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={formData.description}
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder="Project description..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
<select
value={formData.brand_id}
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Select brand</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={formData.status}
onChange={e => setFormData(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
<select
value={formData.owner_id}
onChange={e => setFormData(f => ({ ...f, owner_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">Unassigned</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
<input
type="date"
value={formData.due_date}
onChange={e => setFormData(f => ({ ...f, due_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={!formData.name}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
Create Project
</button>
</div>
</div>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages } from 'lucide-react'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
export default function Settings() {
const { t, lang, setLang } = useLanguage()
const [restarting, setRestarting] = useState(false)
const [success, setSuccess] = useState(false)
const handleRestartTutorial = async () => {
setRestarting(true)
setSuccess(false)
try {
await api.patch('/users/me/tutorial', { completed: false })
setSuccess(true)
setTimeout(() => {
window.location.reload() // Reload to trigger tutorial
}, 1500)
} catch (err) {
console.error('Failed to restart tutorial:', err)
alert('Failed to restart tutorial')
} finally {
setRestarting(false)
}
}
return (
<div className="space-y-6 animate-fade-in max-w-3xl">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<SettingsIcon className="w-7 h-7 text-brand-primary" />
{t('settings.title')}
</h1>
<p className="text-sm text-text-tertiary mt-1">{t('settings.preferences')}</p>
</div>
{/* General Settings */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2>
</div>
<div className="p-6 space-y-4">
{/* Language Selector */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Languages className="w-4 h-4" />
{t('settings.language')}
</label>
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
>
<option value="en">{t('settings.english')}</option>
<option value="ar">{t('settings.arabic')}</option>
</select>
</div>
</div>
</div>
{/* Tutorial Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-text-secondary">
{t('settings.tutorialDesc')}
</p>
<button
onClick={handleRestartTutorial}
disabled={restarting || success}
className="flex items-center gap-2 px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors"
>
{success ? (
<>
<CheckCircle className="w-4 h-4" />
{t('settings.tutorialRestarted')}
</>
) : (
<>
<Play className="w-4 h-4" />
{restarting ? t('settings.restarting') : t('settings.restartTutorial')}
</>
)}
</button>
{success && (
<p className="text-xs text-emerald-600 font-medium">
{t('settings.reloadingPage')}
</p>
)}
</div>
</div>
{/* More settings can go here in the future */}
<div className="bg-white rounded-xl border border-border overflow-hidden opacity-50">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.moreComingSoon')}</h2>
</div>
<div className="p-6">
<p className="text-sm text-text-secondary">
{t('settings.additionalSettings')}
</p>
</div>
</div>
</div>
)
}

423
client/src/pages/Tasks.jsx Normal file
View File

@@ -0,0 +1,423 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, CheckSquare, Edit2, Trash2, Filter } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import TaskCard from '../components/TaskCard'
import Modal from '../components/Modal'
export default function Tasks() {
const { t } = useLanguage()
const { currentUser, teamMembers } = useContext(AppContext)
const { user: authUser, canEditResource, canDeleteResource } = useAuth()
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingTask, setEditingTask] = useState(null)
const [draggedTask, setDraggedTask] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [taskToDelete, setTaskToDelete] = useState(null)
const [filterView, setFilterView] = useState('all') // 'all' | 'assigned_to_me' | 'created_by_me' | member id
const [users, setUsers] = useState([]) // for superadmin member filter
const [formData, setFormData] = useState({
title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: ''
})
const isSuperadmin = authUser?.role === 'superadmin'
useEffect(() => { loadTasks() }, [currentUser])
useEffect(() => {
if (isSuperadmin) {
// Load team members for superadmin filter
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
}
}, [isSuperadmin])
const loadTasks = async () => {
try {
const res = await api.get('/tasks')
setTasks(res.data || res || [])
} catch (err) {
console.error('Failed to load tasks:', err)
} finally {
setLoading(false)
}
}
// Filter tasks client-side based on selected view
const filteredTasks = tasks.filter(task => {
if (filterView === 'all') return true
if (filterView === 'assigned_to_me') {
// Tasks where I'm the assignee (via team_member_id on my user record)
const myTeamMemberId = authUser?.team_member_id
return myTeamMemberId && task.assigned_to === myTeamMemberId
}
if (filterView === 'created_by_me') {
return task.created_by_user_id === authUser?.id
}
// Superadmin filtering by specific team member (assigned_to = member id)
if (isSuperadmin && !isNaN(Number(filterView))) {
return task.assigned_to === Number(filterView)
}
return true
})
const handleSave = async () => {
try {
const data = {
title: formData.title,
description: formData.description,
priority: formData.priority,
due_date: formData.due_date || null,
status: formData.status,
assigned_to: formData.assigned_to || null,
is_personal: false,
}
if (editingTask) {
await api.patch(`/tasks/${editingTask._id || editingTask.id}`, data)
} else {
await api.post('/tasks', data)
}
setShowModal(false)
setEditingTask(null)
setFormData({ title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' })
loadTasks()
} catch (err) {
console.error('Save failed:', err)
if (err.message?.includes('403') || err.message?.includes('modify your own')) {
alert('You can only edit your own tasks')
}
}
}
const handleMove = async (taskId, newStatus) => {
try {
await api.patch(`/tasks/${taskId}`, { status: newStatus })
loadTasks()
} catch (err) {
console.error('Move failed:', err)
if (err.message?.includes('403')) {
alert('You can only modify your own tasks')
}
}
}
const openEdit = (task) => {
if (!canEditResource('task', task)) return
setEditingTask(task)
setFormData({
title: task.title || '',
description: task.description || '',
priority: task.priority || 'medium',
due_date: task.due_date || task.dueDate || '',
status: task.status || 'todo',
assigned_to: task.assigned_to || '',
})
setShowModal(true)
}
const handleDelete = (task) => {
if (!canDeleteResource('task', task)) return
setTaskToDelete(task)
setShowDeleteConfirm(true)
}
const confirmDelete = async () => {
if (!taskToDelete) return
try {
await api.delete(`/tasks/${taskToDelete._id || taskToDelete.id}`)
setTaskToDelete(null)
loadTasks()
} catch (err) {
console.error('Delete failed:', err)
}
}
const handleDragStart = (e, task) => {
setDraggedTask(task)
e.dataTransfer.effectAllowed = 'move'
if (e.target) {
setTimeout(() => e.target.style.opacity = '0.4', 0)
}
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedTask(null)
setDragOverCol(null)
}
const handleDragOver = (e, colStatus) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colStatus)
}
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setDragOverCol(null)
}
}
const handleDrop = (e, colStatus) => {
e.preventDefault()
setDragOverCol(null)
if (draggedTask && draggedTask.status !== colStatus) {
const taskId = draggedTask._id || draggedTask.id
handleMove(taskId, colStatus)
}
setDraggedTask(null)
}
const todoTasks = filteredTasks.filter(t => t.status === 'todo')
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress')
const doneTasks = filteredTasks.filter(t => t.status === 'done')
if (loading) {
return (
<div className="animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
<div className="grid grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => <div key={i} className="h-64 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
}
const columns = [
{ label: t('tasks.todo'), status: 'todo', items: todoTasks, color: 'bg-gray-400' },
{ label: t('tasks.in_progress'), status: 'in_progress', items: inProgressTasks, color: 'bg-blue-400' },
{ label: t('tasks.done'), status: 'done', items: doneTasks, color: 'bg-emerald-400' },
]
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-text-tertiary" />
<select
value={filterView}
onChange={e => setFilterView(e.target.value)}
className="px-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="all">{t('tasks.allTasks')}</option>
<option value="assigned_to_me">{t('tasks.assignedToMe')}</option>
<option value="created_by_me">{t('tasks.createdByMe')}</option>
{isSuperadmin && users.length > 0 && (
<optgroup label={t('tasks.byTeamMember')}>
{users.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</optgroup>
)}
</select>
</div>
<p className="text-sm text-text-secondary">
{filteredTasks.length} {filteredTasks.length !== 1 ? t('tasks.tasks') : t('tasks.task')}
{filterView !== 'all' && tasks.length !== filteredTasks.length && (
<span className="text-text-tertiary"> {t('tasks.of')} {tasks.length}</span>
)}
</p>
</div>
<button
onClick={() => { setEditingTask(null); setFormData({ title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' }); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
>
<Plus className="w-4 h-4" />
{t('tasks.newTask')}
</button>
</div>
{/* Task columns */}
{filteredTasks.length === 0 ? (
<div className="py-20 text-center">
<CheckSquare className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">
{tasks.length === 0 ? t('tasks.noTasks') : t('tasks.noMatch')}
</p>
<p className="text-sm text-text-tertiary mt-1">
{tasks.length === 0 ? t('tasks.createFirst') : t('tasks.tryFilter')}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{columns.map(col => {
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
return (
<div key={col.status}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{col.items.length}
</span>
</div>
<div
className={`kanban-column rounded-xl p-2 space-y-2 min-h-[200px] border-2 transition-colors ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.status)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.status)}
>
{col.items.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? t('posts.dropHere') : t('tasks.noTasks')}
</div>
) : (
col.items.map(task => {
const canEdit = canEditResource('task', task)
const canDelete = canDeleteResource('task', task)
return (
<div
key={task._id || task.id}
draggable={canEdit}
onDragStart={(e) => canEdit && handleDragStart(e, task)}
onDragEnd={handleDragEnd}
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
>
<div className="relative group">
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
{/* Edit/Delete overlay */}
{(canEdit || canDelete) && (
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{canEdit && (
<button
onClick={(e) => { e.stopPropagation(); openEdit(task) }}
className="p-1 hover:bg-surface-tertiary rounded text-text-tertiary hover:text-text-primary"
title={t('tasks.editTask')}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
{canDelete && (
<button
onClick={(e) => { e.stopPropagation(); handleDelete(task) }}
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
title={t('common.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>
</div>
)
})
)}
</div>
</div>
)
})}
</div>
)}
{/* Create/Edit Task Modal */}
<Modal isOpen={showModal} onClose={() => { setShowModal(false); setEditingTask(null) }} title={editingTask ? t('tasks.editTask') : t('tasks.createTask')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.taskTitle')} *</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('posts.whatNeedsDone')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.description')}</label>
<textarea
value={formData.description}
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.optionalDetails')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.assignTo')}</label>
<select
value={formData.assigned_to}
onChange={e => setFormData(f => ({ ...f, assigned_to: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => (
<option key={m.id || m._id} value={m.id || m._id}>{m.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.priority')}</label>
<select
value={formData.priority}
onChange={e => setFormData(f => ({ ...f, priority: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="low">{t('tasks.priority.low')}</option>
<option value="medium">{t('tasks.priority.medium')}</option>
<option value="high">{t('tasks.priority.high')}</option>
<option value="urgent">{t('tasks.priority.urgent')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.dueDate')}</label>
<input
type="date"
value={formData.due_date}
onChange={e => setFormData(f => ({ ...f, due_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => { setShowModal(false); setEditingTask(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={!formData.title}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{editingTask ? t('tasks.saveChanges') : t('tasks.createTask')}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
title={t('tasks.deleteTask')}
isConfirm
danger
confirmText={t('tasks.deleteTask')}
onConfirm={confirmDelete}
>
{t('tasks.deleteConfirm')}
</Modal>
</div>
)
}

448
client/src/pages/Team.jsx Normal file
View File

@@ -0,0 +1,448 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import MemberCard from '../components/MemberCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const EMPTY_MEMBER = {
name: '', email: '', password: '', role: 'content_writer', brands: '', phone: '',
}
const ROLES = [
{ value: 'manager', label: 'Manager' },
{ value: 'approver', label: 'Approver' },
{ value: 'publisher', label: 'Publisher' },
{ value: 'content_creator', label: 'Content Creator' },
{ value: 'producer', label: 'Producer' },
{ value: 'designer', label: 'Designer' },
{ value: 'content_writer', label: 'Content Writer' },
{ value: 'social_media_manager', label: 'Social Media Manager' },
{ value: 'photographer', label: 'Photographer' },
{ value: 'videographer', label: 'Videographer' },
{ value: 'strategist', label: 'Strategist' },
]
export default function Team() {
const { t } = useLanguage()
const { teamMembers, loadTeam, currentUser } = useContext(AppContext)
const { user } = useAuth()
const [showModal, setShowModal] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [editingMember, setEditingMember] = useState(null)
const [isEditingSelf, setIsEditingSelf] = useState(false)
const [formData, setFormData] = useState(EMPTY_MEMBER)
const [selectedMember, setSelectedMember] = useState(null)
const [memberTasks, setMemberTasks] = useState([])
const [memberPosts, setMemberPosts] = useState([])
const [loadingDetail, setLoadingDetail] = useState(false)
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
const openNew = () => {
setEditingMember(null)
setIsEditingSelf(false)
setFormData(EMPTY_MEMBER)
setShowModal(true)
}
const openEdit = (member) => {
const isSelf = member._id === user?.id || member.id === user?.id
setEditingMember(member)
setIsEditingSelf(isSelf)
setFormData({
name: member.name || '',
email: member.email || '',
password: '',
role: member.team_role || member.role || 'content_writer',
brands: Array.isArray(member.brands) ? member.brands.join(', ') : (member.brands || ''),
phone: member.phone || '',
})
setShowModal(true)
}
const handleSave = async () => {
try {
const brands = typeof formData.brands === 'string'
? formData.brands.split(',').map(b => b.trim()).filter(Boolean)
: formData.brands
// If editing self, use self-service endpoint
if (isEditingSelf) {
const data = {
name: formData.name,
team_role: formData.role,
brands,
phone: formData.phone,
}
await api.patch('/users/me/profile', data)
} else {
// Manager/superadmin creating or editing other users
const data = {
name: formData.name,
email: formData.email,
team_role: formData.role,
brands,
phone: formData.phone,
}
if (formData.password) {
data.password = formData.password
}
if (editingMember) {
await api.patch(`/users/team/${editingMember._id}`, data)
} else {
await api.post('/users/team', data)
}
}
setShowModal(false)
setEditingMember(null)
setIsEditingSelf(false)
setFormData(EMPTY_MEMBER)
loadTeam()
} catch (err) {
console.error('Save failed:', err)
alert(err.message || 'Failed to save')
}
}
const openMemberDetail = async (member) => {
setSelectedMember(member)
setLoadingDetail(true)
try {
const [tasksRes, postsRes] = await Promise.allSettled([
api.get(`/tasks?assignedTo=${member._id}`),
api.get(`/posts?assignedTo=${member._id}`),
])
setMemberTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
setMemberPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
} catch {
setMemberTasks([])
setMemberPosts([])
} finally {
setLoadingDetail(false)
}
}
// Member detail view
if (selectedMember) {
const todoCount = memberTasks.filter(t => t.status === 'todo').length
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
return (
<div className="space-y-6 animate-fade-in">
<button
onClick={() => setSelectedMember(null)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('team.backToTeam')}
</button>
{/* Member profile */}
<div className="bg-white rounded-xl border border-border p-6">
<div className="flex items-start gap-4">
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
<p className="text-sm text-text-secondary capitalize">{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}</p>
{selectedMember.email && (
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
)}
{selectedMember.brands && selectedMember.brands.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedMember.brands.map(b => <BrandBadge key={b} brand={b} />)}
</div>
)}
</div>
<button
onClick={() => openEdit(selectedMember)}
className="px-3 py-1.5 text-sm font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg"
>
{t('common.edit')}
</button>
</div>
</div>
{/* Workload stats */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
</div>
</div>
{/* Tasks & Posts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks */}
<div className="bg-white rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
</div>
<div className="divide-y divide-border-light max-h-[400px] overflow-y-auto">
{loadingDetail ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('common.loading')}</div>
) : memberTasks.length === 0 ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('team.noTasks')}</div>
) : (
memberTasks.map(task => (
<div key={task._id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</p>
</div>
<StatusBadge status={task.status} size="xs" />
</div>
))
)}
</div>
</div>
{/* Posts */}
<div className="bg-white rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
</div>
<div className="divide-y divide-border-light max-h-[400px] overflow-y-auto">
{loadingDetail ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('common.loading')}</div>
) : memberPosts.length === 0 ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('posts.noPosts')}</div>
) : (
memberPosts.map(post => (
<div key={post._id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
{post.brand && <BrandBadge brand={post.brand} />}
</div>
<StatusBadge status={post.status} size="xs" />
</div>
))
)}
</div>
</div>
</div>
</div>
)
}
// Team grid
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex items-center justify-between">
<p className="text-sm text-text-secondary">
{teamMembers.length} {teamMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p>
<div className="flex gap-2">
{/* Edit own profile button */}
<button
onClick={() => {
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
if (self) openEdit(self)
}}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
<UserIcon className="w-4 h-4" />
{t('team.myProfile')}
</button>
{/* Add member button (managers and superadmins only) */}
{canManageTeam && (
<button
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
>
<Plus className="w-4 h-4" />
{t('team.addMember')}
</button>
)}
</div>
</div>
{/* Member grid */}
{teamMembers.length === 0 ? (
<div className="py-20 text-center">
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 stagger-children">
{teamMembers.map(member => (
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
))}
</div>
)}
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
title={isEditingSelf ? t('team.editProfile') : (editingMember ? t('team.editMember') : t('team.newMember'))}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.name')} *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('team.fullName')}
/>
</div>
{!isEditingSelf && (
<>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.email')} *</label>
<input
type="email"
value={formData.email}
onChange={e => setFormData(f => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="email@example.com"
disabled={editingMember}
/>
</div>
{!editingMember && (
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.password')} {editingMember && t('team.optional')}</label>
<input
type="password"
value={formData.password}
onChange={e => setFormData(f => ({ ...f, password: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
/>
{!formData.password && !editingMember && (
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
)}
</div>
)}
</>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
{user?.role === 'manager' && !editingMember && !isEditingSelf ? (
<>
<input
type="text"
value="Contributor"
disabled
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
</>
) : (
<select
value={formData.role}
onChange={e => setFormData(f => ({ ...f, role: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')}</label>
<input
type="text"
value={formData.phone}
onChange={e => setFormData(f => ({ ...f, phone: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="+966 ..."
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
<input
type="text"
value={formData.brands}
onChange={e => setFormData(f => ({ ...f, brands: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Samaya Investment, Hira Cultural District"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.brandsHelp')}</p>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingMember && !isEditingSelf && canManageTeam && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
{t('team.remove')}
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={!formData.name || (!isEditingSelf && !editingMember && !formData.email)}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{isEditingSelf ? t('team.saveProfile') : (editingMember ? t('team.saveChanges') : t('team.addMember'))}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('team.removeMember')}
isConfirm
danger
confirmText={t('team.remove')}
onConfirm={async () => {
if (editingMember) {
await api.delete(`/users/team/${editingMember._id}`)
setShowModal(false)
setEditingMember(null)
setIsEditingSelf(false)
setShowDeleteConfirm(false)
if (selectedMember?._id === editingMember._id) {
setSelectedMember(null)
}
loadTeam()
}
}}
>
{t('team.removeConfirm', { name: editingMember?.name })}
</Modal>
</div>
)
}

315
client/src/pages/Users.jsx Normal file
View File

@@ -0,0 +1,315 @@
import { useState, useEffect } from 'react'
import { Plus, Shield, Edit2, Trash2, UserCheck } from 'lucide-react'
import { api } from '../utils/api'
import Modal from '../components/Modal'
import { useAuth } from '../contexts/AuthContext'
const ROLES = [
{ value: 'superadmin', label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
{ value: 'manager', label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
{ value: 'contributor', label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
]
const EMPTY_FORM = {
name: '', email: '', password: '', role: 'contributor', avatar: '',
}
function RoleBadge({ role }) {
const roleInfo = ROLES.find(r => r.value === role) || ROLES[2]
return (
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${roleInfo.color}`}>
<span>{roleInfo.icon}</span>
{roleInfo.label}
</span>
)
}
export default function Users() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [form, setForm] = useState(EMPTY_FORM)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [userToDelete, setUserToDelete] = useState(null)
useEffect(() => { loadUsers() }, [])
const loadUsers = async () => {
try {
const res = await api.get('/users')
setUsers(res)
} catch (err) {
console.error('Failed to load users:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
const data = {
name: form.name,
email: form.email,
role: form.role,
avatar: form.avatar || null,
}
if (form.password) data.password = form.password
if (editingUser) {
await api.patch(`/users/${editingUser.id}`, data)
} else {
if (!form.password) {
alert('Password is required for new users')
return
}
data.password = form.password
await api.post('/users', data)
}
setShowModal(false)
setEditingUser(null)
setForm(EMPTY_FORM)
loadUsers()
} catch (err) {
console.error('Save failed:', err)
alert('Failed to save user: ' + err.message)
}
}
const openEdit = (user) => {
setEditingUser(user)
setForm({
name: user.name || '',
email: user.email || '',
password: '',
role: user.role || 'contributor',
avatar: user.avatar || '',
})
setShowModal(true)
}
const openNew = () => {
setEditingUser(null)
setForm(EMPTY_FORM)
setShowModal(true)
}
const confirmDelete = async () => {
if (!userToDelete) return
try {
await api.delete(`/users/${userToDelete.id}`)
loadUsers()
setUserToDelete(null)
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete user')
}
}
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
<div className="h-64 bg-surface-tertiary rounded-xl"></div>
</div>
)
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Shield className="w-7 h-7 text-purple-600" />
User Management
</h1>
<p className="text-sm text-text-tertiary mt-1">{users.length} user{users.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
>
<Plus className="w-4 h-4" />
Add User
</button>
</div>
{/* Users List */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">User</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Email</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Role</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Created</th>
<th className="text-right px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{users.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-sm text-text-tertiary">
No users found
</td>
</tr>
) : (
users.map(user => {
const isCurrentUser = currentUser?.id === user.id
const roleInfo = ROLES.find(r => r.value === user.role) || ROLES[2]
return (
<tr key={user.id} className="hover:bg-surface-secondary group">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${user.role === 'superadmin' ? 'from-purple-500 to-pink-500' : 'from-blue-500 to-indigo-500'} flex items-center justify-center text-white font-bold text-sm`}>
{user.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{user.name}</p>
{isCurrentUser && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
You
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4 text-sm text-text-secondary">{user.email}</td>
<td className="px-5 py-4">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-4 text-sm text-text-tertiary">
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
</td>
<td className="px-5 py-4">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => openEdit(user)}
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
title="Edit user"
>
<Edit2 className="w-4 h-4" />
</button>
{!isCurrentUser && (
<button
onClick={() => { setUserToDelete(user); setShowDeleteConfirm(true) }}
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Add/Edit User Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingUser(null) }}
title={editingUser ? 'Edit User' : 'Add New User'}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="Full name"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Email *</label>
<input
type="email"
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="user@samayainvest.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Password {editingUser && '(leave blank to keep current)'}
</label>
<input
type="password"
value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="••••••••"
required={!editingUser}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Role *</label>
<div className="grid grid-cols-3 gap-2">
{ROLES.map(r => (
<button
key={r.value}
type="button"
onClick={() => setForm(f => ({ ...f, role: r.value }))}
className={`p-3 rounded-lg border-2 text-center transition-all ${
form.role === r.value
? 'border-brand-primary bg-brand-primary/5'
: 'border-border hover:border-brand-primary/30'
}`}
>
<div className="text-2xl mb-1">{r.icon}</div>
<div className="text-xs font-medium text-text-primary">{r.label}</div>
</button>
))}
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => { setShowModal(false); setEditingUser(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!form.name || !form.email || (!editingUser && !form.password)}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{editingUser ? 'Save Changes' : 'Add User'}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
title="Delete User?"
isConfirm
danger
confirmText="Delete User"
onConfirm={confirmDelete}
>
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This action cannot be undone.
</Modal>
</div>
)
}

125
client/src/utils/api.js Normal file
View File

@@ -0,0 +1,125 @@
const API = '/api';
// Map SQLite fields to frontend-friendly format
const toCamel = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
const normalize = (data) => {
if (Array.isArray(data)) return data.map(normalize);
if (data && typeof data === 'object' && !Array.isArray(data)) {
const out = {};
for (const [k, v] of Object.entries(data)) {
const camelKey = toCamel(k);
out[camelKey] = v;
if (camelKey !== k) out[k] = v;
}
// Add _id alias
if (out.id !== undefined && out._id === undefined) out._id = out.id;
// Map brand_name → brand (frontend expects post.brand as string)
if (out.brandName && !out.brand) out.brand = out.brandName;
// Map assigned_name for display
if (out.assignedName && !out.assignedToName) out.assignedToName = out.assignedName;
return out;
}
return data;
};
const handleResponse = async (r, label) => {
if (!r.ok) {
if (r.status === 401 || r.status === 403) {
// Unauthorized - redirect to login if not already there
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
throw new Error(`${label} failed: ${r.status}`);
}
const json = await r.json();
return normalize(json);
};
export const api = {
get: (path) => fetch(`${API}${path}`, {
credentials: 'include'
}).then(r => handleResponse(r, `GET ${path}`)),
post: (path, data) => fetch(`${API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
}).then(r => handleResponse(r, `POST ${path}`)),
patch: (path, data) => fetch(`${API}${path}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
}).then(r => handleResponse(r, `PATCH ${path}`)),
delete: (path) => fetch(`${API}${path}`, {
method: 'DELETE',
credentials: 'include',
}).then(r => handleResponse(r, `DELETE ${path}`)),
upload: (path, formData) => fetch(`${API}${path}`, {
method: 'POST',
credentials: 'include',
body: formData,
}).then(r => handleResponse(r, `UPLOAD ${path}`)),
};
// Brand colors map — matches Samaya brands from backend
export const BRAND_COLORS = {
'Samaya Investment': { bg: 'bg-indigo-100', text: 'text-indigo-700', dot: 'bg-indigo-500' },
'Hira Cultural District': { bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
'Holy Quran Museum': { bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
'Al-Safiya Museum': { bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
'Hayhala': { bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
'Jabal Thawr': { bg: 'bg-stone-100', text: 'text-stone-700', dot: 'bg-stone-500' },
'Coffee Chain': { bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
'Taibah Gifts': { bg: 'bg-pink-100', text: 'text-pink-700', dot: 'bg-pink-500' },
'Google Maps': { bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
'default': { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500' },
};
export const getBrandColor = (brand) => BRAND_COLORS[brand] || BRAND_COLORS['default'];
// Platform icons helper — svg paths for inline icons
export const PLATFORMS = {
instagram: { label: 'Instagram', color: '#E4405F', icon: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z' },
twitter: { label: 'X', color: '#000000', icon: 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z' },
facebook: { label: 'Facebook', color: '#1877F2', icon: 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z' },
linkedin: { label: 'LinkedIn', color: '#0A66C2', icon: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z' },
tiktok: { label: 'TikTok', color: '#000000', icon: 'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z' },
youtube: { label: 'YouTube', color: '#FF0000', icon: 'M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z' },
snapchat: { label: 'Snapchat', color: '#FFFC00', icon: 'M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738a.36.36 0 01.083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.631-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12.017 24c6.624 0 11.99-5.367 11.99-11.988C24.007 5.367 18.641 0 12.017 0z' },
google_ads: { label: 'Google Ads', color: '#4285F4', icon: 'M12 0C5.372 0 0 5.373 0 12s5.372 12 12 12 12-5.373 12-12S18.628 0 12 0zm5.82 16.32l-2.16 1.25c-.37.21-.84.09-1.05-.28l-5.82-10.08c-.21-.37-.09-.84.28-1.05l2.16-1.25c.37-.21.84-.09 1.05.28l5.82 10.08c.21.37.09.84-.28 1.05z' },
};
// Status config
export const STATUS_CONFIG = {
draft: { label: 'Draft', bg: 'bg-gray-100', text: 'text-gray-600', dot: 'bg-gray-400' },
in_review: { label: 'In Review', bg: 'bg-amber-50', text: 'text-amber-700', dot: 'bg-amber-400' },
approved: { label: 'Approved', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
scheduled: { label: 'Scheduled', bg: 'bg-purple-50', text: 'text-purple-700', dot: 'bg-purple-400' },
published: { label: 'Published', bg: 'bg-emerald-50', text: 'text-emerald-700', dot: 'bg-emerald-400' },
rejected: { label: 'Rejected', bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
todo: { label: 'To Do', bg: 'bg-gray-100', text: 'text-gray-600', dot: 'bg-gray-400' },
in_progress: { label: 'In Progress', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
done: { label: 'Done', bg: 'bg-emerald-50', text: 'text-emerald-700', dot: 'bg-emerald-400' },
active: { label: 'Active', bg: 'bg-emerald-50', text: 'text-emerald-700', dot: 'bg-emerald-400' },
paused: { label: 'Paused', bg: 'bg-amber-50', text: 'text-amber-700', dot: 'bg-amber-400' },
completed: { label: 'Completed', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
cancelled: { label: 'Cancelled', bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
planning: { label: 'Planning', bg: 'bg-gray-100', text: 'text-gray-600', dot: 'bg-gray-400' },
};
export const getStatusConfig = (status) => STATUS_CONFIG[status] || STATUS_CONFIG['draft'];
// Priority config
export const PRIORITY_CONFIG = {
low: { label: 'Low', color: 'bg-gray-400' },
medium: { label: 'Medium', color: 'bg-amber-400' },
high: { label: 'High', color: 'bg-orange-500' },
urgent: { label: 'Urgent', color: 'bg-red-500' },
};

9
client/update-inputs.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Script to add dir="auto" to all input and textarea elements
# This enables automatic RTL/LTR detection for mixed content
find ./src -name "*.jsx" -type f -exec sed -i 's/<input\([^>]*\)\(type="text"\|type="email"\|type="url"\|type="search"\)\([^>]*\)>/<input\1\2\3 dir="auto">/g' {} \;
find ./src -name "*.jsx" -type f -exec sed -i 's/<textarea\([^>]*\)>/<textarea\1 dir="auto">/g' {} \;
echo "Updated all text inputs and textareas with dir='auto'"

16
client/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})

410
server/db.js Normal file
View File

@@ -0,0 +1,410 @@
const Database = require('better-sqlite3');
const path = require('path');
const bcrypt = require('bcrypt');
const DB_PATH = path.join(__dirname, 'marketing.db');
const db = new Database(DB_PATH);
// Enable WAL mode and foreign keys
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
function initialize() {
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
role TEXT,
avatar_url TEXT,
brands TEXT DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS brands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
priority INTEGER DEFAULT 2,
color TEXT,
icon TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
brand_id INTEGER REFERENCES brands(id),
assigned_to INTEGER REFERENCES team_members(id),
status TEXT DEFAULT 'draft',
platform TEXT,
content_type TEXT,
scheduled_date DATETIME,
published_date DATETIME,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
original_name TEXT,
mime_type TEXT,
size INTEGER,
tags TEXT DEFAULT '[]',
brand_id INTEGER REFERENCES brands(id),
campaign_id INTEGER REFERENCES campaigns(id),
uploaded_by INTEGER REFERENCES team_members(id),
folder TEXT DEFAULT 'general',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
brand_id INTEGER REFERENCES brands(id),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status TEXT DEFAULT 'planning',
color TEXT,
budget REAL,
goals TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
brand_id INTEGER REFERENCES brands(id),
owner_id INTEGER REFERENCES team_members(id),
status TEXT DEFAULT 'active',
priority TEXT DEFAULT 'medium',
due_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
project_id INTEGER REFERENCES projects(id),
assigned_to INTEGER REFERENCES team_members(id),
created_by INTEGER REFERENCES team_members(id),
status TEXT DEFAULT 'todo',
priority TEXT DEFAULT 'medium',
due_date DATE,
is_personal BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);
`);
// Budget entries table — tracks money received
db.exec(`
CREATE TABLE IF NOT EXISTS budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
amount REAL NOT NULL,
source TEXT,
campaign_id INTEGER REFERENCES campaigns(id),
category TEXT DEFAULT 'marketing',
date_received DATE NOT NULL,
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Users table for authentication
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'contributor',
avatar TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Campaign tracks table
db.exec(`
CREATE TABLE IF NOT EXISTS campaign_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
name TEXT,
type TEXT NOT NULL DEFAULT 'organic_social',
platform TEXT,
budget_allocated REAL DEFAULT 0,
budget_spent REAL DEFAULT 0,
revenue REAL DEFAULT 0,
impressions INTEGER DEFAULT 0,
clicks INTEGER DEFAULT 0,
conversions INTEGER DEFAULT 0,
notes TEXT DEFAULT '',
status TEXT DEFAULT 'planned',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// ─── Ownership columns (link to users table) ───
const addOwnership = (table, column) => {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} INTEGER REFERENCES users(id)`);
console.log(`✅ Added ${column} column to ${table}`);
}
};
addOwnership('posts', 'created_by_user_id');
addOwnership('tasks', 'created_by_user_id');
addOwnership('campaigns', 'created_by_user_id');
addOwnership('projects', 'created_by_user_id');
// Add phone column to team_members if missing
const teamMemberCols = db.prepare("PRAGMA table_info(team_members)").all().map(c => c.name);
if (!teamMemberCols.includes('phone')) {
db.exec("ALTER TABLE team_members ADD COLUMN phone TEXT");
console.log('✅ Added phone column to team_members');
}
// Migrations — add columns if they don't exist
const campaignCols = db.prepare("PRAGMA table_info(campaigns)").all().map(c => c.name);
if (!campaignCols.includes('platforms')) {
db.exec("ALTER TABLE campaigns ADD COLUMN platforms TEXT DEFAULT '[]'");
console.log('✅ Added platforms column to campaigns');
}
// Campaign performance tracking columns
if (!campaignCols.includes('budget_spent')) {
db.exec("ALTER TABLE campaigns ADD COLUMN budget_spent REAL DEFAULT 0");
console.log('✅ Added budget_spent column to campaigns');
}
if (!campaignCols.includes('revenue')) {
db.exec("ALTER TABLE campaigns ADD COLUMN revenue REAL DEFAULT 0");
console.log('✅ Added revenue column to campaigns');
}
if (!campaignCols.includes('impressions')) {
db.exec("ALTER TABLE campaigns ADD COLUMN impressions INTEGER DEFAULT 0");
console.log('✅ Added impressions column to campaigns');
}
if (!campaignCols.includes('clicks')) {
db.exec("ALTER TABLE campaigns ADD COLUMN clicks INTEGER DEFAULT 0");
console.log('✅ Added clicks column to campaigns');
}
if (!campaignCols.includes('conversions')) {
db.exec("ALTER TABLE campaigns ADD COLUMN conversions INTEGER DEFAULT 0");
console.log('✅ Added conversions column to campaigns');
}
if (!campaignCols.includes('cost_per_click')) {
db.exec("ALTER TABLE campaigns ADD COLUMN cost_per_click REAL DEFAULT 0");
console.log('✅ Added cost_per_click column to campaigns');
}
if (!campaignCols.includes('notes')) {
db.exec("ALTER TABLE campaigns ADD COLUMN notes TEXT DEFAULT ''");
console.log('✅ Added notes column to campaigns');
}
// Add track_id to posts
const postCols = db.prepare("PRAGMA table_info(posts)").all().map(c => c.name);
if (!postCols.includes('track_id')) {
db.exec("ALTER TABLE posts ADD COLUMN track_id INTEGER REFERENCES campaign_tracks(id)");
console.log('✅ Added track_id column to posts');
}
if (!postCols.includes('campaign_id')) {
db.exec("ALTER TABLE posts ADD COLUMN campaign_id INTEGER REFERENCES campaigns(id)");
console.log('✅ Added campaign_id column to posts');
}
if (!postCols.includes('platforms')) {
// Add platforms column, migrate existing platform values
db.exec("ALTER TABLE posts ADD COLUMN platforms TEXT DEFAULT '[]'");
// Migrate: copy single platform value into platforms JSON array
const rows = db.prepare("SELECT id, platform FROM posts WHERE platform IS NOT NULL AND platform != ''").all();
const migrate = db.prepare("UPDATE posts SET platforms = ? WHERE id = ?");
for (const row of rows) {
migrate.run(JSON.stringify([row.platform]), row.id);
}
console.log(`✅ Added platforms column to posts, migrated ${rows.length} rows`);
}
// Add campaign_id to assets
const assetCols = db.prepare("PRAGMA table_info(assets)").all().map(c => c.name);
if (!assetCols.includes('campaign_id')) {
db.exec("ALTER TABLE assets ADD COLUMN campaign_id INTEGER REFERENCES campaigns(id)");
console.log('✅ Added campaign_id column to assets');
}
// ─── Link users to team_members ───
const userCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
if (!userCols.includes('team_member_id')) {
db.exec("ALTER TABLE users ADD COLUMN team_member_id INTEGER REFERENCES team_members(id)");
console.log('✅ Added team_member_id column to users');
}
// ─── Post attachments table ───
db.exec(`
CREATE TABLE IF NOT EXISTS post_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT,
mime_type TEXT,
size INTEGER,
url TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// ─── Publication links column on posts ───
if (!postCols.includes('publication_links')) {
db.exec("ALTER TABLE posts ADD COLUMN publication_links TEXT DEFAULT '[]'");
console.log('✅ Added publication_links column to posts');
}
// ─── Merge team_members into users ───
if (!userCols.includes('team_role')) {
db.exec("ALTER TABLE users ADD COLUMN team_role TEXT");
console.log('✅ Added team_role column to users');
}
if (!userCols.includes('brands')) {
db.exec("ALTER TABLE users ADD COLUMN brands TEXT DEFAULT '[]'");
console.log('✅ Added brands column to users');
}
if (!userCols.includes('phone')) {
db.exec("ALTER TABLE users ADD COLUMN phone TEXT");
console.log('✅ Added phone column to users');
}
if (!userCols.includes('tutorial_completed')) {
db.exec("ALTER TABLE users ADD COLUMN tutorial_completed INTEGER DEFAULT 0");
console.log('✅ Added tutorial_completed column to users');
}
// Migrate team_members to users (one-time migration)
const teamMembers = db.prepare('SELECT * FROM team_members').all();
const defaultPasswordHash = bcrypt.hashSync('changeme123', 10);
for (const tm of teamMembers) {
// Skip team_member id=9 (Fahed) - he's already user id=1
if (tm.id === 9) {
// Just update his team_role and brands
db.prepare('UPDATE users SET team_role = ?, brands = ?, team_member_id = ? WHERE id = 1')
.run(tm.role, tm.brands, tm.id);
continue;
}
// Check if user already exists with this team_member_id
const existingUser = db.prepare('SELECT id FROM users WHERE team_member_id = ?').get(tm.id);
if (existingUser) {
// User exists, just update team_role and brands
db.prepare('UPDATE users SET team_role = ?, brands = ?, phone = ? WHERE id = ?')
.run(tm.role, tm.brands, tm.phone || null, existingUser.id);
} else {
// Create new user for this team member
db.prepare(`
INSERT INTO users (name, email, password_hash, role, team_role, brands, phone, team_member_id)
VALUES (?, ?, ?, 'contributor', ?, ?, ?, ?)
`).run(
tm.name,
tm.email,
defaultPasswordHash,
tm.role,
tm.brands,
tm.phone || null,
tm.id
);
console.log(`✅ Created user account for team member: ${tm.name}`);
}
}
// Seed data only if tables are empty
const memberCount = db.prepare('SELECT COUNT(*) as count FROM team_members').get().count;
if (memberCount === 0) {
seedData();
}
// Seed default superadmin if no users exist
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
if (userCount === 0) {
seedDefaultUser();
}
}
function seedData() {
const allBrands = JSON.stringify([
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum',
'Al-Safiya Museum', 'Hayhala', 'Jabal Thawr', 'Coffee Chain', 'Taibah Gifts'
]);
const someBrands = JSON.stringify([
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum', 'Al-Safiya Museum'
]);
const mostAccounts = JSON.stringify([
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum',
'Al-Safiya Museum', 'Hayhala', 'Jabal Thawr', 'Coffee Chain'
]);
const religiousExhibitions = JSON.stringify([
'Holy Quran Museum', 'Al-Safiya Museum', 'Jabal Thawr'
]);
const insertMember = db.prepare(`
INSERT INTO team_members (name, email, role, brands) VALUES (?, ?, ?, ?)
`);
const members = [
['Dr. Muhammad Al-Sayed', 'muhammad.alsayed@samaya.sa', 'approver', allBrands],
['Dr. Fahd Al-Thumairi', 'fahd.thumairi@samaya.sa', 'approver', someBrands],
['Fahda Abdul Aziz', 'fahda@samaya.sa', 'publisher', mostAccounts],
['Sara Al-Zahrani', 'sara@samaya.sa', 'content_creator', JSON.stringify(['Samaya Investment', 'Hira Cultural District', 'Coffee Chain'])],
['Noura', 'noura@samaya.sa', 'content_creator', JSON.stringify(['Samaya Investment', 'Hira Cultural District', 'Hayhala', 'Taibah Gifts'])],
['Saeed Ghanem', 'saeed@samaya.sa', 'content_creator', religiousExhibitions],
['Anas Mater', 'anas@samaya.sa', 'producer', JSON.stringify(['Samaya Investment', 'Hira Cultural District'])],
['Muhammad Nu\'man', 'numan@samaya.sa', 'manager', JSON.stringify(['Google Maps'])],
['Fahed', 'fahed@samaya.sa', 'manager', allBrands],
];
const insertMembers = db.transaction(() => {
for (const m of members) {
insertMember.run(...m);
}
});
insertMembers();
// Seed brands
const insertBrand = db.prepare(`
INSERT INTO brands (name, priority, color, icon) VALUES (?, ?, ?, ?)
`);
const brands = [
['Samaya Investment', 1, '#1E3A5F', '🏢'],
['Hira Cultural District', 1, '#8B4513', '🏛️'],
['Holy Quran Museum', 1, '#2E7D32', '📖'],
['Al-Safiya Museum', 1, '#6A1B9A', '🏺'],
['Hayhala', 1, '#C62828', '🎭'],
['Jabal Thawr', 1, '#4E342E', '⛰️'],
['Coffee Chain', 2, '#795548', '☕'],
['Taibah Gifts', 3, '#E65100', '🎁'],
];
const insertBrands = db.transaction(() => {
for (const b of brands) {
insertBrand.run(...b);
}
});
insertBrands();
console.log('✅ Database seeded with team members and brands');
}
function seedDefaultUser() {
const passwordHash = bcrypt.hashSync('admin123', 10);
const insertUser = db.prepare(`
INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)
`);
insertUser.run('Fahed Muhaidi', 'f.mahidi@samayainvest.com', passwordHash, 'superadmin');
console.log('✅ Default superadmin created (email: f.mahidi@samayainvest.com, password: admin123)');
}
module.exports = { db, initialize };

BIN
server/marketing.db Normal file

Binary file not shown.

BIN
server/marketing.db-shm Normal file

Binary file not shown.

BIN
server/marketing.db-wal Normal file

Binary file not shown.

1
server/node_modules/.bin/color-support generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../color-support/bin.js

1
server/node_modules/.bin/mime generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../mime/cli.js

1
server/node_modules/.bin/mkdirp generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../mkdirp/bin/cmd.js

1
server/node_modules/.bin/node-gyp generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../node-gyp/bin/node-gyp.js

1
server/node_modules/.bin/node-gyp-build generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../node-gyp-build/bin.js

1
server/node_modules/.bin/node-gyp-build-optional generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../node-gyp-build/optional.js

1
server/node_modules/.bin/node-gyp-build-test generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../node-gyp-build/build-test.js

1
server/node_modules/.bin/node-which generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../which/bin/node-which

1
server/node_modules/.bin/nopt generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../nopt/bin/nopt.js

1
server/node_modules/.bin/prebuild-install generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../prebuild-install/bin.js

1
server/node_modules/.bin/rc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../rc/cli.js

1
server/node_modules/.bin/rimraf generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../rimraf/bin.js

1
server/node_modules/.bin/semver generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../semver/bin/semver.js

2628
server/node_modules/.package-lock.json generated vendored Normal file

File diff suppressed because it is too large Load Diff

10
server/node_modules/@gar/promisify/LICENSE.md generated vendored Normal file
View File

@@ -0,0 +1,10 @@
The MIT License (MIT)
Copyright © 2020-2022 Michael Garvin
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

65
server/node_modules/@gar/promisify/README.md generated vendored Normal file
View File

@@ -0,0 +1,65 @@
# @gar/promisify
### Promisify an entire object or class instance
This module leverages es6 Proxy and Reflect to promisify every function in an
object or class instance.
It assumes the callback that the function is expecting is the last
parameter, and that it is an error-first callback with only one value,
i.e. `(err, value) => ...`. This mirrors node's `util.promisify` method.
In order that you can use it as a one-stop-shop for all your promisify
needs, you can also pass it a function. That function will be
promisified as normal using node's built-in `util.promisify` method.
[node's custom promisified
functions](https://nodejs.org/api/util.html#util_custom_promisified_functions)
will also be mirrored, further allowing this to be a drop-in replacement
for the built-in `util.promisify`.
### Examples
Promisify an entire object
```javascript
const promisify = require('@gar/promisify')
class Foo {
constructor (attr) {
this.attr = attr
}
double (input, cb) {
cb(null, input * 2)
}
const foo = new Foo('baz')
const promisified = promisify(foo)
console.log(promisified.attr)
console.log(await promisified.double(1024))
```
Promisify a function
```javascript
const promisify = require('@gar/promisify')
function foo (a, cb) {
if (a !== 'bad') {
return cb(null, 'ok')
}
return cb('not ok')
}
const promisified = promisify(foo)
// This will resolve to 'ok'
promisified('good')
// this will reject
promisified('bad')
```

36
server/node_modules/@gar/promisify/index.js generated vendored Normal file
View File

@@ -0,0 +1,36 @@
'use strict'
const { promisify } = require('util')
const handler = {
get: function (target, prop, receiver) {
if (typeof target[prop] !== 'function') {
return target[prop]
}
if (target[prop][promisify.custom]) {
return function () {
return Reflect.get(target, prop, receiver)[promisify.custom].apply(target, arguments)
}
}
return function () {
return new Promise((resolve, reject) => {
Reflect.get(target, prop, receiver).apply(target, [...arguments, function (err, result) {
if (err) {
return reject(err)
}
resolve(result)
}])
})
}
}
}
module.exports = function (thingToPromisify) {
if (typeof thingToPromisify === 'function') {
return promisify(thingToPromisify)
}
if (typeof thingToPromisify === 'object') {
return new Proxy(thingToPromisify, handler)
}
throw new TypeError('Can only promisify functions or objects')
}

32
server/node_modules/@gar/promisify/package.json generated vendored Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@gar/promisify",
"version": "1.1.3",
"description": "Promisify an entire class or object",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://github.com/wraithgar/gar-promisify.git"
},
"scripts": {
"lint": "standard",
"lint:fix": "standard --fix",
"test": "lab -a @hapi/code -t 100",
"posttest": "npm run lint"
},
"files": [
"index.js"
],
"keywords": [
"promisify",
"all",
"class",
"object"
],
"author": "Gar <gar+npm@danger.computer>",
"license": "MIT",
"devDependencies": {
"@hapi/code": "^8.0.1",
"@hapi/lab": "^24.1.0",
"standard": "^16.0.3"
}
}

20
server/node_modules/@npmcli/fs/LICENSE.md generated vendored Normal file
View File

@@ -0,0 +1,20 @@
<!-- This file is automatically added by @npmcli/template-oss. Do not edit. -->
ISC License
Copyright npm, Inc.
Permission to use, copy, modify, and/or distribute this
software for any purpose with or without fee is hereby
granted, provided that the above copyright notice and this
permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND NPM DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO
EVENT SHALL NPM BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE
USE OR PERFORMANCE OF THIS SOFTWARE.

60
server/node_modules/@npmcli/fs/README.md generated vendored Normal file
View File

@@ -0,0 +1,60 @@
# @npmcli/fs
polyfills, and extensions, of the core `fs` module.
## Features
- all exposed functions return promises
- `fs.rm` polyfill for node versions < 14.14.0
- `fs.mkdir` polyfill adding support for the `recursive` and `force` options in node versions < 10.12.0
- `fs.copyFile` extended to accept an `owner` option
- `fs.mkdir` extended to accept an `owner` option
- `fs.mkdtemp` extended to accept an `owner` option
- `fs.writeFile` extended to accept an `owner` option
- `fs.withTempDir` added
- `fs.cp` polyfill for node < 16.7.0
## The `owner` option
The `copyFile`, `mkdir`, `mkdtemp`, `writeFile`, and `withTempDir` functions
all accept a new `owner` property in their options. It can be used in two ways:
- `{ owner: { uid: 100, gid: 100 } }` - set the `uid` and `gid` explicitly
- `{ owner: 100 }` - use one value, will set both `uid` and `gid` the same
The special string `'inherit'` may be passed instead of a number, which will
cause this module to automatically determine the correct `uid` and/or `gid`
from the nearest existing parent directory of the target.
## `fs.withTempDir(root, fn, options) -> Promise`
### Parameters
- `root`: the directory in which to create the temporary directory
- `fn`: a function that will be called with the path to the temporary directory
- `options`
- `tmpPrefix`: a prefix to be used in the generated directory name
### Usage
The `withTempDir` function creates a temporary directory, runs the provided
function (`fn`), then removes the temporary directory and resolves or rejects
based on the result of `fn`.
```js
const fs = require('@npmcli/fs')
const os = require('os')
// this function will be called with the full path to the temporary directory
// it is called with `await` behind the scenes, so can be async if desired.
const myFunction = async (tempPath) => {
return 'done!'
}
const main = async () => {
const result = await fs.withTempDir(os.tmpdir(), myFunction)
// result === 'done!'
}
main()
```

View File

@@ -0,0 +1,17 @@
const url = require('url')
const node = require('../node.js')
const polyfill = require('./polyfill.js')
const useNative = node.satisfies('>=10.12.0')
const fileURLToPath = (path) => {
// the polyfill is tested separately from this module, no need to hack
// process.version to try to trigger it just for coverage
// istanbul ignore next
return useNative
? url.fileURLToPath(path)
: polyfill(path)
}
module.exports = fileURLToPath

View File

@@ -0,0 +1,121 @@
const { URL, domainToUnicode } = require('url')
const CHAR_LOWERCASE_A = 97
const CHAR_LOWERCASE_Z = 122
const isWindows = process.platform === 'win32'
class ERR_INVALID_FILE_URL_HOST extends TypeError {
constructor (platform) {
super(`File URL host must be "localhost" or empty on ${platform}`)
this.code = 'ERR_INVALID_FILE_URL_HOST'
}
toString () {
return `${this.name} [${this.code}]: ${this.message}`
}
}
class ERR_INVALID_FILE_URL_PATH extends TypeError {
constructor (msg) {
super(`File URL path ${msg}`)
this.code = 'ERR_INVALID_FILE_URL_PATH'
}
toString () {
return `${this.name} [${this.code}]: ${this.message}`
}
}
class ERR_INVALID_ARG_TYPE extends TypeError {
constructor (name, actual) {
super(`The "${name}" argument must be one of type string or an instance ` +
`of URL. Received type ${typeof actual} ${actual}`)
this.code = 'ERR_INVALID_ARG_TYPE'
}
toString () {
return `${this.name} [${this.code}]: ${this.message}`
}
}
class ERR_INVALID_URL_SCHEME extends TypeError {
constructor (expected) {
super(`The URL must be of scheme ${expected}`)
this.code = 'ERR_INVALID_URL_SCHEME'
}
toString () {
return `${this.name} [${this.code}]: ${this.message}`
}
}
const isURLInstance = (input) => {
return input != null && input.href && input.origin
}
const getPathFromURLWin32 = (url) => {
const hostname = url.hostname
let pathname = url.pathname
for (let n = 0; n < pathname.length; n++) {
if (pathname[n] === '%') {
const third = pathname.codePointAt(n + 2) | 0x20
if ((pathname[n + 1] === '2' && third === 102) ||
(pathname[n + 1] === '5' && third === 99)) {
throw new ERR_INVALID_FILE_URL_PATH('must not include encoded \\ or / characters')
}
}
}
pathname = pathname.replace(/\//g, '\\')
pathname = decodeURIComponent(pathname)
if (hostname !== '') {
return `\\\\${domainToUnicode(hostname)}${pathname}`
}
const letter = pathname.codePointAt(1) | 0x20
const sep = pathname[2]
if (letter < CHAR_LOWERCASE_A || letter > CHAR_LOWERCASE_Z ||
(sep !== ':')) {
throw new ERR_INVALID_FILE_URL_PATH('must be absolute')
}
return pathname.slice(1)
}
const getPathFromURLPosix = (url) => {
if (url.hostname !== '') {
throw new ERR_INVALID_FILE_URL_HOST(process.platform)
}
const pathname = url.pathname
for (let n = 0; n < pathname.length; n++) {
if (pathname[n] === '%') {
const third = pathname.codePointAt(n + 2) | 0x20
if (pathname[n + 1] === '2' && third === 102) {
throw new ERR_INVALID_FILE_URL_PATH('must not include encoded / characters')
}
}
}
return decodeURIComponent(pathname)
}
const fileURLToPath = (path) => {
if (typeof path === 'string') {
path = new URL(path)
} else if (!isURLInstance(path)) {
throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path)
}
if (path.protocol !== 'file:') {
throw new ERR_INVALID_URL_SCHEME('file')
}
return isWindows
? getPathFromURLWin32(path)
: getPathFromURLPosix(path)
}
module.exports = fileURLToPath

View File

@@ -0,0 +1,20 @@
// given an input that may or may not be an object, return an object that has
// a copy of every defined property listed in 'copy'. if the input is not an
// object, assign it to the property named by 'wrap'
const getOptions = (input, { copy, wrap }) => {
const result = {}
if (input && typeof input === 'object') {
for (const prop of copy) {
if (input[prop] !== undefined) {
result[prop] = input[prop]
}
}
} else {
result[wrap] = input
}
return result
}
module.exports = getOptions

9
server/node_modules/@npmcli/fs/lib/common/node.js generated vendored Normal file
View File

@@ -0,0 +1,9 @@
const semver = require('semver')
const satisfies = (range) => {
return semver.satisfies(process.version, range, { includePrerelease: true })
}
module.exports = {
satisfies,
}

92
server/node_modules/@npmcli/fs/lib/common/owner.js generated vendored Normal file
View File

@@ -0,0 +1,92 @@
const { dirname, resolve } = require('path')
const fileURLToPath = require('./file-url-to-path/index.js')
const fs = require('../fs.js')
// given a path, find the owner of the nearest parent
const find = async (path) => {
// if we have no getuid, permissions are irrelevant on this platform
if (!process.getuid) {
return {}
}
// fs methods accept URL objects with a scheme of file: so we need to unwrap
// those into an actual path string before we can resolve it
const resolved = path != null && path.href && path.origin
? resolve(fileURLToPath(path))
: resolve(path)
let stat
try {
stat = await fs.lstat(resolved)
} finally {
// if we got a stat, return its contents
if (stat) {
return { uid: stat.uid, gid: stat.gid }
}
// try the parent directory
if (resolved !== dirname(resolved)) {
return find(dirname(resolved))
}
// no more parents, never got a stat, just return an empty object
return {}
}
}
// given a path, uid, and gid update the ownership of the path if necessary
const update = async (path, uid, gid) => {
// nothing to update, just exit
if (uid === undefined && gid === undefined) {
return
}
try {
// see if the permissions are already the same, if they are we don't
// need to do anything, so return early
const stat = await fs.stat(path)
if (uid === stat.uid && gid === stat.gid) {
return
}
} catch (err) {}
try {
await fs.chown(path, uid, gid)
} catch (err) {}
}
// accepts a `path` and the `owner` property of an options object and normalizes
// it into an object with numerical `uid` and `gid`
const validate = async (path, input) => {
let uid
let gid
if (typeof input === 'string' || typeof input === 'number') {
uid = input
gid = input
} else if (input && typeof input === 'object') {
uid = input.uid
gid = input.gid
}
if (uid === 'inherit' || gid === 'inherit') {
const owner = await find(path)
if (uid === 'inherit') {
uid = owner.uid
}
if (gid === 'inherit') {
gid = owner.gid
}
}
return { uid, gid }
}
module.exports = {
find,
update,
validate,
}

22
server/node_modules/@npmcli/fs/lib/copy-file.js generated vendored Normal file
View File

@@ -0,0 +1,22 @@
const fs = require('./fs.js')
const getOptions = require('./common/get-options.js')
const owner = require('./common/owner.js')
const copyFile = async (src, dest, opts) => {
const options = getOptions(opts, {
copy: ['mode', 'owner'],
wrap: 'mode',
})
const { uid, gid } = await owner.validate(dest, options.owner)
// the node core method as of 16.5.0 does not support the mode being in an
// object, so we have to pass the mode value directly
const result = await fs.copyFile(src, dest, options.mode)
await owner.update(dest, uid, gid)
return result
}
module.exports = copyFile

15
server/node_modules/@npmcli/fs/lib/cp/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,15 @@
(The MIT License)
Copyright (c) 2011-2017 JP Richardson
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

22
server/node_modules/@npmcli/fs/lib/cp/index.js generated vendored Normal file
View File

@@ -0,0 +1,22 @@
const fs = require('../fs.js')
const getOptions = require('../common/get-options.js')
const node = require('../common/node.js')
const polyfill = require('./polyfill.js')
// node 16.7.0 added fs.cp
const useNative = node.satisfies('>=16.7.0')
const cp = async (src, dest, opts) => {
const options = getOptions(opts, {
copy: ['dereference', 'errorOnExist', 'filter', 'force', 'preserveTimestamps', 'recursive'],
})
// the polyfill is tested separately from this module, no need to hack
// process.version to try to trigger it just for coverage
// istanbul ignore next
return useNative
? fs.cp(src, dest, options)
: polyfill(src, dest, options)
}
module.exports = cp

428
server/node_modules/@npmcli/fs/lib/cp/polyfill.js generated vendored Normal file
View File

@@ -0,0 +1,428 @@
// this file is a modified version of the code in node 17.2.0
// which is, in turn, a modified version of the fs-extra module on npm
// node core changes:
// - Use of the assert module has been replaced with core's error system.
// - All code related to the glob dependency has been removed.
// - Bring your own custom fs module is not currently supported.
// - Some basic code cleanup.
// changes here:
// - remove all callback related code
// - drop sync support
// - change assertions back to non-internal methods (see options.js)
// - throws ENOTDIR when rmdir gets an ENOENT for a path that exists in Windows
'use strict'
const {
ERR_FS_CP_DIR_TO_NON_DIR,
ERR_FS_CP_EEXIST,
ERR_FS_CP_EINVAL,
ERR_FS_CP_FIFO_PIPE,
ERR_FS_CP_NON_DIR_TO_DIR,
ERR_FS_CP_SOCKET,
ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY,
ERR_FS_CP_UNKNOWN,
ERR_FS_EISDIR,
ERR_INVALID_ARG_TYPE,
} = require('../errors.js')
const {
constants: {
errno: {
EEXIST,
EISDIR,
EINVAL,
ENOTDIR,
},
},
} = require('os')
const {
chmod,
copyFile,
lstat,
mkdir,
readdir,
readlink,
stat,
symlink,
unlink,
utimes,
} = require('../fs.js')
const {
dirname,
isAbsolute,
join,
parse,
resolve,
sep,
toNamespacedPath,
} = require('path')
const { fileURLToPath } = require('url')
const defaultOptions = {
dereference: false,
errorOnExist: false,
filter: undefined,
force: true,
preserveTimestamps: false,
recursive: false,
}
async function cp (src, dest, opts) {
if (opts != null && typeof opts !== 'object') {
throw new ERR_INVALID_ARG_TYPE('options', ['Object'], opts)
}
return cpFn(
toNamespacedPath(getValidatedPath(src)),
toNamespacedPath(getValidatedPath(dest)),
{ ...defaultOptions, ...opts })
}
function getValidatedPath (fileURLOrPath) {
const path = fileURLOrPath != null && fileURLOrPath.href
&& fileURLOrPath.origin
? fileURLToPath(fileURLOrPath)
: fileURLOrPath
return path
}
async function cpFn (src, dest, opts) {
// Warn about using preserveTimestamps on 32-bit node
// istanbul ignore next
if (opts.preserveTimestamps && process.arch === 'ia32') {
const warning = 'Using the preserveTimestamps option in 32-bit ' +
'node is not recommended'
process.emitWarning(warning, 'TimestampPrecisionWarning')
}
const stats = await checkPaths(src, dest, opts)
const { srcStat, destStat } = stats
await checkParentPaths(src, srcStat, dest)
if (opts.filter) {
return handleFilter(checkParentDir, destStat, src, dest, opts)
}
return checkParentDir(destStat, src, dest, opts)
}
async function checkPaths (src, dest, opts) {
const { 0: srcStat, 1: destStat } = await getStats(src, dest, opts)
if (destStat) {
if (areIdentical(srcStat, destStat)) {
throw new ERR_FS_CP_EINVAL({
message: 'src and dest cannot be the same',
path: dest,
syscall: 'cp',
errno: EINVAL,
})
}
if (srcStat.isDirectory() && !destStat.isDirectory()) {
throw new ERR_FS_CP_DIR_TO_NON_DIR({
message: `cannot overwrite directory ${src} ` +
`with non-directory ${dest}`,
path: dest,
syscall: 'cp',
errno: EISDIR,
})
}
if (!srcStat.isDirectory() && destStat.isDirectory()) {
throw new ERR_FS_CP_NON_DIR_TO_DIR({
message: `cannot overwrite non-directory ${src} ` +
`with directory ${dest}`,
path: dest,
syscall: 'cp',
errno: ENOTDIR,
})
}
}
if (srcStat.isDirectory() && isSrcSubdir(src, dest)) {
throw new ERR_FS_CP_EINVAL({
message: `cannot copy ${src} to a subdirectory of self ${dest}`,
path: dest,
syscall: 'cp',
errno: EINVAL,
})
}
return { srcStat, destStat }
}
function areIdentical (srcStat, destStat) {
return destStat.ino && destStat.dev && destStat.ino === srcStat.ino &&
destStat.dev === srcStat.dev
}
function getStats (src, dest, opts) {
const statFunc = opts.dereference ?
(file) => stat(file, { bigint: true }) :
(file) => lstat(file, { bigint: true })
return Promise.all([
statFunc(src),
statFunc(dest).catch((err) => {
// istanbul ignore next: unsure how to cover.
if (err.code === 'ENOENT') {
return null
}
// istanbul ignore next: unsure how to cover.
throw err
}),
])
}
async function checkParentDir (destStat, src, dest, opts) {
const destParent = dirname(dest)
const dirExists = await pathExists(destParent)
if (dirExists) {
return getStatsForCopy(destStat, src, dest, opts)
}
await mkdir(destParent, { recursive: true })
return getStatsForCopy(destStat, src, dest, opts)
}
function pathExists (dest) {
return stat(dest).then(
() => true,
// istanbul ignore next: not sure when this would occur
(err) => (err.code === 'ENOENT' ? false : Promise.reject(err)))
}
// Recursively check if dest parent is a subdirectory of src.
// It works for all file types including symlinks since it
// checks the src and dest inodes. It starts from the deepest
// parent and stops once it reaches the src parent or the root path.
async function checkParentPaths (src, srcStat, dest) {
const srcParent = resolve(dirname(src))
const destParent = resolve(dirname(dest))
if (destParent === srcParent || destParent === parse(destParent).root) {
return
}
let destStat
try {
destStat = await stat(destParent, { bigint: true })
} catch (err) {
// istanbul ignore else: not sure when this would occur
if (err.code === 'ENOENT') {
return
}
// istanbul ignore next: not sure when this would occur
throw err
}
if (areIdentical(srcStat, destStat)) {
throw new ERR_FS_CP_EINVAL({
message: `cannot copy ${src} to a subdirectory of self ${dest}`,
path: dest,
syscall: 'cp',
errno: EINVAL,
})
}
return checkParentPaths(src, srcStat, destParent)
}
const normalizePathToArray = (path) =>
resolve(path).split(sep).filter(Boolean)
// Return true if dest is a subdir of src, otherwise false.
// It only checks the path strings.
function isSrcSubdir (src, dest) {
const srcArr = normalizePathToArray(src)
const destArr = normalizePathToArray(dest)
return srcArr.every((cur, i) => destArr[i] === cur)
}
async function handleFilter (onInclude, destStat, src, dest, opts, cb) {
const include = await opts.filter(src, dest)
if (include) {
return onInclude(destStat, src, dest, opts, cb)
}
}
function startCopy (destStat, src, dest, opts) {
if (opts.filter) {
return handleFilter(getStatsForCopy, destStat, src, dest, opts)
}
return getStatsForCopy(destStat, src, dest, opts)
}
async function getStatsForCopy (destStat, src, dest, opts) {
const statFn = opts.dereference ? stat : lstat
const srcStat = await statFn(src)
// istanbul ignore else: can't portably test FIFO
if (srcStat.isDirectory() && opts.recursive) {
return onDir(srcStat, destStat, src, dest, opts)
} else if (srcStat.isDirectory()) {
throw new ERR_FS_EISDIR({
message: `${src} is a directory (not copied)`,
path: src,
syscall: 'cp',
errno: EINVAL,
})
} else if (srcStat.isFile() ||
srcStat.isCharacterDevice() ||
srcStat.isBlockDevice()) {
return onFile(srcStat, destStat, src, dest, opts)
} else if (srcStat.isSymbolicLink()) {
return onLink(destStat, src, dest)
} else if (srcStat.isSocket()) {
throw new ERR_FS_CP_SOCKET({
message: `cannot copy a socket file: ${dest}`,
path: dest,
syscall: 'cp',
errno: EINVAL,
})
} else if (srcStat.isFIFO()) {
throw new ERR_FS_CP_FIFO_PIPE({
message: `cannot copy a FIFO pipe: ${dest}`,
path: dest,
syscall: 'cp',
errno: EINVAL,
})
}
// istanbul ignore next: should be unreachable
throw new ERR_FS_CP_UNKNOWN({
message: `cannot copy an unknown file type: ${dest}`,
path: dest,
syscall: 'cp',
errno: EINVAL,
})
}
function onFile (srcStat, destStat, src, dest, opts) {
if (!destStat) {
return _copyFile(srcStat, src, dest, opts)
}
return mayCopyFile(srcStat, src, dest, opts)
}
async function mayCopyFile (srcStat, src, dest, opts) {
if (opts.force) {
await unlink(dest)
return _copyFile(srcStat, src, dest, opts)
} else if (opts.errorOnExist) {
throw new ERR_FS_CP_EEXIST({
message: `${dest} already exists`,
path: dest,
syscall: 'cp',
errno: EEXIST,
})
}
}
async function _copyFile (srcStat, src, dest, opts) {
await copyFile(src, dest)
if (opts.preserveTimestamps) {
return handleTimestampsAndMode(srcStat.mode, src, dest)
}
return setDestMode(dest, srcStat.mode)
}
async function handleTimestampsAndMode (srcMode, src, dest) {
// Make sure the file is writable before setting the timestamp
// otherwise open fails with EPERM when invoked with 'r+'
// (through utimes call)
if (fileIsNotWritable(srcMode)) {
await makeFileWritable(dest, srcMode)
return setDestTimestampsAndMode(srcMode, src, dest)
}
return setDestTimestampsAndMode(srcMode, src, dest)
}
function fileIsNotWritable (srcMode) {
return (srcMode & 0o200) === 0
}
function makeFileWritable (dest, srcMode) {
return setDestMode(dest, srcMode | 0o200)
}
async function setDestTimestampsAndMode (srcMode, src, dest) {
await setDestTimestamps(src, dest)
return setDestMode(dest, srcMode)
}
function setDestMode (dest, srcMode) {
return chmod(dest, srcMode)
}
async function setDestTimestamps (src, dest) {
// The initial srcStat.atime cannot be trusted
// because it is modified by the read(2) system call
// (See https://nodejs.org/api/fs.html#fs_stat_time_values)
const updatedSrcStat = await stat(src)
return utimes(dest, updatedSrcStat.atime, updatedSrcStat.mtime)
}
function onDir (srcStat, destStat, src, dest, opts) {
if (!destStat) {
return mkDirAndCopy(srcStat.mode, src, dest, opts)
}
return copyDir(src, dest, opts)
}
async function mkDirAndCopy (srcMode, src, dest, opts) {
await mkdir(dest)
await copyDir(src, dest, opts)
return setDestMode(dest, srcMode)
}
async function copyDir (src, dest, opts) {
const dir = await readdir(src)
for (let i = 0; i < dir.length; i++) {
const item = dir[i]
const srcItem = join(src, item)
const destItem = join(dest, item)
const { destStat } = await checkPaths(srcItem, destItem, opts)
await startCopy(destStat, srcItem, destItem, opts)
}
}
async function onLink (destStat, src, dest) {
let resolvedSrc = await readlink(src)
if (!isAbsolute(resolvedSrc)) {
resolvedSrc = resolve(dirname(src), resolvedSrc)
}
if (!destStat) {
return symlink(resolvedSrc, dest)
}
let resolvedDest
try {
resolvedDest = await readlink(dest)
} catch (err) {
// Dest exists and is a regular file or directory,
// Windows may throw UNKNOWN error. If dest already exists,
// fs throws error anyway, so no need to guard against it here.
// istanbul ignore next: can only test on windows
if (err.code === 'EINVAL' || err.code === 'UNKNOWN') {
return symlink(resolvedSrc, dest)
}
// istanbul ignore next: should not be possible
throw err
}
if (!isAbsolute(resolvedDest)) {
resolvedDest = resolve(dirname(dest), resolvedDest)
}
if (isSrcSubdir(resolvedSrc, resolvedDest)) {
throw new ERR_FS_CP_EINVAL({
message: `cannot copy ${resolvedSrc} to a subdirectory of self ` +
`${resolvedDest}`,
path: dest,
syscall: 'cp',
errno: EINVAL,
})
}
// Do not copy if src is a subdir of dest since unlinking
// dest in this case would result in removing src contents
// and therefore a broken symlink would be created.
const srcStat = await stat(src)
if (srcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) {
throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({
message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`,
path: dest,
syscall: 'cp',
errno: EINVAL,
})
}
return copyLink(resolvedSrc, dest)
}
async function copyLink (resolvedSrc, dest) {
await unlink(dest)
return symlink(resolvedSrc, dest)
}
module.exports = cp

129
server/node_modules/@npmcli/fs/lib/errors.js generated vendored Normal file
View File

@@ -0,0 +1,129 @@
'use strict'
const { inspect } = require('util')
// adapted from node's internal/errors
// https://github.com/nodejs/node/blob/c8a04049/lib/internal/errors.js
// close copy of node's internal SystemError class.
class SystemError {
constructor (code, prefix, context) {
// XXX context.code is undefined in all constructors used in cp/polyfill
// that may be a bug copied from node, maybe the constructor should use
// `code` not `errno`? nodejs/node#41104
let message = `${prefix}: ${context.syscall} returned ` +
`${context.code} (${context.message})`
if (context.path !== undefined) {
message += ` ${context.path}`
}
if (context.dest !== undefined) {
message += ` => ${context.dest}`
}
this.code = code
Object.defineProperties(this, {
name: {
value: 'SystemError',
enumerable: false,
writable: true,
configurable: true,
},
message: {
value: message,
enumerable: false,
writable: true,
configurable: true,
},
info: {
value: context,
enumerable: true,
configurable: true,
writable: false,
},
errno: {
get () {
return context.errno
},
set (value) {
context.errno = value
},
enumerable: true,
configurable: true,
},
syscall: {
get () {
return context.syscall
},
set (value) {
context.syscall = value
},
enumerable: true,
configurable: true,
},
})
if (context.path !== undefined) {
Object.defineProperty(this, 'path', {
get () {
return context.path
},
set (value) {
context.path = value
},
enumerable: true,
configurable: true,
})
}
if (context.dest !== undefined) {
Object.defineProperty(this, 'dest', {
get () {
return context.dest
},
set (value) {
context.dest = value
},
enumerable: true,
configurable: true,
})
}
}
toString () {
return `${this.name} [${this.code}]: ${this.message}`
}
[Symbol.for('nodejs.util.inspect.custom')] (_recurseTimes, ctx) {
return inspect(this, {
...ctx,
getters: true,
customInspect: false,
})
}
}
function E (code, message) {
module.exports[code] = class NodeError extends SystemError {
constructor (ctx) {
super(code, message, ctx)
}
}
}
E('ERR_FS_CP_DIR_TO_NON_DIR', 'Cannot overwrite directory with non-directory')
E('ERR_FS_CP_EEXIST', 'Target already exists')
E('ERR_FS_CP_EINVAL', 'Invalid src or dest')
E('ERR_FS_CP_FIFO_PIPE', 'Cannot copy a FIFO pipe')
E('ERR_FS_CP_NON_DIR_TO_DIR', 'Cannot overwrite non-directory with directory')
E('ERR_FS_CP_SOCKET', 'Cannot copy a socket file')
E('ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY', 'Cannot overwrite symlink in subdirectory of self')
E('ERR_FS_CP_UNKNOWN', 'Cannot copy an unknown file type')
E('ERR_FS_EISDIR', 'Path is a directory')
module.exports.ERR_INVALID_ARG_TYPE = class ERR_INVALID_ARG_TYPE extends Error {
constructor (name, expected, actual) {
super()
this.code = 'ERR_INVALID_ARG_TYPE'
this.message = `The ${name} argument must be ${expected}. Received ${typeof actual}`
}
}

8
server/node_modules/@npmcli/fs/lib/fs.js generated vendored Normal file
View File

@@ -0,0 +1,8 @@
const fs = require('fs')
const promisify = require('@gar/promisify')
// this module returns the core fs module wrapped in a proxy that promisifies
// method calls within the getter. we keep it in a separate module so that the
// overridden methods have a consistent way to get to promisified fs methods
// without creating a circular dependency
module.exports = promisify(fs)

10
server/node_modules/@npmcli/fs/lib/index.js generated vendored Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
...require('./fs.js'),
copyFile: require('./copy-file.js'),
cp: require('./cp/index.js'),
mkdir: require('./mkdir/index.js'),
mkdtemp: require('./mkdtemp.js'),
rm: require('./rm/index.js'),
withTempDir: require('./with-temp-dir.js'),
writeFile: require('./write-file.js'),
}

32
server/node_modules/@npmcli/fs/lib/mkdir/index.js generated vendored Normal file
View File

@@ -0,0 +1,32 @@
const fs = require('../fs.js')
const getOptions = require('../common/get-options.js')
const node = require('../common/node.js')
const owner = require('../common/owner.js')
const polyfill = require('./polyfill.js')
// node 10.12.0 added the options parameter, which allows recursive and mode
// properties to be passed
const useNative = node.satisfies('>=10.12.0')
// extends mkdir with the ability to specify an owner of the new dir
const mkdir = async (path, opts) => {
const options = getOptions(opts, {
copy: ['mode', 'recursive', 'owner'],
wrap: 'mode',
})
const { uid, gid } = await owner.validate(path, options.owner)
// the polyfill is tested separately from this module, no need to hack
// process.version to try to trigger it just for coverage
// istanbul ignore next
const result = useNative
? await fs.mkdir(path, options)
: await polyfill(path, options)
await owner.update(path, uid, gid)
return result
}
module.exports = mkdir

81
server/node_modules/@npmcli/fs/lib/mkdir/polyfill.js generated vendored Normal file
View File

@@ -0,0 +1,81 @@
const { dirname } = require('path')
const fileURLToPath = require('../common/file-url-to-path/index.js')
const fs = require('../fs.js')
const defaultOptions = {
mode: 0o777,
recursive: false,
}
const mkdir = async (path, opts) => {
const options = { ...defaultOptions, ...opts }
// if we're not in recursive mode, just call the real mkdir with the path and
// the mode option only
if (!options.recursive) {
return fs.mkdir(path, options.mode)
}
const makeDirectory = async (dir, mode) => {
// we can't use dirname directly since these functions support URL
// objects with the file: protocol as the path input, so first we get a
// string path, then we can call dirname on that
const parent = dir != null && dir.href && dir.origin
? dirname(fileURLToPath(dir))
: dirname(dir)
// if the parent is the dir itself, try to create it. anything but EISDIR
// should be rethrown
if (parent === dir) {
try {
await fs.mkdir(dir, opts)
} catch (err) {
if (err.code !== 'EISDIR') {
throw err
}
}
return undefined
}
try {
await fs.mkdir(dir, mode)
return dir
} catch (err) {
// ENOENT means the parent wasn't there, so create that
if (err.code === 'ENOENT') {
const made = await makeDirectory(parent, mode)
await makeDirectory(dir, mode)
// return the shallowest path we created, i.e. the result of creating
// the parent
return made
}
// an EEXIST means there's already something there
// an EROFS means we have a read-only filesystem and can't create a dir
// any other error is fatal and we should give up now
if (err.code !== 'EEXIST' && err.code !== 'EROFS') {
throw err
}
// stat the directory, if the result is a directory, then we successfully
// created this one so return its path. otherwise, we reject with the
// original error by ignoring the error in the catch
try {
const stat = await fs.stat(dir)
if (stat.isDirectory()) {
// if it already existed, we didn't create anything so return
// undefined
return undefined
}
} catch (_) {}
// if the thing that's there isn't a directory, then just re-throw
throw err
}
}
return makeDirectory(path, options.mode)
}
module.exports = mkdir

28
server/node_modules/@npmcli/fs/lib/mkdtemp.js generated vendored Normal file
View File

@@ -0,0 +1,28 @@
const { dirname, sep } = require('path')
const fs = require('./fs.js')
const getOptions = require('./common/get-options.js')
const owner = require('./common/owner.js')
const mkdtemp = async (prefix, opts) => {
const options = getOptions(opts, {
copy: ['encoding', 'owner'],
wrap: 'encoding',
})
// mkdtemp relies on the trailing path separator to indicate if it should
// create a directory inside of the prefix. if that's the case then the root
// we infer ownership from is the prefix itself, otherwise it's the dirname
// /tmp -> /tmpABCDEF, infers from /
// /tmp/ -> /tmp/ABCDEF, infers from /tmp
const root = prefix.endsWith(sep) ? prefix : dirname(prefix)
const { uid, gid } = await owner.validate(root, options.owner)
const result = await fs.mkdtemp(prefix, options)
await owner.update(result, uid, gid)
return result
}
module.exports = mkdtemp

22
server/node_modules/@npmcli/fs/lib/rm/index.js generated vendored Normal file
View File

@@ -0,0 +1,22 @@
const fs = require('../fs.js')
const getOptions = require('../common/get-options.js')
const node = require('../common/node.js')
const polyfill = require('./polyfill.js')
// node 14.14.0 added fs.rm, which allows both the force and recursive options
const useNative = node.satisfies('>=14.14.0')
const rm = async (path, opts) => {
const options = getOptions(opts, {
copy: ['retryDelay', 'maxRetries', 'recursive', 'force'],
})
// the polyfill is tested separately from this module, no need to hack
// process.version to try to trigger it just for coverage
// istanbul ignore next
return useNative
? fs.rm(path, options)
: polyfill(path, options)
}
module.exports = rm

239
server/node_modules/@npmcli/fs/lib/rm/polyfill.js generated vendored Normal file
View File

@@ -0,0 +1,239 @@
// this file is a modified version of the code in node core >=14.14.0
// which is, in turn, a modified version of the rimraf module on npm
// node core changes:
// - Use of the assert module has been replaced with core's error system.
// - All code related to the glob dependency has been removed.
// - Bring your own custom fs module is not currently supported.
// - Some basic code cleanup.
// changes here:
// - remove all callback related code
// - drop sync support
// - change assertions back to non-internal methods (see options.js)
// - throws ENOTDIR when rmdir gets an ENOENT for a path that exists in Windows
const errnos = require('os').constants.errno
const { join } = require('path')
const fs = require('../fs.js')
// error codes that mean we need to remove contents
const notEmptyCodes = new Set([
'ENOTEMPTY',
'EEXIST',
'EPERM',
])
// error codes we can retry later
const retryCodes = new Set([
'EBUSY',
'EMFILE',
'ENFILE',
'ENOTEMPTY',
'EPERM',
])
const isWindows = process.platform === 'win32'
const defaultOptions = {
retryDelay: 100,
maxRetries: 0,
recursive: false,
force: false,
}
// this is drastically simplified, but should be roughly equivalent to what
// node core throws
class ERR_FS_EISDIR extends Error {
constructor (path) {
super()
this.info = {
code: 'EISDIR',
message: 'is a directory',
path,
syscall: 'rm',
errno: errnos.EISDIR,
}
this.name = 'SystemError'
this.code = 'ERR_FS_EISDIR'
this.errno = errnos.EISDIR
this.syscall = 'rm'
this.path = path
this.message = `Path is a directory: ${this.syscall} returned ` +
`${this.info.code} (is a directory) ${path}`
}
toString () {
return `${this.name} [${this.code}]: ${this.message}`
}
}
class ENOTDIR extends Error {
constructor (path) {
super()
this.name = 'Error'
this.code = 'ENOTDIR'
this.errno = errnos.ENOTDIR
this.syscall = 'rmdir'
this.path = path
this.message = `not a directory, ${this.syscall} '${this.path}'`
}
toString () {
return `${this.name}: ${this.code}: ${this.message}`
}
}
// force is passed separately here because we respect it for the first entry
// into rimraf only, any further calls that are spawned as a result (i.e. to
// delete content within the target) will ignore ENOENT errors
const rimraf = async (path, options, isTop = false) => {
const force = isTop ? options.force : true
const stat = await fs.lstat(path)
.catch((err) => {
// we only ignore ENOENT if we're forcing this call
if (err.code === 'ENOENT' && force) {
return
}
if (isWindows && err.code === 'EPERM') {
return fixEPERM(path, options, err, isTop)
}
throw err
})
// no stat object here means either lstat threw an ENOENT, or lstat threw
// an EPERM and the fixPERM function took care of things. either way, we're
// already done, so return early
if (!stat) {
return
}
if (stat.isDirectory()) {
return rmdir(path, options, null, isTop)
}
return fs.unlink(path)
.catch((err) => {
if (err.code === 'ENOENT' && force) {
return
}
if (err.code === 'EISDIR') {
return rmdir(path, options, err, isTop)
}
if (err.code === 'EPERM') {
// in windows, we handle this through fixEPERM which will also try to
// delete things again. everywhere else since deleting the target as a
// file didn't work we go ahead and try to delete it as a directory
return isWindows
? fixEPERM(path, options, err, isTop)
: rmdir(path, options, err, isTop)
}
throw err
})
}
const fixEPERM = async (path, options, originalErr, isTop) => {
const force = isTop ? options.force : true
const targetMissing = await fs.chmod(path, 0o666)
.catch((err) => {
if (err.code === 'ENOENT' && force) {
return true
}
throw originalErr
})
// got an ENOENT above, return now. no file = no problem
if (targetMissing) {
return
}
// this function does its own lstat rather than calling rimraf again to avoid
// infinite recursion for a repeating EPERM
const stat = await fs.lstat(path)
.catch((err) => {
if (err.code === 'ENOENT' && force) {
return
}
throw originalErr
})
if (!stat) {
return
}
if (stat.isDirectory()) {
return rmdir(path, options, originalErr, isTop)
}
return fs.unlink(path)
}
const rmdir = async (path, options, originalErr, isTop) => {
if (!options.recursive && isTop) {
throw originalErr || new ERR_FS_EISDIR(path)
}
const force = isTop ? options.force : true
return fs.rmdir(path)
.catch(async (err) => {
// in Windows, calling rmdir on a file path will fail with ENOENT rather
// than ENOTDIR. to determine if that's what happened, we have to do
// another lstat on the path. if the path isn't actually gone, we throw
// away the ENOENT and replace it with our own ENOTDIR
if (isWindows && err.code === 'ENOENT') {
const stillExists = await fs.lstat(path).then(() => true, () => false)
if (stillExists) {
err = new ENOTDIR(path)
}
}
// not there, not a problem
if (err.code === 'ENOENT' && force) {
return
}
// we may not have originalErr if lstat tells us our target is a
// directory but that changes before we actually remove it, so
// only throw it here if it's set
if (originalErr && err.code === 'ENOTDIR') {
throw originalErr
}
// the directory isn't empty, remove the contents and try again
if (notEmptyCodes.has(err.code)) {
const files = await fs.readdir(path)
await Promise.all(files.map((file) => {
const target = join(path, file)
return rimraf(target, options)
}))
return fs.rmdir(path)
}
throw err
})
}
const rm = async (path, opts) => {
const options = { ...defaultOptions, ...opts }
let retries = 0
const errHandler = async (err) => {
if (retryCodes.has(err.code) && ++retries < options.maxRetries) {
const delay = retries * options.retryDelay
await promiseTimeout(delay)
return rimraf(path, options, true).catch(errHandler)
}
throw err
}
return rimraf(path, options, true).catch(errHandler)
}
const promiseTimeout = (ms) => new Promise((r) => setTimeout(r, ms))
module.exports = rm

39
server/node_modules/@npmcli/fs/lib/with-temp-dir.js generated vendored Normal file
View File

@@ -0,0 +1,39 @@
const { join, sep } = require('path')
const getOptions = require('./common/get-options.js')
const mkdir = require('./mkdir/index.js')
const mkdtemp = require('./mkdtemp.js')
const rm = require('./rm/index.js')
// create a temp directory, ensure its permissions match its parent, then call
// the supplied function passing it the path to the directory. clean up after
// the function finishes, whether it throws or not
const withTempDir = async (root, fn, opts) => {
const options = getOptions(opts, {
copy: ['tmpPrefix'],
})
// create the directory, and fix its ownership
await mkdir(root, { recursive: true, owner: 'inherit' })
const target = await mkdtemp(join(`${root}${sep}`, options.tmpPrefix || ''), { owner: 'inherit' })
let err
let result
try {
result = await fn(target)
} catch (_err) {
err = _err
}
try {
await rm(target, { force: true, recursive: true })
} catch (err) {}
if (err) {
throw err
}
return result
}
module.exports = withTempDir

19
server/node_modules/@npmcli/fs/lib/write-file.js generated vendored Normal file
View File

@@ -0,0 +1,19 @@
const fs = require('./fs.js')
const getOptions = require('./common/get-options.js')
const owner = require('./common/owner.js')
const writeFile = async (file, data, opts) => {
const options = getOptions(opts, {
copy: ['encoding', 'mode', 'flag', 'signal', 'owner'],
wrap: 'encoding',
})
const { uid, gid } = await owner.validate(file, options.owner)
const result = await fs.writeFile(file, data, options)
await owner.update(file, uid, gid)
return result
}
module.exports = writeFile

38
server/node_modules/@npmcli/fs/package.json generated vendored Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "@npmcli/fs",
"version": "1.1.1",
"description": "filesystem utilities for the npm cli",
"main": "lib/index.js",
"files": [
"bin",
"lib"
],
"scripts": {
"preversion": "npm test",
"postversion": "npm publish",
"prepublishOnly": "git push origin --follow-tags",
"snap": "tap",
"test": "tap",
"npmclilint": "npmcli-lint",
"lint": "eslint '**/*.js'",
"lintfix": "npm run lint -- --fix",
"posttest": "npm run lint",
"postsnap": "npm run lintfix --",
"postlint": "npm-template-check"
},
"keywords": [
"npm",
"oss"
],
"author": "GitHub Inc.",
"license": "ISC",
"devDependencies": {
"@npmcli/template-oss": "^2.3.1",
"tap": "^15.0.9"
},
"dependencies": {
"@gar/promisify": "^1.0.1",
"semver": "^7.3.5"
},
"templateVersion": "2.3.1"
}

22
server/node_modules/@npmcli/move-file/LICENSE.md generated vendored Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
Copyright (c) npm, Inc.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

69
server/node_modules/@npmcli/move-file/README.md generated vendored Normal file
View File

@@ -0,0 +1,69 @@
# @npmcli/move-file
A fork of [move-file](https://github.com/sindresorhus/move-file) with
compatibility with all node 10.x versions.
> Move a file (or directory)
The built-in
[`fs.rename()`](https://nodejs.org/api/fs.html#fs_fs_rename_oldpath_newpath_callback)
is just a JavaScript wrapper for the C `rename(2)` function, which doesn't
support moving files across partitions or devices. This module is what you
would have expected `fs.rename()` to be.
## Highlights
- Promise API.
- Supports moving a file across partitions and devices.
- Optionally prevent overwriting an existing file.
- Creates non-existent destination directories for you.
- Support for Node versions that lack built-in recursive `fs.mkdir()`
- Automatically recurses when source is a directory.
## Install
```
$ npm install @npmcli/move-file
```
## Usage
```js
const moveFile = require('@npmcli/move-file');
(async () => {
await moveFile('source/unicorn.png', 'destination/unicorn.png');
console.log('The file has been moved');
})();
```
## API
### moveFile(source, destination, options?)
Returns a `Promise` that resolves when the file has been moved.
### moveFile.sync(source, destination, options?)
#### source
Type: `string`
File, or directory, you want to move.
#### destination
Type: `string`
Where you want the file or directory moved.
#### options
Type: `object`
##### overwrite
Type: `boolean`\
Default: `true`
Overwrite existing destination file(s).

162
server/node_modules/@npmcli/move-file/index.js generated vendored Normal file
View File

@@ -0,0 +1,162 @@
const { dirname, join, resolve, relative, isAbsolute } = require('path')
const rimraf_ = require('rimraf')
const { promisify } = require('util')
const {
access: access_,
accessSync,
copyFile: copyFile_,
copyFileSync,
unlink: unlink_,
unlinkSync,
readdir: readdir_,
readdirSync,
rename: rename_,
renameSync,
stat: stat_,
statSync,
lstat: lstat_,
lstatSync,
symlink: symlink_,
symlinkSync,
readlink: readlink_,
readlinkSync
} = require('fs')
const access = promisify(access_)
const copyFile = promisify(copyFile_)
const unlink = promisify(unlink_)
const readdir = promisify(readdir_)
const rename = promisify(rename_)
const stat = promisify(stat_)
const lstat = promisify(lstat_)
const symlink = promisify(symlink_)
const readlink = promisify(readlink_)
const rimraf = promisify(rimraf_)
const rimrafSync = rimraf_.sync
const mkdirp = require('mkdirp')
const pathExists = async path => {
try {
await access(path)
return true
} catch (er) {
return er.code !== 'ENOENT'
}
}
const pathExistsSync = path => {
try {
accessSync(path)
return true
} catch (er) {
return er.code !== 'ENOENT'
}
}
const moveFile = async (source, destination, options = {}, root = true, symlinks = []) => {
if (!source || !destination) {
throw new TypeError('`source` and `destination` file required')
}
options = {
overwrite: true,
...options
}
if (!options.overwrite && await pathExists(destination)) {
throw new Error(`The destination file exists: ${destination}`)
}
await mkdirp(dirname(destination))
try {
await rename(source, destination)
} catch (error) {
if (error.code === 'EXDEV' || error.code === 'EPERM') {
const sourceStat = await lstat(source)
if (sourceStat.isDirectory()) {
const files = await readdir(source)
await Promise.all(files.map((file) => moveFile(join(source, file), join(destination, file), options, false, symlinks)))
} else if (sourceStat.isSymbolicLink()) {
symlinks.push({ source, destination })
} else {
await copyFile(source, destination)
}
} else {
throw error
}
}
if (root) {
await Promise.all(symlinks.map(async ({ source, destination }) => {
let target = await readlink(source)
// junction symlinks in windows will be absolute paths, so we need to make sure they point to the destination
if (isAbsolute(target))
target = resolve(destination, relative(source, target))
// try to determine what the actual file is so we can create the correct type of symlink in windows
let targetStat
try {
targetStat = await stat(resolve(dirname(source), target))
} catch (err) {}
await symlink(target, destination, targetStat && targetStat.isDirectory() ? 'junction' : 'file')
}))
await rimraf(source)
}
}
const moveFileSync = (source, destination, options = {}, root = true, symlinks = []) => {
if (!source || !destination) {
throw new TypeError('`source` and `destination` file required')
}
options = {
overwrite: true,
...options
}
if (!options.overwrite && pathExistsSync(destination)) {
throw new Error(`The destination file exists: ${destination}`)
}
mkdirp.sync(dirname(destination))
try {
renameSync(source, destination)
} catch (error) {
if (error.code === 'EXDEV' || error.code === 'EPERM') {
const sourceStat = lstatSync(source)
if (sourceStat.isDirectory()) {
const files = readdirSync(source)
for (const file of files) {
moveFileSync(join(source, file), join(destination, file), options, false, symlinks)
}
} else if (sourceStat.isSymbolicLink()) {
symlinks.push({ source, destination })
} else {
copyFileSync(source, destination)
}
} else {
throw error
}
}
if (root) {
for (const { source, destination } of symlinks) {
let target = readlinkSync(source)
// junction symlinks in windows will be absolute paths, so we need to make sure they point to the destination
if (isAbsolute(target))
target = resolve(destination, relative(source, target))
// try to determine what the actual file is so we can create the correct type of symlink in windows
let targetStat
try {
targetStat = statSync(resolve(dirname(source), target))
} catch (err) {}
symlinkSync(target, destination, targetStat && targetStat.isDirectory() ? 'junction' : 'file')
}
rimrafSync(source)
}
}
module.exports = moveFile
module.exports.sync = moveFileSync

View File

@@ -0,0 +1 @@
../mkdirp/bin/cmd.js

View File

@@ -0,0 +1,15 @@
# Changers Lorgs!
## 1.0
Full rewrite. Essentially a brand new module.
- Return a promise instead of taking a callback.
- Use native `fs.mkdir(path, { recursive: true })` when available.
- Drop support for outdated Node.js versions. (Technically still works on
Node.js v8, but only 10 and above are officially supported.)
## 0.x
Original and most widely used recursive directory creation implementation
in JavaScript, dating back to 2010.

View File

@@ -0,0 +1,21 @@
Copyright James Halliday (mail@substack.net) and Isaac Z. Schlueter (i@izs.me)
This project is free software released under the MIT license:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
const usage = () => `
usage: mkdirp [DIR1,DIR2..] {OPTIONS}
Create each supplied directory including any necessary parent directories
that don't yet exist.
If the directory already exists, do nothing.
OPTIONS are:
-m<mode> If a directory needs to be created, set the mode as an octal
--mode=<mode> permission string.
-v --version Print the mkdirp version number
-h --help Print this helpful banner
-p --print Print the first directories created for each path provided
--manual Use manual implementation, even if native is available
`
const dirs = []
const opts = {}
let print = false
let dashdash = false
let manual = false
for (const arg of process.argv.slice(2)) {
if (dashdash)
dirs.push(arg)
else if (arg === '--')
dashdash = true
else if (arg === '--manual')
manual = true
else if (/^-h/.test(arg) || /^--help/.test(arg)) {
console.log(usage())
process.exit(0)
} else if (arg === '-v' || arg === '--version') {
console.log(require('../package.json').version)
process.exit(0)
} else if (arg === '-p' || arg === '--print') {
print = true
} else if (/^-m/.test(arg) || /^--mode=/.test(arg)) {
const mode = parseInt(arg.replace(/^(-m|--mode=)/, ''), 8)
if (isNaN(mode)) {
console.error(`invalid mode argument: ${arg}\nMust be an octal number.`)
process.exit(1)
}
opts.mode = mode
} else
dirs.push(arg)
}
const mkdirp = require('../')
const impl = manual ? mkdirp.manual : mkdirp
if (dirs.length === 0)
console.error(usage())
Promise.all(dirs.map(dir => impl(dir, opts)))
.then(made => print ? made.forEach(m => m && console.log(m)) : null)
.catch(er => {
console.error(er.message)
if (er.code)
console.error(' code: ' + er.code)
process.exit(1)
})

Some files were not shown because too many files have changed in this diff Show More