diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 81d68eb..96e0fe4 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -31,8 +31,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ secrets.GH_PAT || github.actor }} + password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} - name: Get version from package.json id: package-version diff --git a/ADMIN_AUTO_SETUP.md b/ADMIN_AUTO_SETUP.md new file mode 100644 index 0000000..b5c08fa --- /dev/null +++ b/ADMIN_AUTO_SETUP.md @@ -0,0 +1,62 @@ +# Automatic Admin User Setup + +The application now **automatically creates a super admin user** when the server starts, if one doesn't already exist. + +## How It Works + +1. **On Server Startup:** + - Database is initialized + - Migrations run (ensures all columns exist) + - Admin user is automatically created if missing + +2. **Default Credentials:** + - **Username:** `admin` + - **Password:** `admin123!` + - **Email:** (none) + +3. **Customization via Environment Variables:** + You can customize the admin credentials by setting environment variables: + ```bash + ADMIN_USERNAME=myadmin + ADMIN_PASSWORD=MySecurePassword123! + ADMIN_EMAIL=admin@example.com + ``` + +## For Docker/Production + +Add to your `docker-compose.yml` or environment: +```yaml +environment: + - ADMIN_USERNAME=admin + - ADMIN_PASSWORD=YourSecurePasswordHere + - ADMIN_EMAIL=admin@yourdomain.com +``` + +## What Gets Created + +- Super admin user with `is_super_admin = 1` +- Default services (24 services) for the admin user +- All necessary database columns are automatically added + +## Database Schema + +The `users` table automatically includes: +- `id` (PRIMARY KEY) +- `username` (UNIQUE) +- `password_hash` +- `email` +- `is_super_admin` (INTEGER, DEFAULT 0) +- `created_at` (TIMESTAMP) + +## Manual Override + +If you need to manually create/update an admin user, you can still use: +```bash +node scripts/create-admin-remote.js [email] +``` + +## Security Note + +⚠️ **Important:** The default password is `admin123!` - **change it immediately after first login!** + +For production, always set `ADMIN_PASSWORD` environment variable to a strong password. diff --git a/MANUAL_SUPER_ADMIN_SETUP.md b/MANUAL_SUPER_ADMIN_SETUP.md new file mode 100644 index 0000000..7752ac6 --- /dev/null +++ b/MANUAL_SUPER_ADMIN_SETUP.md @@ -0,0 +1,144 @@ +# Manual Super Admin Setup Guide + +This guide shows you how to create a super admin user on a remote server using either SQL commands or the provided script. + +## Option 1: Using the Script (Recommended) + +The easiest way is to use the existing script: + +```bash +# On your remote server +cd /path/to/HairManager +node scripts/create-super-admin.js +``` + +**Note:** You'll need to edit `scripts/create-super-admin.js` first to set your desired username and password (lines 16-17). + +## Option 2: Manual SQL Execution + +If you need to create a super admin manually via SQL, follow these steps: + +### Step 1: Generate Password Hash + +You need to hash your password using bcrypt. You can do this in several ways: + +#### Method A: Using Node.js (on the server) +```bash +node -e "const bcrypt = require('bcrypt'); bcrypt.hash('YourPassword123!', 10).then(hash => console.log(hash));" +``` + +#### Method B: Using Python (if bcrypt is installed) +```python +import bcrypt +password = b'YourPassword123!' +hashed = bcrypt.hashpw(password, bcrypt.gensalt()) +print(hashed.decode()) +``` + +#### Method C: Create a temporary script +```javascript +// hash-password.js +import bcrypt from 'bcrypt'; +const password = 'YourPassword123!'; +const hash = await bcrypt.hash(password, 10); +console.log(hash); +``` + +Run: `node hash-password.js` + +### Step 2: Execute SQL + +Once you have the password hash, connect to your SQLite database and run: + +```sql +-- Check if user already exists +SELECT id, username, is_super_admin FROM users WHERE username = 'admin'; + +-- Option A: Create a NEW super admin user +INSERT INTO users (username, password_hash, email, is_super_admin) +VALUES ('admin', '$2b$10$YOUR_HASHED_PASSWORD_HERE', 'admin@example.com', 1); + +-- Option B: Update an EXISTING user to be super admin +UPDATE users +SET is_super_admin = 1, password_hash = '$2b$10$YOUR_HASHED_PASSWORD_HERE' +WHERE username = 'admin'; + +-- Verify the user was created/updated +SELECT id, username, email, is_super_admin FROM users WHERE username = 'admin'; +``` + +### Step 3: Verify + +After creating/updating, verify with: +```sql +SELECT id, username, email, is_super_admin, created_at +FROM users +WHERE is_super_admin = 1; +``` + +## Option 3: Quick SQLite Command Line + +If you have direct access to the database file on the server: + +```bash +# Connect to the database +sqlite3 /path/to/data/hairmanager.db + +# Then run the SQL commands from Option 2 +``` + +## Database Location + +The database location depends on your environment: + +- **Development**: `./hairmanager.db` (in project root) +- **Production/Docker**: `./data/hairmanager.db` (in data directory) +- **Custom**: Check your `NODE_ENV` and deployment configuration + +## Example: Complete Manual Setup + +```bash +# 1. SSH into your server +ssh user@your-server.com + +# 2. Navigate to your app directory +cd /path/to/HairManager + +# 3. Generate password hash +node -e "const bcrypt = require('bcrypt'); bcrypt.hash('MySecurePassword123!', 10).then(hash => console.log(hash));" + +# Copy the hash output (starts with $2b$10$...) + +# 4. Connect to database +sqlite3 data/hairmanager.db + +# 5. Insert super admin (replace HASH with your hash from step 3) +INSERT INTO users (username, password_hash, email, is_super_admin) +VALUES ('admin', 'HASH_FROM_STEP_3', 'admin@example.com', 1); + +# 6. Verify +SELECT id, username, is_super_admin FROM users WHERE username = 'admin'; + +# 7. Exit +.quit +``` + +## Troubleshooting + +### "no such table: users" +- Run the migration first: `node database/migrate.js` or ensure migrations have run + +### "column is_super_admin does not exist" +- The migration should add this column automatically +- Manually add it: `ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0;` + +### "UNIQUE constraint failed" +- User already exists, use UPDATE instead of INSERT + +## Security Notes + +⚠️ **Important:** +- Never commit password hashes to version control +- Use strong passwords for super admin accounts +- Consider using environment variables for sensitive data +- After creating the admin, verify you can log in and then remove any temporary scripts diff --git a/database/ensureAdmin.js b/database/ensureAdmin.js new file mode 100644 index 0000000..8805dc0 --- /dev/null +++ b/database/ensureAdmin.js @@ -0,0 +1,145 @@ +import bcrypt from 'bcrypt'; +import sqlite3 from 'sqlite3'; + +const runAsync = (db, sql, params = []) => { + return new Promise((resolve, reject) => { + db.run(sql, params, function(err) { + if (err) reject(err); + else resolve(this); + }); + }); +}; + +const getAsync = (db, sql, params = []) => { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +}; + +/** + * Ensures a super admin user exists in the database + * Uses environment variables for credentials, with sensible defaults + */ +export function ensureAdminUser(dbPath) { + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('Error opening database for admin user creation:', err); + reject(err); + return; + } + }); + + // Get admin credentials from environment variables or use defaults + const adminUsername = process.env.ADMIN_USERNAME || 'admin'; + const adminPassword = process.env.ADMIN_PASSWORD || 'admin123!'; + const adminEmail = process.env.ADMIN_EMAIL || null; + + // Check if admin user already exists (case-insensitive) + getAsync(db, 'SELECT id, username, is_super_admin FROM users WHERE LOWER(username) = LOWER(?)', [adminUsername]) + .then(async (existingUser) => { + if (existingUser) { + // User exists - check if they're a super admin + if (existingUser.is_super_admin === 1) { + console.log(`✓ Super admin user "${adminUsername}" already exists (ID: ${existingUser.id})`); + db.close(); + resolve(); + return; + } else { + // User exists but isn't super admin - upgrade them + console.log(`⚠️ User "${adminUsername}" exists but is not a super admin. Upgrading...`); + try { + const passwordHash = await bcrypt.hash(adminPassword, 10); + await runAsync( + db, + 'UPDATE users SET is_super_admin = 1, password_hash = ?, email = COALESCE(?, email) WHERE username = ?', + [passwordHash, adminEmail, adminUsername] + ); + console.log(`✓ User "${adminUsername}" upgraded to super admin`); + db.close(); + resolve(); + } catch (hashErr) { + console.error('Error hashing password:', hashErr); + db.close(); + reject(hashErr); + } + return; + } + } + + // User doesn't exist - create new super admin + console.log(`📝 Creating super admin user "${adminUsername}"...`); + try { + const passwordHash = await bcrypt.hash(adminPassword, 10); + + const result = await runAsync( + db, + 'INSERT INTO users (username, password_hash, email, is_super_admin) VALUES (?, ?, ?, ?)', + [adminUsername, passwordHash, adminEmail, 1] + ); + + const userId = result.lastID; + console.log(`✓ Super admin user "${adminUsername}" created (ID: ${userId})`); + + // Create default services for the new admin user + const defaultServices = [ + { service_name: 'Blow Dry', type: 'Hair', price: 15.00 }, + { service_name: 'Shampoo & Set', type: 'Hair', price: 14.00 }, + { service_name: 'Dry Cut', type: 'Hair', price: 14.00 }, + { service_name: 'Cut & Blow Dry', type: 'Hair', price: 25.00 }, + { service_name: 'Cut & Set', type: 'Hair', price: 24.00 }, + { service_name: 'Restyling', type: 'Hair', price: 30.00 }, + { service_name: 'Gents Dry Cut', type: 'Hair', price: 14.50 }, + { service_name: 'Clipper Cuts', type: 'Hair', price: 6.00 }, + { service_name: 'Beard Trim', type: 'Hair', price: 5.00 }, + { service_name: 'Child Cut', type: 'Hair', price: 10.00 }, + { service_name: 'Child Cut & Blow Dry', type: 'Hair', price: 18.00 }, + { service_name: 'Other', type: 'Hair', price: 0.00 }, + { service_name: 'File & Polish', type: 'Nails', price: 10.00 }, + { service_name: 'Manicure', type: 'Nails', price: 18.00 }, + { service_name: 'Gel Polish', type: 'Nails', price: 20.00 }, + { service_name: 'Removal', type: 'Nails', price: 6.00 }, + { service_name: 'Gel Removal & Re-Apply', type: 'Nails', price: 25.00 }, + { service_name: 'Pedicure', type: 'Nails', price: 20.00 }, + { service_name: 'Blow Dry & Fringe Trim', type: 'Hair', price: 17.00 }, + { service_name: 'Nails Cut & Filed', type: 'Nails', price: 6.00 }, + { service_name: 'Wash & Cut', type: 'Hair', price: 20.00 }, + { service_name: 'Colour', type: 'Hair', price: 60.00 }, + { service_name: 'Colour, cut & blow dry', type: 'Hair', price: 45.00 }, + { service_name: 'Hair wash', type: 'Hair', price: 5.00 } + ]; + + // Insert default services for the new admin user + for (const service of defaultServices) { + await runAsync(db, 'INSERT OR IGNORE INTO services (user_id, service_name, type, price) VALUES (?, ?, ?, ?)', + [userId, service.service_name, service.type, service.price]); + } + + console.log(`✓ Default services created for super admin user`); + console.log(`\n🔑 Admin Credentials:`); + console.log(` Username: ${adminUsername}`); + console.log(` Password: ${adminPassword}`); + if (adminEmail) { + console.log(` Email: ${adminEmail}`); + } + console.log(`\n⚠️ IMPORTANT: Change the default password after first login!`); + console.log(` You can set ADMIN_USERNAME, ADMIN_PASSWORD, and ADMIN_EMAIL environment variables to customize.\n`); + + db.close(); + resolve(); + } catch (error) { + console.error('Error creating super admin:', error); + db.close(); + reject(error); + } + }) + .catch((err) => { + console.error('Error checking for admin user:', err); + db.close(); + reject(err); + }); + }); +} diff --git a/database/init.js b/database/init.js index 9743c08..9eeaa3f 100644 --- a/database/init.js +++ b/database/init.js @@ -30,6 +30,7 @@ export function initDatabase(dbPath) { username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, email TEXT, + is_super_admin INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) `) diff --git a/database/migrate.js b/database/migrate.js index afb3386..315248b 100644 --- a/database/migrate.js +++ b/database/migrate.js @@ -61,16 +61,28 @@ function migrateDatabase(customDbPath = null) { `)); } - // Check if is_super_admin column exists in users table (for existing tables) + // Check if all required columns exist in users table (for existing tables) db.all("PRAGMA table_info(users)", [], (err, userColumns) => { if (err) { console.error('Error checking users table info:', err); // Continue anyway } else if (userColumns) { const userColumnNames = userColumns.map(col => col.name); + + // Ensure is_super_admin column exists if (!userColumnNames.includes('is_super_admin')) { migrations.push(runAsync(db, 'ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0')); } + + // Ensure email column exists (for older databases) + if (!userColumnNames.includes('email')) { + migrations.push(runAsync(db, 'ALTER TABLE users ADD COLUMN email TEXT')); + } + + // Ensure created_at column exists + if (!userColumnNames.includes('created_at')) { + migrations.push(runAsync(db, 'ALTER TABLE users ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP')); + } } }); diff --git a/package-lock.json b/package-lock.json index 50797b0..c953c3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "hairmanager", - "version": "1.0.4", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hairmanager", - "version": "1.0.4", + "version": "1.0.8", "dependencies": { "@azure/identity": "^4.13.0", "@microsoft/microsoft-graph-client": "^3.0.7", - "@sendgrid/mail": "^8.1.6", "@tiptap/extension-color": "^3.13.0", "@tiptap/extension-link": "^3.13.0", "@tiptap/extension-text-style": "^3.13.0", @@ -27,7 +26,9 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.5.0", + "react-router-dom": "^7.12.0", "react-window": "^2.2.3", + "resend": "^4.8.0", "sqlite3": "^5.1.7" }, "devDependencies": { @@ -1293,6 +1294,24 @@ "node": ">=10" } }, + "node_modules/@react-email/render": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", + "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3", + "react-promise-suspense": "^0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -1614,42 +1633,17 @@ "win32" ] }, - "node_modules/@sendgrid/client": { - "version": "8.1.6", - "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz", - "integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==", + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", "license": "MIT", "dependencies": { - "@sendgrid/helpers": "^8.0.0", - "axios": "^1.12.0" + "domhandler": "^5.0.3", + "selderee": "^0.11.0" }, - "engines": { - "node": ">=12.*" - } - }, - "node_modules/@sendgrid/helpers": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", - "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", - "license": "MIT", - "dependencies": { - "deepmerge": "^4.2.2" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@sendgrid/mail": { - "version": "8.1.6", - "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz", - "integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==", - "license": "MIT", - "dependencies": { - "@sendgrid/client": "^8.1.5", - "@sendgrid/helpers": "^8.0.0" - }, - "engines": { - "node": ">=12.*" + "funding": { + "url": "https://ko-fi.com/killymxi" } }, "node_modules/@tiptap/core": { @@ -2471,23 +2465,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2896,18 +2873,6 @@ "color-support": "bin.js" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3127,15 +3092,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -3161,6 +3117,47 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", @@ -3171,6 +3168,20 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3314,21 +3325,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", @@ -3827,63 +3823,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4114,21 +4053,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -4165,6 +4089,22 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -4205,6 +4145,25 @@ "html2canvas": "^1.0.0-rc.5" } }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -4660,6 +4619,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5394,6 +5362,19 @@ "node": ">=6" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5443,6 +5424,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -5535,6 +5525,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -5764,12 +5769,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -5911,6 +5910,21 @@ "react": "*" } }, + "node_modules/react-promise-suspense": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", + "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/react-promise-suspense/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -5921,6 +5935,57 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "license": "MIT", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-window": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.3.tgz", @@ -5952,6 +6017,18 @@ "license": "MIT", "optional": true }, + "node_modules/resend": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz", + "integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==", + "license": "MIT", + "dependencies": { + "@react-email/render": "1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6107,6 +6184,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6169,6 +6258,12 @@ "license": "ISC", "optional": true }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index e93a736..eb754c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hairmanager", "private": true, - "version": "1.0.4", + "version": "1.0.8", "type": "module", "scripts": { "dev": "vite", @@ -15,7 +15,6 @@ "dependencies": { "@azure/identity": "^4.13.0", "@microsoft/microsoft-graph-client": "^3.0.7", - "@sendgrid/mail": "^8.1.6", "@tiptap/extension-color": "^3.13.0", "@tiptap/extension-link": "^3.13.0", "@tiptap/extension-text-style": "^3.13.0", @@ -32,7 +31,9 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.5.0", + "react-router-dom": "^7.12.0", "react-window": "^2.2.3", + "resend": "^4.8.0", "sqlite3": "^5.1.7" }, "devDependencies": { diff --git a/routes/admin-users.js b/routes/admin-users.js index 76f146e..09e67bc 100644 --- a/routes/admin-users.js +++ b/routes/admin-users.js @@ -130,7 +130,7 @@ router.post('/', async (req, res) => { } // Check if user already exists - db.get('SELECT id FROM users WHERE username = ?', [username], async (err, existingUser) => { + db.get('SELECT id FROM users WHERE LOWER(username) = LOWER(?)', [username], async (err, existingUser) => { if (err) { console.error('Error checking for existing user:', err); return res.status(500).json({ error: 'Database error' }); @@ -237,7 +237,7 @@ router.put('/:id', async (req, res) => { // Check if username is being changed and if it already exists if (username) { - db.get('SELECT id FROM users WHERE username = ? AND id != ?', [username, userId], async (err, existingUser) => { + db.get('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?', [username, userId], async (err, existingUser) => { if (err) { console.error('Error checking for existing username:', err); return res.status(500).json({ error: 'Database error' }); diff --git a/routes/auth.js b/routes/auth.js index 842e2b0..a171ffc 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -15,8 +15,8 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Username and password are required' }); } - // Check if user already exists - db.get('SELECT id FROM users WHERE username = ?', [username], async (err, existingUser) => { + // Check if user already exists (case-insensitive) + db.get('SELECT id FROM users WHERE LOWER(username) = LOWER(?)', [username], async (err, existingUser) => { if (err) { console.error('Error checking for existing user:', err); return res.status(500).json({ error: 'Database error' }); @@ -111,8 +111,8 @@ router.post('/login', async (req, res) => { return res.status(400).json({ error: 'Username and password are required' }); } - // Find user - explicitly select is_super_admin to ensure it's included - db.get('SELECT id, username, password_hash, email, is_super_admin, created_at FROM users WHERE username = ?', [username], async (err, user) => { + // Find user - case-insensitive username lookup + db.get('SELECT id, username, password_hash, email, is_super_admin, created_at FROM users WHERE LOWER(username) = LOWER(?)', [username], async (err, user) => { if (err) { console.error('Error finding user:', err); return res.status(500).json({ error: 'Database error' }); @@ -144,26 +144,43 @@ router.post('/login', async (req, res) => { converted: isSuperAdmin }); - // Generate JWT token (include is_super_admin for convenience, but we'll verify from DB) - const token = jwt.sign({ - userId: user.id, - username: user.username, - is_super_admin: isSuperAdmin - }, JWT_SECRET, { expiresIn: '7d' }); + // Get user's subscription plan name for immediate access + db.get( + `SELECT sp.name as plan_name + FROM user_subscriptions us + JOIN subscription_plans sp ON us.plan_id = sp.id + WHERE us.user_id = ? AND (us.status = 'active' OR us.status IS NULL) + LIMIT 1`, + [user.id], + (planErr, subscription) => { + let planName = 'free'; + if (!planErr && subscription && subscription.plan_name) { + planName = subscription.plan_name; + } - const responseUser = { - id: user.id, - username: user.username, - email: user.email, - is_super_admin: isSuperAdmin - }; - - console.log('[AUTH /login] Response user object being sent:', JSON.stringify(responseUser, null, 2)); - - res.json({ - token, - user: responseUser - }); + // Generate JWT token (include is_super_admin for convenience, but we'll verify from DB) + const token = jwt.sign({ + userId: user.id, + username: user.username, + is_super_admin: isSuperAdmin + }, JWT_SECRET, { expiresIn: '7d' }); + + const responseUser = { + id: user.id, + username: user.username, + email: user.email, + is_super_admin: isSuperAdmin, + plan_name: planName + }; + + console.log('[AUTH /login] Response user object being sent:', JSON.stringify(responseUser, null, 2)); + + res.json({ + token, + user: responseUser + }); + } + ); } catch (error) { console.error('Error verifying password:', error); res.status(500).json({ error: 'Error verifying password' }); @@ -205,18 +222,35 @@ router.get('/me', (req, res) => { console.log('[AUTH /me] Returning is_super_admin:', isSuperAdmin); - const responseUser = { - id: user.id, - username: user.username, - email: user.email, - is_super_admin: isSuperAdmin - }; - - console.log('[AUTH /me] Response user object:', JSON.stringify(responseUser, null, 2)); - - res.json({ - user: responseUser - }); + // Get user's subscription plan name for immediate access + db.get( + `SELECT sp.name as plan_name + FROM user_subscriptions us + JOIN subscription_plans sp ON us.plan_id = sp.id + WHERE us.user_id = ? AND (us.status = 'active' OR us.status IS NULL) + LIMIT 1`, + [user.id], + (planErr, subscription) => { + let planName = 'free'; + if (!planErr && subscription && subscription.plan_name) { + planName = subscription.plan_name; + } + + const responseUser = { + id: user.id, + username: user.username, + email: user.email, + is_super_admin: isSuperAdmin, + plan_name: planName + }; + + console.log('[AUTH /me] Response user object:', JSON.stringify(responseUser, null, 2)); + + res.json({ + user: responseUser + }); + } + ); }); } catch (error) { return res.status(401).json({ error: 'Invalid token' }); diff --git a/routes/email-logs.js b/routes/email-logs.js index 204f23e..eca1c18 100644 --- a/routes/email-logs.js +++ b/routes/email-logs.js @@ -4,153 +4,110 @@ import { readFileSync } from 'fs'; const router = express.Router(); -// Webhook route doesn't require authentication (SendGrid calls it) +// Webhook route doesn't require authentication (Resend calls it) // GET handler for testing webhook endpoint router.get('/webhook', (req, res) => { res.json({ - message: 'SendGrid webhook endpoint is active. SendGrid will POST events here.', + message: 'Resend webhook endpoint is active. Resend will POST events here.', method: 'POST', - note: 'This endpoint does not require authentication for SendGrid webhooks' + note: 'This endpoint does not require authentication for Resend webhooks' }); }); -// POST handler for SendGrid webhook events -router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => { +// POST handler for Resend webhook events +// Resend sends individual events (not arrays like SendGrid) +// Event types: email.sent, email.delivered, email.delivery_delayed, +// email.complained, email.bounced, email.opened, email.clicked +router.post('/webhook', express.json(), async (req, res) => { const db = req.app.locals.db; - // Log webhook receipt const webhookReceivedAt = new Date().toISOString(); console.log('\n[WEBHOOK] ========================================'); console.log('[WEBHOOK] Webhook received at:', webhookReceivedAt); console.log('[WEBHOOK] Content-Type:', req.headers['content-type']); - console.log('[WEBHOOK] Content-Length:', req.headers['content-length']); - console.log('[WEBHOOK] User-Agent:', req.headers['user-agent']); - // Log raw webhook body - let rawBodyString; - try { - if (Buffer.isBuffer(req.body)) { - rawBodyString = req.body.toString('utf8'); - } else if (typeof req.body === 'string') { - rawBodyString = req.body; - } else { - rawBodyString = JSON.stringify(req.body); - } - console.log('[WEBHOOK] Raw JSON Body:'); - console.log(JSON.stringify(JSON.parse(rawBodyString), null, 2)); - } catch (e) { - rawBodyString = req.body?.toString?.() || String(req.body); - console.log('[WEBHOOK] Raw Body (could not parse as JSON):', rawBodyString.substring(0, 500)); - } + const event = req.body; + const eventJson = JSON.stringify(event); + console.log('[WEBHOOK] Event JSON:', eventJson); console.log('[WEBHOOK] ========================================\n'); try { - // SendGrid sends events as an array - let events; - try { - if (Array.isArray(req.body)) { - events = req.body; - } else { - events = JSON.parse(req.body.toString()); - } - } catch (parseErr) { - console.error('[WEBHOOK] ❌ Error parsing webhook body:', parseErr.message); - console.error('[WEBHOOK] Body type:', typeof req.body); - console.error('[WEBHOOK] Body preview:', req.body?.toString?.()?.substring(0, 200)); - return res.status(400).json({ error: 'Invalid webhook format', details: parseErr.message }); + // Resend webhook payload structure: + // { type: "email.delivered", created_at: "...", data: { email_id: "...", to: [...], ... } } + const eventType = event.type; + const eventData = event.data || {}; + const emailId = eventData.email_id; + const toEmails = eventData.to || []; + const createdAt = event.created_at || new Date().toISOString(); + + console.log(`[WEBHOOK] Event type: ${eventType}`); + console.log(`[WEBHOOK] Email ID: ${emailId}`); + console.log(`[WEBHOOK] To: ${toEmails.join(', ')}`); + + if (!emailId) { + console.warn('[WEBHOOK] No email_id found in event'); + return res.status(200).json({ success: true, message: 'No email_id in event' }); } - if (!Array.isArray(events)) { - console.error('[WEBHOOK] ❌ Events is not an array. Type:', typeof events); - console.error('[WEBHOOK] Events value:', events); - return res.status(400).json({ error: 'Invalid webhook format - expected array' }); + // Map Resend event types to our status + let logStatus = null; + if (eventType === 'email.sent') { + logStatus = 'sent'; + } else if (eventType === 'email.delivered') { + logStatus = 'delivered'; + } else if (eventType === 'email.bounced' || eventType === 'email.complained') { + logStatus = 'failed'; + } else if (eventType === 'email.delivery_delayed') { + logStatus = 'sent'; // Keep as sent, it's still in transit + } else if (eventType === 'email.opened') { + logStatus = 'opened'; + } else if (eventType === 'email.clicked') { + logStatus = 'opened'; // Clicked implies opened } - console.log(`[WEBHOOK] ✓ Received ${events.length} event(s) from SendGrid\n`); + if (!logStatus) { + console.log(`[WEBHOOK] No status mapping for event type: ${eventType}, acknowledging`); + return res.status(200).json({ success: true, skipped: true }); + } - // Sort events by timestamp (oldest first) to process in chronological order - // This ensures "delivered" is processed before "open", etc. - events.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); - console.log(`[WEBHOOK] Events sorted by timestamp (oldest first)\n`); + const now = createdAt; + const errorMsg = eventType === 'email.bounced' ? (eventData.bounce?.message || 'Email bounced') : + eventType === 'email.complained' ? 'Recipient marked as spam' : null; - let processedCount = 0; let updatedCount = 0; let skippedCount = 0; - // Process events sequentially to avoid race conditions - for (let index = 0; index < events.length; index++) { - const event = events[index]; - const eventJson = JSON.stringify(event); - console.log(`[WEBHOOK] --- Processing event ${index + 1}/${events.length} ---`); - console.log(`[WEBHOOK] Event JSON:`, eventJson); - const { sg_message_id, sg_event_id, event: eventType, email, timestamp, reason, status, sg_machine_open } = event; - - processedCount++; - console.log(`[WEBHOOK] Event type: ${eventType}`); - console.log(`[WEBHOOK] Message ID: ${sg_message_id}`); - console.log(`[WEBHOOK] Event ID: ${sg_event_id}`); - console.log(`[WEBHOOK] Email: ${email}`); - console.log(`[WEBHOOK] Timestamp: ${timestamp} (${new Date(timestamp * 1000).toISOString()})`); - if (reason) console.log(`[WEBHOOK] Reason: ${reason}`); - if (eventType === 'open' && sg_machine_open !== undefined) { - console.log(`[WEBHOOK] Machine/Preview Open: ${sg_machine_open}`); - } - - // Map SendGrid event types to our status - // Important: We need to respect status hierarchy - "opened" should only be set if already "delivered" - let logStatus = null; // null means don't update status for this event - if (eventType === 'delivered') { - logStatus = 'delivered'; - } else if (eventType === 'bounce' || eventType === 'dropped' || eventType === 'deferred') { - logStatus = 'failed'; - } else if (eventType === 'open' || eventType === 'click') { - // Only set to "opened" if: - // 1. Current status is already "delivered" (can't skip delivery state) - // 2. It's NOT a machine/preview open (sg_machine_open should be false or undefined) - // Machine opens (like Outlook's scanner) should not update status to "opened" - if (sg_machine_open === true) { - console.log(`[WEBHOOK] ⏭ Skipping machine/preview open event - not a real user open`); - logStatus = null; // Don't update status for machine opens - } else { - logStatus = 'opened'; // We'll check current status before updating - } - } else if (eventType === 'processed') { - logStatus = 'sent'; - } - - const now = new Date(timestamp * 1000).toISOString() || new Date().toISOString(); - const errorMsg = reason || (eventType === 'bounce' ? 'Email bounced' : null) || - (eventType === 'dropped' ? 'Email dropped' : null); - - // Extract the base message ID (before first dot) for matching - // SendGrid message IDs can be in format: "base.recvd-..." or just "base" - const baseMessageId = sg_message_id ? sg_message_id.split('.')[0] : null; - - if (!baseMessageId) { - console.warn('[WEBHOOK] No message ID found in event:', event); - return; - } - - console.log(`[WEBHOOK] Proposed status: ${logStatus || 'none (will skip)'}`); - console.log(`[WEBHOOK] Matching: webhook message ID="${sg_message_id}", base="${baseMessageId}"`); + // Process each recipient + for (const recipientEmail of toEmails) { + // Check current status to respect hierarchy + const currentRow = await new Promise((resolve) => { + db.get( + `SELECT id, status, user_id FROM email_logs + WHERE sendgrid_message_id = ? AND recipient_email = ? + LIMIT 1`, + [emailId, recipientEmail], + (err, row) => { + if (err) { + console.error('[WEBHOOK] Error checking current status:', err); + resolve(null); + } else { + resolve(row); + } + } + ); + }); - // For "open" events, we need to check current status first - // Only update to "opened" if current status is "delivered" - if (logStatus === 'opened') { - // First, get the current status AND id (we need id for webhook_events table) - // Match by BOTH message ID AND recipient email to handle multiple recipients correctly - const currentRow = await new Promise((resolve) => { + if (!currentRow) { + // Try matching by just the email_id (message ID) without recipient + const fallbackRow = await new Promise((resolve) => { db.get( `SELECT id, status, user_id FROM email_logs - WHERE (sendgrid_message_id = ? - OR sendgrid_message_id = ? - OR ? LIKE (sendgrid_message_id || '%')) - AND recipient_email = ? + WHERE sendgrid_message_id = ? LIMIT 1`, - [sg_message_id, baseMessageId, sg_message_id, email], + [emailId], (err, row) => { if (err) { - console.error('[WEBHOOK] Error checking current status:', err); + console.error('[WEBHOOK] Error in fallback lookup:', err); resolve(null); } else { resolve(row); @@ -159,519 +116,171 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r ); }); - if (!currentRow) { + if (!fallbackRow) { skippedCount++; - console.warn(`[WEBHOOK] No email log found for message ID: ${sg_message_id} (base: ${baseMessageId}) and recipient ${email}`); + console.warn(`[WEBHOOK] No email log found for email_id: ${emailId}, recipient: ${recipientEmail}`); continue; } - // Always store the webhook event, even if we skip the status update - db.run( - `INSERT INTO webhook_events - (email_log_id, user_id, event_type, sendgrid_message_id, sendgrid_event_id, raw_event_data, processed_at, event_timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [ - currentRow.id, - currentRow.user_id, - eventType, - sg_message_id, - sg_event_id || null, - eventJson, - webhookReceivedAt, - timestamp || null - ], - (err) => { - if (err) { - console.error('[WEBHOOK] Error storing webhook event for opened:', err); - } else { - console.log(`[WEBHOOK] ✓ Stored webhook event for opened: email_log_id=${currentRow.id}, status="${currentRow.status}"`); - } - } - ); - - // Only update to "opened" if: - // 1. Current status is "delivered" (can't skip delivery state) - // 2. It's NOT a machine/preview open (already checked above, but double-check) - // This prevents skipping the "delivered" state and ignores preview/scanner opens - if (currentRow.status === 'delivered' && sg_machine_open !== true) { - const openResult = await new Promise((resolve) => { - // First try with webhook_event_data column - db.run( - `UPDATE email_logs - SET status = ?, sendgrid_event_id = ?, error_message = ?, updated_at = ?, webhook_event_data = ? - WHERE (sendgrid_message_id = ? - OR sendgrid_message_id = ? - OR ? LIKE (sendgrid_message_id || '%')) - AND recipient_email = ?`, - [ - 'opened', - sg_event_id || null, - errorMsg, - now, - eventJson, // Store latest webhook event data - sg_message_id, - baseMessageId, - sg_message_id, - email // Match by recipient email to handle multiple recipients - ], - function(updateErr) { - if (updateErr) { - // If webhook_event_data column doesn't exist, try without it - if (updateErr.message && updateErr.message.includes('webhook_event_data')) { - console.warn('[WEBHOOK] webhook_event_data column not found for opened event, updating without it'); - db.run( - `UPDATE email_logs - SET status = ?, sendgrid_event_id = ?, error_message = ?, updated_at = ? - WHERE (sendgrid_message_id = ? - OR sendgrid_message_id = ? - OR ? LIKE (sendgrid_message_id || '%')) - AND recipient_email = ?`, - [ - 'opened', - sg_event_id || null, - errorMsg, - now, - sg_message_id, - baseMessageId, - sg_message_id, - email - ], - function(retryErr) { - if (retryErr) { - console.error('[WEBHOOK] ❌ Error updating to opened:', retryErr); - resolve({ success: false, changes: 0 }); - } else if (this.changes > 0) { - console.log(`[WEBHOOK] ✓ Updated to opened (was delivered): ${sg_message_id} for ${email}`); - resolve({ success: true, changes: this.changes }); - } else { - console.log(`[WEBHOOK] ⚠ No rows updated for opened event`); - resolve({ success: false, changes: 0 }); - } - } - ); - } else { - console.error('[WEBHOOK] ❌ Error updating to opened:', updateErr); - resolve({ success: false, changes: 0 }); - } - } else if (this.changes > 0) { - console.log(`[WEBHOOK] ✓ Updated to opened (was delivered): ${sg_message_id} for ${email}`); - resolve({ success: true, changes: this.changes }); - } else { - console.log(`[WEBHOOK] ⚠ No rows updated for opened event`); - resolve({ success: false, changes: 0 }); - } - } - ); - }); - - // Update counters synchronously after await - if (openResult.success && openResult.changes > 0) { - updatedCount++; - } else { - skippedCount++; - } - } else { - console.log(`[WEBHOOK] ⏭ Skipping "opened" update - current status is "${currentRow.status}", not "delivered"`); - skippedCount++; - } - continue; // Don't continue with the regular update for "open" events - } - - // For all other events, update normally - if (!logStatus) { - skippedCount++; - console.log(`[WEBHOOK] No status mapping for event type: ${eventType}, skipping status update`); + // Use fallback row + await processStatusUpdate(db, fallbackRow, logStatus, errorMsg, now, eventJson, emailId, webhookReceivedAt, eventType); + updatedCount++; continue; } - // Check current status before updating to respect hierarchy - // Don't downgrade: delivered > sent, opened > delivered - // Match by BOTH message ID AND recipient email to handle multiple recipients correctly - const currentStatus = await new Promise((resolve) => { - db.get( - `SELECT status FROM email_logs - WHERE (sendgrid_message_id = ? - OR sendgrid_message_id = ? - OR ? LIKE (sendgrid_message_id || '%')) - AND recipient_email = ? - LIMIT 1`, - [sg_message_id, baseMessageId, sg_message_id, email], - (err, row) => { - if (err) { - console.error('[WEBHOOK] Error checking current status:', err); - resolve(null); - } else { - resolve(row ? row.status : null); - } + // Store webhook event + db.run( + `INSERT INTO webhook_events + (email_log_id, user_id, event_type, sendgrid_message_id, sendgrid_event_id, raw_event_data, processed_at, event_timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + currentRow.id, + currentRow.user_id, + eventType, + emailId, + null, + eventJson, + webhookReceivedAt, + null + ], + (err) => { + if (err) { + console.error('[WEBHOOK] Error storing webhook event:', err); } - ); - }); - - // Respect status hierarchy: don't downgrade or skip states - // Status progression: sent → delivered/failed → opened - if (currentStatus) { - const statusHierarchy = { 'pending': 0, 'sent': 1, 'delivered': 2, 'opened': 3, 'failed': 0 }; - const currentLevel = statusHierarchy[currentStatus] || 0; - const newLevel = statusHierarchy[logStatus] || 0; - - // Don't allow skipping states - e.g., can't go from "sent" directly to "opened" - if (logStatus === 'opened' && currentStatus !== 'delivered') { - skippedCount++; - console.log(`[WEBHOOK] ⏭ Skipping "opened" - current status "${currentStatus}" must be "delivered" first`); - continue; } - - // Don't downgrade (except failed can overwrite anything) - if (newLevel <= currentLevel && logStatus !== 'failed') { - skippedCount++; - console.log(`[WEBHOOK] ⏭ Skipping update - current status "${currentStatus}" (level ${currentLevel}) is higher than "${logStatus}" (level ${newLevel})`); - continue; - } - } + ); - // First, find the email_log_id(s) that match this event - // Match by BOTH message ID AND recipient email to handle multiple recipients correctly - // Each recipient gets a unique full message ID in webhooks (e.g., "base.recvd-...") - // But we store the base ID when sending, so we need to match: - // 1. Full webhook ID matches stored base ID (stored base is prefix of webhook full) - // 2. Base webhook ID matches stored base ID (exact match) - // 3. Full webhook ID exact match (in case we stored full ID) - const matchingLogs = await new Promise((resolve) => { - db.all( - `SELECT id, user_id, recipient_email, sendgrid_message_id FROM email_logs - WHERE ( - sendgrid_message_id = ? - OR sendgrid_message_id = ? - OR ? LIKE (sendgrid_message_id || '%') - OR sendgrid_message_id LIKE (? || '%') - ) - AND recipient_email = ? - LIMIT 10`, - [sg_message_id, baseMessageId, sg_message_id, baseMessageId, email], - (err, rows) => { - if (err) { - console.error('[WEBHOOK] Error finding matching email logs:', err); - resolve([]); - } else { - if (rows && rows.length > 0) { - console.log(`[WEBHOOK] Found ${rows.length} matching email log(s) for recipient ${email}`); - rows.forEach(row => { - console.log(`[WEBHOOK] - Log ID: ${row.id}, stored msg_id: ${row.sendgrid_message_id}, recipient: ${row.recipient_email}`); - }); - } else { - console.warn(`[WEBHOOK] No matching email log found for recipient ${email}, message ID: ${sg_message_id} (base: ${baseMessageId})`); - } - resolve(rows || []); - } - } - ); - }); + // Respect status hierarchy + const statusHierarchy = { 'pending': 0, 'sent': 1, 'delivered': 2, 'opened': 3, 'failed': 0 }; + const currentLevel = statusHierarchy[currentRow.status] || 0; + const newLevel = statusHierarchy[logStatus] || 0; - // Store webhook event in webhook_events table for debugging (even if no match found) - // eventJson is already declared at the start of the loop - if (matchingLogs.length > 0) { - for (const log of matchingLogs) { - db.run( - `INSERT INTO webhook_events - (email_log_id, user_id, event_type, sendgrid_message_id, sendgrid_event_id, raw_event_data, processed_at, event_timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [ - log.id, - log.user_id, - eventType, - sg_message_id, - sg_event_id || null, - eventJson, - webhookReceivedAt, - timestamp || null - ], - (err) => { - if (err) { - console.error('[WEBHOOK] Error storing webhook event:', err); - } else { - console.log(`[WEBHOOK] ✓ Stored webhook event for email_log_id: ${log.id}`); - } - } - ); - } - } else { - // Store event even if no matching email log found (for debugging) - db.run( - `INSERT INTO webhook_events - (email_log_id, user_id, event_type, sendgrid_message_id, sendgrid_event_id, raw_event_data, processed_at, event_timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [ - null, - 0, // Unknown user - will need to match later - eventType, - sg_message_id, - sg_event_id || null, - eventJson, - webhookReceivedAt, - timestamp || null - ], - (err) => { - if (err) { - console.error('[WEBHOOK] Error storing unmatched webhook event:', err); - } else { - console.log(`[WEBHOOK] ⚠ Stored unmatched webhook event (no email_log found)`); - } - } - ); + // For "opened", require "delivered" first + if (logStatus === 'opened' && currentRow.status !== 'delivered') { + console.log(`[WEBHOOK] Skipping "opened" - current status "${currentRow.status}" must be "delivered" first`); + skippedCount++; + continue; } - // Try multiple matching strategies, but also match by recipient email: - // 1. Exact match with full webhook ID AND recipient email - // 2. Exact match with base ID AND recipient email - // 3. LIKE pattern: webhook full ID starts with stored base ID AND recipient email matches + // Don't downgrade (except failed can overwrite anything) + if (newLevel <= currentLevel && logStatus !== 'failed') { + console.log(`[WEBHOOK] Skipping - current status "${currentRow.status}" is higher than "${logStatus}"`); + skippedCount++; + continue; + } + + // Update the email log const updateResult = await new Promise((resolve) => { - // First try with webhook_event_data column db.run( `UPDATE email_logs - SET status = ?, sendgrid_event_id = ?, error_message = ?, updated_at = ?, webhook_event_data = ? - WHERE (sendgrid_message_id = ? - OR sendgrid_message_id = ? - OR ? LIKE (sendgrid_message_id || '%')) - AND recipient_email = ?`, - [ - logStatus, - sg_event_id || null, - errorMsg, - now, - eventJson, // Store latest webhook event data - sg_message_id, // Exact match with full ID - baseMessageId, // Exact match with base ID (most common: both are base) - sg_message_id, // Webhook full ID starts with stored base (webhook: "base.recvd-...", stored: "base") - email // Match by recipient email to handle multiple recipients - ], - function(err) { - if (err) { - // If webhook_event_data column doesn't exist, try without it - if (err.message && err.message.includes('webhook_event_data')) { - console.warn('[WEBHOOK] webhook_event_data column not found, updating without it'); - db.run( - `UPDATE email_logs - SET status = ?, sendgrid_event_id = ?, error_message = ?, updated_at = ? - WHERE (sendgrid_message_id = ? - OR sendgrid_message_id = ? - OR ? LIKE (sendgrid_message_id || '%')) - AND recipient_email = ?`, - [ - logStatus, - sg_event_id || null, - errorMsg, - now, - sg_message_id, - baseMessageId, - sg_message_id, - email - ], - function(retryErr) { - if (retryErr) { - console.error('[WEBHOOK] ❌ Error updating email log:', retryErr); - resolve({ success: false, changes: 0 }); - } else if (this.changes > 0) { - console.log(`[WEBHOOK] ✓ Updated ${this.changes} email log(s): ${sg_message_id} (base: ${baseMessageId}) -> ${logStatus} for ${email}`); - resolve({ success: true, changes: this.changes }); - } else { - console.warn(`[WEBHOOK] ⚠ No email log found matching message ID: ${sg_message_id} (base: ${baseMessageId}) and email: ${email}`); - resolve({ success: false, changes: 0, notFound: true }); - } - } - ); - } else { - console.error('[WEBHOOK] ❌ Error updating email log:', err); - resolve({ success: false, changes: 0 }); - } - } else if (this.changes > 0) { - console.log(`[WEBHOOK] ✓ Updated ${this.changes} email log(s): ${sg_message_id} (base: ${baseMessageId}) -> ${logStatus} for ${email}`); - resolve({ success: true, changes: this.changes }); + SET status = ?, error_message = ?, updated_at = ?, webhook_event_data = ? + WHERE id = ?`, + [logStatus, errorMsg, now, eventJson, currentRow.id], + function(updateErr) { + if (updateErr) { + console.error('[WEBHOOK] Error updating email log:', updateErr); + resolve(false); } else { - console.warn(`[WEBHOOK] ⚠ No email log found matching message ID: ${sg_message_id} (base: ${baseMessageId}) and email: ${email}`); - resolve({ success: false, changes: 0, notFound: true }); + console.log(`[WEBHOOK] Updated email log ${currentRow.id}: ${currentRow.status} -> ${logStatus}`); + resolve(this.changes > 0); } } ); }); - // Update counters synchronously based on result - if (updateResult.success && updateResult.changes > 0) { + if (updateResult) { updatedCount++; - // If we just updated to "delivered", check for any pending "open" events - // This handles cases where Outlook sends "open" events before "delivered" + // If just set to "delivered", check for pending open events if (logStatus === 'delivered') { - // Find the email_log_id that was just updated - const updatedLogId = await new Promise((resolve) => { + const pendingOpen = await new Promise((resolve) => { db.get( - `SELECT id FROM email_logs - WHERE (sendgrid_message_id = ? - OR sendgrid_message_id = ? - OR ? LIKE (sendgrid_message_id || '%')) - AND recipient_email = ? - LIMIT 1`, - [sg_message_id, baseMessageId, sg_message_id, email], - (err, row) => { - if (err) { - console.error('[WEBHOOK] Error finding updated log ID:', err); - resolve(null); - } else { - resolve(row ? row.id : null); - } - } + `SELECT id, raw_event_data FROM webhook_events + WHERE email_log_id = ? AND event_type IN ('email.opened', 'email.clicked') + ORDER BY processed_at DESC LIMIT 1`, + [currentRow.id], + (err, row) => resolve(err ? null : row) ); }); - if (updatedLogId) { - // Check for any "open" events that were stored but not applied - // Only apply real user opens (not machine/preview opens) that occurred after delivery - db.get( - `SELECT id, raw_event_data, event_timestamp, sendgrid_event_id - FROM webhook_events - WHERE email_log_id = ? - AND event_type IN ('open', 'click') - ORDER BY event_timestamp DESC - LIMIT 1`, - [updatedLogId], - (err, openEvent) => { - if (!err && openEvent) { - try { - const openEventData = JSON.parse(openEvent.raw_event_data); - - // Skip machine/preview opens - only apply real user opens - if (openEventData.sg_machine_open === true) { - console.log(`[WEBHOOK] Found pending "open" event but it's a machine/preview open - skipping`); - return; - } - - const openTimestamp = openEvent.event_timestamp || Math.floor(Date.now() / 1000); - const openNow = new Date(openTimestamp * 1000).toISOString(); - - console.log(`[WEBHOOK] Found pending real user "open" event (timestamp: ${openTimestamp}), applying now that status is "delivered"`); - - // Update to "opened" using the stored open event data - db.run( - `UPDATE email_logs - SET status = 'opened', sendgrid_event_id = ?, updated_at = ?, webhook_event_data = ? - WHERE id = ?`, - [ - openEvent.sendgrid_event_id || null, - openNow, - openEvent.raw_event_data, - updatedLogId - ], - function(openUpdateErr) { - if (openUpdateErr) { - // Try without webhook_event_data if column doesn't exist - if (openUpdateErr.message && openUpdateErr.message.includes('webhook_event_data')) { - db.run( - `UPDATE email_logs - SET status = 'opened', sendgrid_event_id = ?, updated_at = ? - WHERE id = ?`, - [ - openEvent.sendgrid_event_id || null, - openNow, - updatedLogId - ], - (retryErr) => { - if (retryErr) { - console.error('[WEBHOOK] Error applying pending open event:', retryErr); - } else { - console.log(`[WEBHOOK] ✓ Applied pending "open" event to email_log_id=${updatedLogId}`); - } - } - ); - } else { - console.error('[WEBHOOK] Error applying pending open event:', openUpdateErr); - } - } else { - console.log(`[WEBHOOK] ✓ Applied pending "open" event to email_log_id=${updatedLogId}`); - } - } - ); - } catch (parseErr) { - console.error('[WEBHOOK] Error parsing pending open event data:', parseErr); - } - } + if (pendingOpen) { + console.log(`[WEBHOOK] Found pending open event, applying now`); + db.run( + `UPDATE email_logs SET status = 'opened', updated_at = ?, webhook_event_data = ? WHERE id = ?`, + [now, pendingOpen.raw_event_data, currentRow.id], + (err) => { + if (err) console.error('[WEBHOOK] Error applying pending open:', err); + else console.log(`[WEBHOOK] Applied pending open to email_log_id=${currentRow.id}`); } ); } } - } else if (updateResult.notFound) { + } else { skippedCount++; } - - // If no match found, try fallback matching by email AND message ID - if (!updateResult.success && updateResult.changes === 0) { - // Try to find by email, message ID, and recent date as fallback - db.all( - `SELECT id, sendgrid_message_id, recipient_email, sent_at FROM email_logs - WHERE recipient_email = ? - AND (sendgrid_message_id = ? OR sendgrid_message_id = ? OR ? LIKE (sendgrid_message_id || '%')) - AND sent_at >= datetime('now', '-7 days') - ORDER BY sent_at DESC LIMIT 5`, - [email, sg_message_id, baseMessageId, sg_message_id], - (err, rows) => { + } + + // If no recipients in the event, try to update by email_id alone + if (toEmails.length === 0) { + const result = await new Promise((resolve) => { + db.run( + `UPDATE email_logs SET status = ?, error_message = ?, updated_at = ?, webhook_event_data = ? + WHERE sendgrid_message_id = ?`, + [logStatus, errorMsg, now, eventJson, emailId], + function(err) { if (err) { - console.error('[WEBHOOK] Error finding email log by email:', err); - return; - } - if (rows && rows.length > 0) { - console.log(`[WEBHOOK] Found ${rows.length} potential matches by email. Stored message IDs:`, rows.map(r => ({ - id: r.id, - msg_id: r.sendgrid_message_id, - email: r.recipient_email, - sent_at: r.sent_at - }))); - // Try to update the most recent one if it's within a few minutes - const mostRecent = rows[0]; - const sentTime = new Date(mostRecent.sent_at); - const now = new Date(); - const minutesDiff = (now - sentTime) / (1000 * 60); - - if (minutesDiff < 30) { // Within 30 minutes - console.log(`[WEBHOOK] Attempting to update most recent log (ID: ${mostRecent.id}) as fallback match`); - db.run( - `UPDATE email_logs - SET status = ?, sendgrid_event_id = ?, error_message = ?, updated_at = ? - WHERE id = ?`, - [logStatus, sg_event_id || null, errorMsg, new Date().toISOString(), mostRecent.id], - function(updateErr) { - if (updateErr) { - console.error('[WEBHOOK] Error updating fallback match:', updateErr); - } else if (this.changes > 0) { - updatedCount++; - console.log(`[WEBHOOK] ✓ Updated fallback match (ID: ${mostRecent.id}) -> ${logStatus}`); - } - } - ); - } + console.error('[WEBHOOK] Error updating by email_id:', err); + resolve(0); + } else { + resolve(this.changes); } } ); - } + }); + updatedCount += result; } - // Summary log - console.log('\n[WEBHOOK] ========================================'); - console.log(`[WEBHOOK] Summary: ${processedCount} processed, ${updatedCount} updated, ${skippedCount} skipped`); - console.log('[WEBHOOK] ========================================\n'); - - res.status(200).json({ - success: true, - processed: processedCount, - updated: updatedCount, - skipped: skippedCount - }); + console.log(`\n[WEBHOOK] Summary: ${updatedCount} updated, ${skippedCount} skipped`); + res.status(200).json({ success: true, updated: updatedCount, skipped: skippedCount }); } catch (error) { - console.error('\n[WEBHOOK] ❌❌❌ ERROR PROCESSING WEBHOOK ❌❌❌'); - console.error('[WEBHOOK] Error:', error.message); + console.error('\n[WEBHOOK] ERROR PROCESSING WEBHOOK:', error.message); console.error('[WEBHOOK] Stack:', error.stack); - console.error('[WEBHOOK] ========================================\n'); res.status(500).json({ error: 'Failed to process webhook', details: error.message }); } }); +// Helper function to process a status update +async function processStatusUpdate(db, row, logStatus, errorMsg, now, eventJson, emailId, webhookReceivedAt, eventType) { + // Store webhook event + db.run( + `INSERT INTO webhook_events + (email_log_id, user_id, event_type, sendgrid_message_id, sendgrid_event_id, raw_event_data, processed_at, event_timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [row.id, row.user_id, eventType, emailId, null, eventJson, webhookReceivedAt, null], + (err) => { + if (err) console.error('[WEBHOOK] Error storing webhook event:', err); + } + ); + + // Update the log + return new Promise((resolve) => { + db.run( + `UPDATE email_logs SET status = ?, error_message = ?, updated_at = ?, webhook_event_data = ? + WHERE id = ?`, + [logStatus, errorMsg, now, eventJson, row.id], + function(err) { + if (err) { + console.error('[WEBHOOK] Error updating email log:', err); + resolve(false); + } else { + console.log(`[WEBHOOK] Updated fallback email log ${row.id} -> ${logStatus}`); + resolve(this.changes > 0); + } + } + ); + }); +} + // All other routes require authentication router.use(authenticateToken); @@ -700,7 +309,6 @@ router.get('/:id/webhook-events', (req, res) => { const userId = req.userId; const { id } = req.params; - // First verify the email log belongs to the user db.get( 'SELECT id FROM email_logs WHERE id = ? AND user_id = ?', [id, userId], @@ -715,7 +323,6 @@ router.get('/:id/webhook-events', (req, res) => { return; } - // Fetch all webhook events for this email log, ordered by processed_at db.all( `SELECT * FROM webhook_events WHERE email_log_id = ? @@ -764,7 +371,6 @@ router.delete('/:id', (req, res) => { const userId = req.userId; const { id } = req.params; - // First verify the email log belongs to the user db.get( 'SELECT id FROM email_logs WHERE id = ? AND user_id = ?', [id, userId], @@ -780,17 +386,14 @@ router.delete('/:id', (req, res) => { return; } - // Delete associated webhook events first (due to foreign key) db.run( 'DELETE FROM webhook_events WHERE email_log_id = ?', [id], (webhookErr) => { if (webhookErr) { console.error('Error deleting webhook events:', webhookErr); - // Continue with email log deletion even if webhook events fail } - // Delete the email log db.run( 'DELETE FROM email_logs WHERE id = ? AND user_id = ?', [id, userId], @@ -834,7 +437,6 @@ router.get('/pdf/:id', (req, res) => { return res.status(404).json({ error: 'PDF not found' }); } - // Serve the PDF file try { const pdfData = readFileSync(row.pdf_file_path); res.contentType('application/pdf'); @@ -847,7 +449,7 @@ router.get('/pdf/:id', (req, res) => { ); }); -// POST /api/email-logs/update-status - Update email status from SendGrid webhook +// POST /api/email-logs/update-status - Update email status from webhook router.post('/update-status', (req, res) => { const db = req.app.locals.db; const { messageId, eventId, status, errorMessage } = req.body; @@ -916,13 +518,12 @@ router.put('/:id/status', (req, res) => { ); }); -// POST /api/email-logs/check-status - Manually check SendGrid status for pending/sent emails +// POST /api/email-logs/check-status - Check Resend email status via API router.post('/check-status', async (req, res) => { const db = req.app.locals.db; const userId = req.userId; try { - // Get profile settings to access SendGrid API key db.get('SELECT email_relay_api_key FROM admin_settings WHERE user_id = ?', [userId], async (err, profile) => { if (err) { console.error('Error fetching profile settings:', err); @@ -930,16 +531,10 @@ router.post('/check-status', async (req, res) => { } if (!profile || !profile.email_relay_api_key) { - return res.status(400).json({ error: 'SendGrid API key not configured' }); + return res.status(400).json({ error: 'Resend API key not configured' }); } const apiKey = profile.email_relay_api_key; - if (!apiKey || apiKey.trim() === '') { - console.error('[STATUS CHECK] API key is empty or invalid'); - return res.status(400).json({ error: 'SendGrid API key is empty' }); - } - - console.log('[STATUS CHECK] Using API key (first 10 chars):', apiKey.substring(0, 10) + '...'); // Get all pending or sent emails for this user db.all( @@ -960,166 +555,64 @@ router.post('/check-status', async (req, res) => { } try { - // First, test the API key with a simple request to verify it works - const authHeader = `Bearer ${apiKey.trim()}`; - console.log('[STATUS CHECK] Testing API key with SendGrid...'); - - // Test API key by making a simple request to user profile endpoint - const testResponse = await fetch('https://api.sendgrid.com/v3/user/profile', { - method: 'GET', - headers: { - 'Authorization': authHeader, - 'Content-Type': 'application/json' - } - }); - - if (!testResponse.ok) { - const testErrorText = await testResponse.text(); - let testErrorData; - try { - testErrorData = JSON.parse(testErrorText); - } catch (e) { - testErrorData = { message: testErrorText }; - } - console.error('[STATUS CHECK] API key test failed:', testResponse.status, testErrorData); - return res.status(400).json({ - error: 'SendGrid API key is invalid or lacks required permissions', - details: testErrorData - }); - } - - console.log('[STATUS CHECK] API key is valid, proceeding with status checks...'); - - // Use SendGrid REST API directly with fetch let updatedCount = 0; const updatePromises = logs.map(async (log) => { try { - // Query SendGrid Messages API by email address - // This works for both local and production testing - // Get base message ID (before first dot) for matching - const baseMessageId = log.sendgrid_message_id ? log.sendgrid_message_id.split('.')[0] : null; - - // Query by email - get recent messages for this recipient - const queryParams = new URLSearchParams({ - query: `to_email="${log.recipient_email}"`, - limit: '50' // Get up to 50 recent messages - }); - - console.log(`[STATUS CHECK] Checking log ${log.id} for email ${log.recipient_email}, message ID: ${log.sendgrid_message_id} (base: ${baseMessageId})`); - console.log(`[STATUS CHECK] Query: ${queryParams.toString()}`); - + // Use Resend API to get email status const response = await fetch( - `https://api.sendgrid.com/v3/messages?${queryParams}`, + `https://api.resend.com/emails/${log.sendgrid_message_id}`, { method: 'GET', headers: { - 'Authorization': authHeader, + 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } } ); if (!response.ok) { - const errorText = await response.text(); - let errorData; - try { - errorData = JSON.parse(errorText); - } catch (e) { - errorData = { message: errorText }; - } - console.error(`[STATUS CHECK] SendGrid API error for log ${log.id}:`, response.status, response.statusText); - console.error('[STATUS CHECK] Error details:', JSON.stringify(errorData, null, 2)); - - // The Messages API requires additional permissions - // This is expected - use webhooks for automatic status updates - if (response.status === 400 && errorData.errors && errorData.errors.some(e => e.message && e.message.includes('authorization required'))) { - console.log(`[STATUS CHECK] Messages API not available. Use "Mark Delivered" button for local testing, or configure webhook for production.`); - } else if (response.status === 403 || (errorData.errors && errorData.errors.some(e => e.message && e.message.includes('permission')))) { - console.error('[STATUS CHECK] Messages API requires additional permissions'); - } + console.error(`[STATUS CHECK] Resend API error for log ${log.id}:`, response.status); return; } const data = await response.json(); - // Find the message that matches our message ID - if (data && data.messages && Array.isArray(data.messages)) { - // Try to find by exact match first, then by base ID - let matchingMessage = data.messages.find(msg => { - if (!msg.msg_id) return false; - const msgBaseId = msg.msg_id.split('.')[0]; - return msg.msg_id === log.sendgrid_message_id || - msg.msg_id === baseMessageId || - msgBaseId === baseMessageId || - msg.msg_id.startsWith(baseMessageId); - }); - - // If no match by message ID, try matching by events - if (!matchingMessage) { - matchingMessage = data.messages.find(msg => - msg.events && msg.events.some(evt => { - if (!evt.sg_message_id) return false; - const evtBaseId = evt.sg_message_id.split('.')[0]; - return evt.sg_message_id === log.sendgrid_message_id || - evt.sg_message_id === baseMessageId || - evtBaseId === baseMessageId || - evt.sg_message_id.startsWith(baseMessageId); - }) - ); - } - - if (matchingMessage && matchingMessage.events && matchingMessage.events.length > 0) { - // Get the most recent event (events are sorted by most recent first) - const latestEvent = matchingMessage.events[0]; - const eventType = latestEvent.event; - - console.log(`[STATUS CHECK] Found matching message for log ${log.id}, latest event: ${eventType}`); - - // Map SendGrid event types to our status - let logStatus = 'sent'; - if (eventType === 'delivered') { - logStatus = 'delivered'; - } else if (eventType === 'bounce' || eventType === 'dropped' || eventType === 'deferred') { - logStatus = 'failed'; - } else if (eventType === 'open' || eventType === 'click') { - logStatus = 'opened'; - } else if (eventType === 'processed') { - logStatus = 'sent'; - } - - const now = new Date().toISOString(); - const errorMsg = latestEvent.reason || null; - - // Update the email log - return new Promise((resolve) => { - db.run( - `UPDATE email_logs - SET status = ?, sendgrid_event_id = ?, error_message = ?, updated_at = ? - WHERE id = ?`, - [logStatus, latestEvent.sg_event_id || null, errorMsg, now, log.id], - function(updateErr) { - if (updateErr) { - console.error('[STATUS CHECK] Error updating email log:', updateErr); - } else if (this.changes > 0) { - updatedCount++; - console.log(`[STATUS CHECK] ✓ Updated email log ${log.id}: ${logStatus} (event: ${eventType})`); - } - resolve(); - } - ); - }); - } else { - console.log(`[STATUS CHECK] No matching message found for log ${log.id} (msg_id: ${log.sendgrid_message_id}, email: ${log.recipient_email})`); - console.log(`[STATUS CHECK] Found ${data.messages.length} messages for this email, but none matched the message ID`); - } - } else { - console.log(`[STATUS CHECK] No messages found for log ${log.id} (email: ${log.recipient_email})`); + // Resend returns last_event field with the latest status + const lastEvent = data.last_event; + let logStatus = 'sent'; + + if (lastEvent === 'delivered') { + logStatus = 'delivered'; + } else if (lastEvent === 'bounced' || lastEvent === 'complained') { + logStatus = 'failed'; + } else if (lastEvent === 'opened' || lastEvent === 'clicked') { + logStatus = 'opened'; + } else if (lastEvent === 'sent') { + logStatus = 'sent'; } + + const now = new Date().toISOString(); + + return new Promise((resolve) => { + db.run( + `UPDATE email_logs + SET status = ?, updated_at = ? + WHERE id = ?`, + [logStatus, now, log.id], + function(updateErr) { + if (updateErr) { + console.error('[STATUS CHECK] Error updating email log:', updateErr); + } else if (this.changes > 0) { + updatedCount++; + console.log(`[STATUS CHECK] Updated email log ${log.id}: ${logStatus}`); + } + resolve(); + } + ); + }); } catch (checkErr) { console.error(`[STATUS CHECK] Error checking status for log ${log.id}:`, checkErr.message); - console.error('[STATUS CHECK] Stack:', checkErr.stack); - // Continue with other logs even if one fails } }); @@ -1132,9 +625,9 @@ router.post('/check-status', async (req, res) => { message: `Checked ${logs.length} emails, updated ${updatedCount} statuses` }); } catch (apiErr) { - console.error('Error querying SendGrid API:', apiErr); + console.error('Error querying Resend API:', apiErr); res.status(500).json({ - error: 'Failed to check SendGrid status', + error: 'Failed to check Resend status', details: apiErr.message }); } @@ -1148,4 +641,3 @@ router.post('/check-status', async (req, res) => { }); export default router; - diff --git a/routes/invoice.js b/routes/invoice.js index c32430a..1c6878c 100644 --- a/routes/invoice.js +++ b/routes/invoice.js @@ -4,6 +4,7 @@ import { writeFile, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { Resend } from 'resend'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -22,11 +23,9 @@ router.post('/send-email', async (req, res) => { // Also handle semicolon or comma-separated emails let toEmails = []; if (Array.isArray(to)) { - // Flatten array and split any strings that contain semicolons or commas toEmails = to .flatMap(email => { if (typeof email === 'string' && email.trim()) { - // Split by semicolon or comma, then trim and filter return email .split(/[;,]/) .map(e => e.trim()) @@ -34,9 +33,8 @@ router.post('/send-email', async (req, res) => { } return email ? [email] : []; }) - .filter((email, index, self) => self.indexOf(email) === index); // Remove duplicates + .filter((email, index, self) => self.indexOf(email) === index); } else if (typeof to === 'string' && to.trim()) { - // Split by semicolon or comma, then trim and filter toEmails = to .split(/[;,]/) .map(e => e.trim()) @@ -64,20 +62,16 @@ router.post('/send-email', async (req, res) => { return res.status(400).json({ error: 'Profile settings not configured' }); } - // Use SendGrid email relay service + // Use Resend email service const apiKey = profile.email_relay_api_key; const fromEmail = profile.email_relay_from_email || profile.email; const fromName = profile.email_relay_from_name || profile.business_name || profile.name || ''; - const ccEnabled = profile.email_relay_bcc_enabled === 1 || profile.email_relay_bcc_enabled === true; // Checkbox enables CC - // Use email_subject from profile if not provided in request, or use default + const ccEnabled = profile.email_relay_bcc_enabled === 1 || profile.email_relay_bcc_enabled === true; const emailSubject = subject || profile.email_subject || 'Invoice'; - - // Build recipient list early so it's available in error handler - const toRecipients = toEmails.map(email => ({ email: email.trim() })).filter(r => r.email); if (!apiKey) { return res.status(400).json({ - error: 'SendGrid API key not configured. Please set it in Profile Settings.' + error: 'Resend API key not configured. Please set it in Profile Settings.' }); } @@ -88,8 +82,7 @@ router.post('/send-email', async (req, res) => { } try { - const sgMail = (await import('@sendgrid/mail')).default; - sgMail.setApiKey(apiKey); + const resend = new Resend(apiKey); // Convert base64 PDF to buffer const pdfBuffer = Buffer.from(pdfData, 'base64'); @@ -97,14 +90,11 @@ router.post('/send-email', async (req, res) => { // Check if body is HTML (contains HTML tags) const isHtml = body && body.includes('<') && body.includes('>'); - // Convert to HTML if not already HTML let htmlBody; let textBody; if (isHtml) { - // Already HTML, use as-is htmlBody = body; - // Create plain text version by stripping HTML tags textBody = body .replace(//gi, '\n') .replace(/<\/p>/gi, '\n\n') @@ -117,7 +107,6 @@ router.post('/send-email', async (req, res) => { .replace(/"/g, '"') .trim(); } else { - // Plain text, convert to HTML htmlBody = body ? body.split('\n\n').map(paragraph => { if (!paragraph.trim()) return '
'; @@ -127,87 +116,59 @@ router.post('/send-email', async (req, res) => { textBody = body || 'Please find the invoice attached.'; } - // Build personalizations array for SendGrid v3 API - const personalizations = [{ - to: toRecipients - }]; - - // Add BCC with from email if enabled - if (ccEnabled && fromEmail && fromEmail.trim()) { - personalizations[0].bcc = [{ email: fromEmail.trim() }]; - console.log('BCC enabled, adding to BCC:', fromEmail.trim()); - } else { - console.log('BCC disabled or fromEmail missing. ccEnabled:', ccEnabled, 'fromEmail:', fromEmail); - } - - const msg = { - personalizations: personalizations, - from: { - email: fromEmail, - name: fromName - }, + // Build the Resend email payload + const emailPayload = { + from: fromName ? `${fromName} <${fromEmail}>` : fromEmail, + to: toEmails, subject: emailSubject, - content: [ - { - type: 'text/plain', - value: textBody - }, - { - type: 'text/html', - value: htmlBody - } - ], + html: htmlBody, + text: textBody, attachments: [ { - content: pdfBuffer.toString('base64'), + content: pdfBuffer, filename: pdfFilename || 'invoice.pdf', - type: 'application/pdf', - disposition: 'attachment' } ] }; - const result = await sgMail.send(msg); - console.log('Email sent via SendGrid'); - - // Extract SendGrid message ID from response - // SendGrid returns message ID in x-message-id header - // Format can be: "base.recvd-..." or just "base" - // We'll store the base part (before first dot) for better matching - let sendgridMessageId = result[0]?.headers?.['x-message-id'] || - result[0]?.body?.message_id || - null; - - // Extract base message ID (before first dot) for consistent matching - if (sendgridMessageId && sendgridMessageId.includes('.')) { - sendgridMessageId = sendgridMessageId.split('.')[0]; + // Add BCC with from email if enabled + if (ccEnabled && fromEmail && fromEmail.trim()) { + emailPayload.bcc = [fromEmail.trim()]; + console.log('BCC enabled, adding to BCC:', fromEmail.trim()); + } + + const { data, error: resendError } = await resend.emails.send(emailPayload); + + if (resendError) { + throw resendError; } + + console.log('Email sent via Resend'); - console.log('SendGrid message ID extracted:', sendgridMessageId); + // Extract Resend message ID from response + const resendMessageId = data?.id || null; + console.log('Resend message ID:', resendMessageId); // Save PDF to server const invoicesDir = process.env.NODE_ENV === 'production' ? join(__dirname, '..', 'data', 'invoices') : join(__dirname, '..', 'invoices'); - // Ensure invoices directory exists if (!existsSync(invoicesDir)) { await mkdir(invoicesDir, { recursive: true }); } - // Generate filename: Invoice_{invoiceNumber}_{timestamp}.pdf const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const invoiceNum = invoiceNumber || 'unknown'; const pdfFile = `Invoice_${invoiceNum}_${timestamp}.pdf`; const pdfPath = join(invoicesDir, pdfFile); - // Save PDF file await writeFile(pdfPath, pdfBuffer); console.log('PDF saved to:', pdfPath); // Log email to database const now = new Date().toISOString(); - const db = req.app.locals.db; + const toRecipients = toEmails.map(email => ({ email: email.trim() })).filter(r => r.email); for (const recipient of toRecipients) { db.run( `INSERT INTO email_logs @@ -218,8 +179,8 @@ router.post('/send-email', async (req, res) => { invoiceNum, recipient.email, emailSubject, - 'sent', // Initial status - sendgridMessageId, + 'sent', + resendMessageId, pdfPath, now, now @@ -236,60 +197,35 @@ router.post('/send-email', async (req, res) => { return res.json({ success: true, - messageId: sendgridMessageId || 'sendgrid', - method: 'SendGrid', + messageId: resendMessageId || 'resend', + method: 'Resend', pdfPath: pdfPath }); } catch (relayError) { - console.error('SendGrid error:', relayError); - let errorMessage = 'Failed to send email via SendGrid'; + console.error('Resend error:', relayError); + let errorMessage = 'Failed to send email via Resend'; let errorDetails = relayError.message; let suggestions = []; - if (relayError.response) { - const statusCode = relayError.response.statusCode; - const body = relayError.response.body; - - if (statusCode === 401) { - errorMessage = 'Invalid SendGrid API key. Please check your API key in Profile Settings.'; - } else if (statusCode === 403) { - errorMessage = 'SendGrid API key does not have permission to send emails.'; - } else if (statusCode === 400 && body?.errors) { - const firstError = body.errors[0]; - if (firstError.message?.includes('sender')) { - errorMessage = 'Sender email not verified in SendGrid. Please verify your sender email address in SendGrid.'; - suggestions.push('Go to SendGrid → Settings → Sender Authentication → Verify your sender email'); - } else { - errorMessage = `SendGrid error: ${firstError.message || relayError.message}`; - errorDetails = firstError.message || relayError.message; - } - } else { - errorMessage = `SendGrid error: ${body?.errors?.[0]?.message || relayError.message}`; - errorDetails = body?.errors?.[0]?.message || relayError.message; + if (relayError.statusCode === 401 || relayError.name === 'validation_error') { + errorMessage = 'Invalid Resend API key. Please check your API key in Profile Settings.'; + } else if (relayError.statusCode === 403) { + errorMessage = 'Resend API key does not have permission to send emails.'; + } else if (relayError.statusCode === 422) { + errorMessage = `Resend validation error: ${relayError.message}`; + if (relayError.message?.includes('domain')) { + suggestions.push('Make sure your sending domain is verified in Resend'); + suggestions.push('Go to resend.com/domains to verify your domain'); } - } - - // Check for IP blocklist/bounce errors - if (errorDetails && ( - errorDetails.includes('block list') || - errorDetails.includes('blocklist') || - errorDetails.includes('S3140') || - errorDetails.includes('bounce') || - errorDetails.includes('550 5.7.1') - )) { - errorMessage = 'Email delivery failed: SendGrid IP address is on recipient\'s blocklist'; - suggestions = [ - 'This is a SendGrid infrastructure issue, not a problem with your email content or configuration', - 'Even with Domain Authentication (SPF, DKIM, DMARC), some email providers may still block SendGrid\'s shared IP addresses', - 'The email may still be delivered to other recipients - this is specific to certain email providers (often Outlook/Microsoft)', - 'SendGrid monitors IP reputation and works to resolve blocklist issues automatically', - 'If this persists, consider SendGrid\'s Dedicated IP option for better deliverability control, or contact SendGrid support' - ]; + } else if (relayError.statusCode === 429) { + errorMessage = 'Rate limit exceeded. Please wait a moment and try again.'; + } else { + errorMessage = `Resend error: ${relayError.message}`; } // Log failed email attempt const now = new Date().toISOString(); - const db = req.app.locals.db; + const toRecipients = toEmails.map(email => ({ email: email.trim() })).filter(r => r.email); for (const recipient of toRecipients) { db.run( `INSERT INTO email_logs @@ -327,4 +263,3 @@ router.post('/send-email', async (req, res) => { }); export default router; - diff --git a/routes/profile.js b/routes/profile.js index c941479..bdce245 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -270,7 +270,7 @@ router.put('/', (req, res) => { currency || 'GBP', google_maps_api_key || '', finalEmailPassword, - email_relay_service || 'sendgrid', + email_relay_service || 'resend', finalEmailRelayApiKey, email_relay_from_email || '', email_relay_from_name || '', @@ -326,7 +326,7 @@ router.put('/', (req, res) => { currency || 'GBP', google_maps_api_key || '', email_password || '', - email_relay_service || 'sendgrid', + email_relay_service || 'resend', email_relay_api_key || '', email_relay_from_email || '', email_relay_from_name || '', @@ -417,7 +417,7 @@ router.get('/export/json', (req, res) => { home_postcode: row.home_postcode || '', currency: row.currency || 'GBP', google_maps_api_key: row.google_maps_api_key || '', - email_relay_service: row.email_relay_service || 'sendgrid', + email_relay_service: row.email_relay_service || 'resend', email_relay_api_key: row.email_relay_api_key || '', email_relay_from_email: row.email_relay_from_email || '', email_relay_from_name: row.email_relay_from_name || '', @@ -499,7 +499,7 @@ router.post('/import/json', (req, res) => { importData.home_postcode || '', importData.currency || 'GBP', importData.google_maps_api_key || '', - importData.email_relay_service || 'sendgrid', + importData.email_relay_service || 'resend', finalEmailRelayApiKey, importData.email_relay_from_email || '', importData.email_relay_from_name || '', @@ -543,7 +543,7 @@ router.post('/import/json', (req, res) => { importData.home_postcode || '', importData.currency || 'GBP', importData.google_maps_api_key || '', - importData.email_relay_service || 'sendgrid', + importData.email_relay_service || 'resend', importData.email_relay_api_key || '', importData.email_relay_from_email || '', importData.email_relay_from_name || '', diff --git a/routes/relay-email.js b/routes/relay-email.js index cb39634..2b6233d 100644 --- a/routes/relay-email.js +++ b/routes/relay-email.js @@ -1,9 +1,9 @@ import express from 'express'; -import sgMail from '@sendgrid/mail'; +import { Resend } from 'resend'; const router = express.Router(); -// Send email via relay service (SendGrid, Mailgun, etc.) +// Send email via Resend API router.post('/send-email', async (req, res) => { try { const { to, subject, body, pdfData, pdfFilename } = req.body; @@ -29,69 +29,55 @@ router.post('/send-email', async (req, res) => { } // Check if relay service is configured - if (!profile.email_relay_service || !profile.email_relay_api_key) { + if (!profile.email_relay_api_key) { return res.status(400).json({ - error: 'Email relay service not configured. Please set up a relay service (SendGrid, etc.) in Profile Settings.' + error: 'Resend API key not configured. Please set up Resend in Profile Settings.' }); } - const relayService = profile.email_relay_service.toLowerCase(); const fromEmail = profile.email_relay_from_email || profile.email || 'noreply@example.com'; const fromName = profile.email_relay_from_name || profile.business_name || profile.name || ''; try { - if (relayService === 'sendgrid') { - // SendGrid implementation - sgMail.setApiKey(profile.email_relay_api_key); + const resend = new Resend(profile.email_relay_api_key); - // Convert base64 PDF to buffer - const pdfBuffer = Buffer.from(pdfData, 'base64'); + // Convert base64 PDF to buffer + const pdfBuffer = Buffer.from(pdfData, 'base64'); - const msg = { - to: to, - from: { - email: fromEmail, - name: fromName - }, - subject: subject, - text: body || 'Please find the invoice attached.', - html: body ? body.replace(/\n/g, '
') : '

Please find the invoice attached.

', - attachments: [ - { - content: pdfBuffer.toString('base64'), - filename: pdfFilename || 'invoice.pdf', - type: 'application/pdf', - disposition: 'attachment' - } - ] - }; + const { data, error: resendError } = await resend.emails.send({ + from: fromName ? `${fromName} <${fromEmail}>` : fromEmail, + to: [to], + subject: subject, + text: body || 'Please find the invoice attached.', + html: body ? body.replace(/\n/g, '
') : '

Please find the invoice attached.

', + attachments: [ + { + content: pdfBuffer, + filename: pdfFilename || 'invoice.pdf', + } + ] + }); - await sgMail.send(msg); - console.log('Email sent via SendGrid'); - return res.json({ - success: true, - message: 'Email sent successfully via SendGrid', - method: 'SendGrid' - }); - } else { - return res.status(400).json({ - error: `Unsupported relay service: ${relayService}. Currently supported: SendGrid` - }); + if (resendError) { + throw resendError; } + + console.log('Email sent via Resend'); + return res.json({ + success: true, + message: 'Email sent successfully via Resend', + method: 'Resend' + }); } catch (relayError) { - console.error('Relay service error:', relayError); + console.error('Resend error:', relayError); - let errorMessage = 'Failed to send email via relay service'; - if (relayError.response) { - errorMessage += `: ${relayError.response.body?.errors?.[0]?.message || relayError.response.body?.message || 'Unknown error'}`; - } else { - errorMessage += `: ${relayError.message}`; - } + let errorMessage = 'Failed to send email via Resend'; + errorMessage += `: ${relayError.message}`; res.status(500).json({ error: errorMessage, details: relayError.message, - service: relayService + service: 'resend' }); } }); diff --git a/routes/subscriptions.js b/routes/subscriptions.js index dbdcbc1..b3348bd 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -90,6 +90,112 @@ router.get('/my-subscription', authenticateToken, (req, res) => { ); }); +// Get current user's subscription usage (plan + usage stats) +router.get('/usage', authenticateToken, (req, res) => { + const db = req.app.locals.db; + const userId = req.userId; + + // Get user's current plan and limits + db.get( + `SELECT + sp.name as plan_name, + sp.display_name as plan_display_name, + sp.max_appointments, + sp.max_locations, + sp.max_services, + sp.features, + sp.price_monthly, + sp.currency, + us.status as subscription_status + FROM user_subscriptions us + JOIN subscription_plans sp ON us.plan_id = sp.id + WHERE us.user_id = ? AND (us.status = 'active' OR us.status IS NULL)`, + [userId], + (err, userSubscription) => { + if (err) { + console.error('Error fetching user subscription:', err); + return res.status(500).json({ error: 'Database error' }); + } + + console.log('[subscriptions/usage] User subscription query result:', userSubscription); + + let currentPlan = userSubscription; + if (!currentPlan) { + // If no active subscription, get the 'Free' plan + db.get( + `SELECT name, display_name, max_appointments, max_locations, max_services, features, price_monthly, currency + FROM subscription_plans WHERE name = 'free'`, + [], + (planErr, freePlan) => { + if (planErr || !freePlan) { + return res.status(500).json({ error: 'No default "Free" plan found.' }); + } + currentPlan = { + name: freePlan.name, + display_name: freePlan.display_name, + max_appointments: freePlan.max_appointments, + max_locations: freePlan.max_locations, + max_services: freePlan.max_services, + features: freePlan.features ? JSON.parse(freePlan.features) : [], + price_monthly: freePlan.price_monthly, + currency: freePlan.currency + }; + fetchUsageAndRespond(); + } + ); + return; + } + + // Parse features if needed + if (currentPlan.features && typeof currentPlan.features === 'string') { + currentPlan.features = JSON.parse(currentPlan.features); + } + + fetchUsageAndRespond(); + + function fetchUsageAndRespond() { + // Get all usage counts in a single optimized query + db.get( + `SELECT + (SELECT COUNT(*) FROM appointments WHERE user_id = ?) as appointment_count, + (SELECT COUNT(*) FROM address_data WHERE user_id = ?) as location_count, + (SELECT COUNT(*) FROM services WHERE user_id = ?) as service_count`, + [userId, userId, userId], + (err, row) => { + if (err) { + console.error('Error fetching usage counts:', err); + return res.status(500).json({ error: 'Database error' }); + } + + const planData = { + name: currentPlan.plan_name || currentPlan.name, + display_name: currentPlan.plan_display_name || currentPlan.display_name, + max_appointments: currentPlan.max_appointments, + max_locations: currentPlan.max_locations, + max_services: currentPlan.max_services, + features: currentPlan.features || [], + price: currentPlan.price_monthly, + currency: currentPlan.currency || 'GBP' + }; + + console.log('[subscriptions/usage] Returning plan data:', planData); + console.log('[subscriptions/usage] Plan name:', planData.name); + + res.json({ + plan: planData, + usage: { + appointments: row.appointment_count || 0, + locations: row.location_count || 0, + services: row.service_count || 0 + } + }); + } + ); + } + } + ); +}); + // Get current user's usage statistics router.get('/my-usage', authenticateToken, (req, res) => { const db = req.app.locals.db; diff --git a/scripts/check-super-admin.js b/scripts/check-super-admin.js new file mode 100644 index 0000000..d04f6e9 --- /dev/null +++ b/scripts/check-super-admin.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Check if Super Admin Users Exist + * + * Usage: + * node scripts/check-super-admin.js + */ + +import sqlite3 from 'sqlite3'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Use data directory in production (Docker), or current directory in development +const dataDir = process.env.NODE_ENV === 'production' + ? join(__dirname, '..', 'data') + : join(__dirname, '..'); +const dbPath = join(dataDir, 'hairmanager.db'); + +console.log('\n🔍 Checking for Super Admin Users...\n'); +console.log(` Database: ${dbPath}\n`); + +return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('❌ Error opening database:', err.message); + console.error(` Make sure the database exists at: ${dbPath}\n`); + reject(err); + return; + } + }); + + // Check for super admin users + db.all( + 'SELECT id, username, email, is_super_admin, created_at FROM users WHERE is_super_admin = 1', + [], + (err, superAdmins) => { + if (err) { + console.error('❌ Error querying database:', err.message); + db.close(); + reject(err); + return; + } + + if (superAdmins.length === 0) { + console.log('⚠️ No super admin users found!\n'); + console.log('💡 To create a super admin, run:'); + console.log(' node scripts/create-admin-remote.js [email]\n'); + } else { + console.log(`✅ Found ${superAdmins.length} super admin user(s):\n`); + superAdmins.forEach((admin, index) => { + console.log(` ${index + 1}. ${admin.username}`); + console.log(` ID: ${admin.id}`); + if (admin.email) { + console.log(` Email: ${admin.email}`); + } + console.log(` Created: ${admin.created_at || 'Unknown'}`); + console.log(''); + }); + } + + // Also show all users for reference + db.all('SELECT id, username, email, is_super_admin FROM users ORDER BY id', [], (err, allUsers) => { + if (!err && allUsers.length > 0) { + console.log('📋 All users in database:\n'); + allUsers.forEach(user => { + const role = user.is_super_admin ? '🔑 Super Admin' : '👤 User'; + console.log(` ${role} - ${user.username} (ID: ${user.id})`); + if (user.email) { + console.log(` Email: ${user.email}`); + } + }); + console.log(''); + } + db.close(); + resolve(); + }); + } + ); +}); diff --git a/scripts/create-admin-remote.js b/scripts/create-admin-remote.js new file mode 100644 index 0000000..f01d82f --- /dev/null +++ b/scripts/create-admin-remote.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/** + * Create Super Admin User - Remote Server Helper + * + * Usage: + * node scripts/create-admin-remote.js [email] + * + * Example: + * node scripts/create-admin-remote.js admin MySecurePass123! admin@example.com + */ + +import bcrypt from 'bcrypt'; +import sqlite3 from 'sqlite3'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { migrateDatabase } from '../database/migrate.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Get arguments from command line +const args = process.argv.slice(2); + +if (args.length < 2) { + console.error('\n❌ Error: Missing required arguments\n'); + console.log('Usage: node scripts/create-admin-remote.js [email]\n'); + console.log('Example:'); + console.log(' node scripts/create-admin-remote.js admin MySecurePass123! admin@example.com\n'); + process.exit(1); +} + +const username = args[0]; +const password = args[1]; +const email = args[2] || null; + +// Use data directory in production (Docker), or current directory in development +const dataDir = process.env.NODE_ENV === 'production' + ? join(__dirname, '..', 'data') + : join(__dirname, '..'); +const dbPath = join(dataDir, 'hairmanager.db'); + +console.log('\n🔧 Creating Super Admin User...\n'); +console.log(` Username: ${username}`); +console.log(` Email: ${email || '(none)'}`); +console.log(` Database: ${dbPath}\n`); + +async function createSuperAdmin() { + // First, ensure migration has run + console.log('📦 Running database migration...'); + try { + await migrateDatabase(dbPath); + console.log('✓ Migration completed\n'); + } catch (err) { + console.error('⚠️ Migration warning (continuing anyway):', err.message); + console.log(''); + } + + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('❌ Error opening database:', err.message); + console.error(` Make sure the database exists at: ${dbPath}\n`); + reject(err); + return; + } + }); + + // Check if user already exists + db.get('SELECT id, username, is_super_admin FROM users WHERE username = ?', [username], async (err, existingUser) => { + if (err) { + console.error('❌ Error checking for existing user:', err.message); + db.close(); + reject(err); + return; + } + + if (existingUser) { + // Update existing user to be super admin + console.log(`⚠️ User "${username}" already exists (ID: ${existingUser.id})`); + console.log(` Current super admin status: ${existingUser.is_super_admin ? 'Yes' : 'No'}`); + console.log(' Updating to super admin...\n'); + + try { + const passwordHash = await bcrypt.hash(password, 10); + db.run( + 'UPDATE users SET password_hash = ?, is_super_admin = 1, email = COALESCE(?, email) WHERE username = ?', + [passwordHash, email, username], + function(updateErr) { + if (updateErr) { + console.error('❌ Error updating user:', updateErr.message); + db.close(); + reject(updateErr); + return; + } + console.log(`✅ User "${username}" updated to super admin`); + console.log(` Password has been updated`); + if (email) { + console.log(` Email set to: ${email}`); + } + console.log(''); + db.close(); + resolve(); + } + ); + } catch (hashErr) { + console.error('❌ Error hashing password:', hashErr.message); + db.close(); + reject(hashErr); + } + return; + } + + // Create new user + console.log(`📝 Creating new super admin user...\n`); + try { + const passwordHash = await bcrypt.hash(password, 10); + + db.run( + 'INSERT INTO users (username, password_hash, email, is_super_admin) VALUES (?, ?, ?, ?)', + [username, passwordHash, email, 1], + function(insertErr) { + if (insertErr) { + console.error('❌ Error creating user:', insertErr.message); + db.close(); + reject(insertErr); + return; + } + + const userId = this.lastID; + console.log(`✅ Super admin user "${username}" created successfully!`); + console.log(` User ID: ${userId}`); + if (email) { + console.log(` Email: ${email}`); + } + console.log(''); + + // Create default services for the new user + const defaultServices = [ + { service_name: 'Blow Dry', type: 'Hair', price: 15.00 }, + { service_name: 'Shampoo & Set', type: 'Hair', price: 14.00 }, + { service_name: 'Dry Cut', type: 'Hair', price: 14.00 }, + { service_name: 'Cut & Blow Dry', type: 'Hair', price: 25.00 }, + { service_name: 'Cut & Set', type: 'Hair', price: 24.00 }, + { service_name: 'Restyling', type: 'Hair', price: 30.00 }, + { service_name: 'Gents Dry Cut', type: 'Hair', price: 14.50 }, + { service_name: 'Clipper Cuts', type: 'Hair', price: 6.00 }, + { service_name: 'Beard Trim', type: 'Hair', price: 5.00 }, + { service_name: 'Child Cut', type: 'Hair', price: 10.00 }, + { service_name: 'Child Cut & Blow Dry', type: 'Hair', price: 18.00 }, + { service_name: 'Other', type: 'Hair', price: 0.00 }, + { service_name: 'File & Polish', type: 'Nails', price: 10.00 }, + { service_name: 'Manicure', type: 'Nails', price: 18.00 }, + { service_name: 'Gel Polish', type: 'Nails', price: 20.00 }, + { service_name: 'Removal', type: 'Nails', price: 6.00 }, + { service_name: 'Gel Removal & Re-Apply', type: 'Nails', price: 25.00 }, + { service_name: 'Pedicure', type: 'Nails', price: 20.00 }, + { service_name: 'Blow Dry & Fringe Trim', type: 'Hair', price: 17.00 }, + { service_name: 'Nails Cut & Filed', type: 'Nails', price: 6.00 }, + { service_name: 'Wash & Cut', type: 'Hair', price: 20.00 }, + { service_name: 'Colour', type: 'Hair', price: 60.00 }, + { service_name: 'Colour, cut & blow dry', type: 'Hair', price: 45.00 }, + { service_name: 'Hair wash', type: 'Hair', price: 5.00 } + ]; + + // Insert default services for the new user + const stmt = db.prepare('INSERT OR IGNORE INTO services (user_id, service_name, type, price) VALUES (?, ?, ?, ?)'); + defaultServices.forEach(service => { + stmt.run([userId, service.service_name, service.type, service.price]); + }); + stmt.finalize(); + + console.log(`✅ Default services created for user "${username}"`); + console.log(''); + db.close(); + resolve(); + } + ); + } catch (error) { + console.error('❌ Error hashing password:', error.message); + db.close(); + reject(error); + } + }); + }); +} + +// Run the script +createSuperAdmin() + .then(() => { + console.log('🎉 Setup complete! You can now log in with:'); + console.log(` Username: ${username}`); + console.log(` Password: ${password}\n`); + process.exit(0); + }) + .catch((err) => { + console.error('\n❌ Error creating super admin:', err.message); + console.error('\n💡 Troubleshooting:'); + console.error(' 1. Make sure the database file exists'); + console.error(' 2. Check file permissions on the database'); + console.error(' 3. Ensure migrations have run'); + console.error(' 4. Check NODE_ENV if using Docker/production\n'); + process.exit(1); + }); diff --git a/server.js b/server.js index d04ca23..6c59900 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ import { dirname, join } from 'path'; import { readFileSync } from 'fs'; import { initDatabase } from './database/init.js'; import { migrateDatabase } from './database/migrate.js'; +import { ensureAdminUser } from './database/ensureAdmin.js'; import appointmentsRoutes from './routes/appointments.js'; import servicesRoutes from './routes/services.js'; import locationsRoutes from './routes/locations.js'; @@ -52,7 +53,7 @@ console.log('[SERVER INIT] Request logging middleware registered'); // Profile routes are now handled by the profile router (routes/profile.js) // which includes authentication middleware -// Test email endpoint removed - using SendGrid only now +// Test email endpoint removed - using Resend API now /* app.post('/api/profile/test-email', async (req, res) => { console.log('\n[DIRECT PROFILE ROUTE] POST /api/profile/test-email - HANDLER CALLED'); @@ -551,9 +552,10 @@ const dataDir = process.env.NODE_ENV === 'production' : __dirname; const dbPath = join(dataDir, 'hairmanager.db'); -// Initialize database first, then migrate, then start server +// Initialize database first, then migrate, then ensure admin user exists, then start server initDatabase(dbPath) .then(() => migrateDatabase(dbPath)) + .then(() => ensureAdminUser(dbPath)) .then(() => { // Create database connection after initialization const db = new sqlite3.Database(dbPath, (err) => { diff --git a/src/App.css b/src/App.css index 83abd6c..af90d6d 100644 --- a/src/App.css +++ b/src/App.css @@ -1,17 +1,17 @@ .app { min-height: 100vh; - background: #e5e5e5; + background: var(--color-bg-page); } -/* Impersonation Banner */ +/* ===== IMPERSONATION BANNER ===== */ .impersonation-banner { - background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%); - color: white; + background: var(--gradient-purple); + color: var(--color-text-inverse); padding: 10px 20px; - font-size: 13px; + font-size: var(--text-sm); position: sticky; top: 0; - z-index: 1000; + z-index: var(--z-banner); box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3); } @@ -21,11 +21,11 @@ display: flex; align-items: center; justify-content: center; - gap: 12px; + gap: var(--space-md); } .impersonation-icon { - font-size: 14px; + font-size: var(--text-base); opacity: 0.9; } @@ -34,7 +34,7 @@ } .impersonation-content strong { - font-weight: 600; + font-weight: var(--font-semibold); } .return-btn { @@ -43,14 +43,14 @@ gap: 6px; padding: 6px 14px; background: rgba(255, 255, 255, 0.2); - color: white; + color: var(--color-text-inverse); border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 6px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 12px; - font-weight: 600; - transition: all 0.15s ease; - margin-left: 8px; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + transition: all var(--transition-fast); + margin-left: var(--space-sm); } .return-btn:hover { @@ -58,18 +58,20 @@ border-color: rgba(255, 255, 255, 0.5); } +/* ===== APP HEADER ===== */ .app-header { - background: white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + background: var(--color-bg-primary); + box-shadow: var(--shadow-md); margin-bottom: 0; width: 100%; + border-bottom: 1px solid var(--color-border); } .app-header-content { max-width: 1400px; margin-left: auto; margin-right: auto; - padding: 20px; + padding: var(--space-md) var(--space-lg); position: relative; } @@ -77,86 +79,304 @@ display: flex; align-items: center; justify-content: space-between; - gap: 20px; + gap: var(--space-lg); } .business-name { margin: 0; - color: #333; - font-size: 1.8em; - font-weight: 700; + color: var(--color-text-primary); + font-size: 1.6em; + font-weight: var(--font-bold); white-space: nowrap; flex-shrink: 0; letter-spacing: -0.5px; } +/* ===== NAVIGATION TABS ===== */ .tabs { display: flex; justify-content: center; align-items: center; - gap: 10px; + gap: var(--space-sm); flex: 1; } +.desktop-nav { + display: flex; +} + +/* ===== MOBILE MENU ===== */ +.mobile-menu-toggle { + display: none; /* Hidden on desktop */ + background: transparent; + border: none; + color: var(--color-text-primary); + font-size: 24px; + cursor: pointer; + padding: 8px; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + flex-shrink: 0; + z-index: calc(var(--z-dropdown) + 10); +} + +.mobile-menu-toggle:hover { + background: var(--color-bg-secondary); +} + +.mobile-nav { + display: none; /* Hidden on desktop */ + position: fixed; + top: 0; + left: 0; + right: 0; + background: var(--color-bg-primary); + box-shadow: var(--shadow-xl); + border-bottom: 1px solid var(--color-border); + z-index: calc(var(--z-dropdown) + 5); + padding: var(--space-lg); + padding-top: calc(var(--space-lg) + 80px); /* Account for header height */ + max-height: 100vh; + overflow-y: auto; + transform: translateY(-100%); + opacity: 0; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease; + pointer-events: none; + -webkit-overflow-scrolling: touch; +} + +.mobile-nav.open { + transform: translateY(0); + opacity: 1; + pointer-events: all; +} + +.mobile-nav-item { + width: 100%; + padding: 14px var(--space-md); + border: none; + background: transparent; + color: var(--color-text-primary); + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-md); + font-size: var(--text-base); + font-weight: var(--font-medium); + border-radius: var(--radius-md); + margin-bottom: var(--space-xs); + transition: all var(--transition-fast); +} + +.mobile-nav-item:hover { + background: var(--color-bg-secondary); + color: var(--color-text-primary); +} + +.mobile-nav-item.active { + background: var(--gradient-primary); + color: var(--color-text-inverse); + box-shadow: var(--shadow-glow-primary); +} + +.mobile-nav-item svg { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.mobile-nav-divider { + height: 1px; + background-color: var(--color-border); + margin: var(--space-md) 0; + width: 100%; +} + +.mobile-nav-item-signout { + color: var(--color-danger) !important; + margin-top: var(--space-xs); +} + +.mobile-nav-item-signout:hover { + background: var(--color-danger-light) !important; + color: var(--color-danger-hover) !important; +} + +.mobile-nav-item-signout svg { + color: var(--color-danger); +} + +/* Mobile Menu Overlay */ +.mobile-nav-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: calc(var(--z-dropdown) + 4); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); +} + +/* ===== RESPONSIVE DESIGN ===== */ +@media (max-width: 768px) { + .desktop-nav { + display: none; /* Hide desktop nav on mobile */ + } + + .mobile-menu-toggle { + display: flex; + align-items: center; + justify-content: center; + } + + .mobile-nav { + display: block; + } + + .header-row { + gap: var(--space-md); + } + + .business-name { + font-size: 1.3em; + flex: 1; + } + + .app-header-content { + padding: var(--space-md) var(--space-md); + } + + .profile-name { + display: none; /* Hide profile name on mobile to save space */ + } + + .profile-menu-container { + display: none; /* Hide profile menu on mobile - available in hamburger menu */ + } +} + +@media (max-width: 480px) { + .business-name { + font-size: 1.1em; + } + + .mobile-nav { + padding: var(--space-md); + padding-top: calc(var(--space-md) + 60px); + } + + .mobile-nav-item { + padding: 12px var(--space-sm); + font-size: var(--text-sm); + } +} + .nav-divider { width: 1px; - height: 30px; - background-color: #d0d0d0; - margin: 0 5px; + height: 28px; + background-color: var(--color-border); + margin: 0 var(--space-sm); flex-shrink: 0; } .tabs button { - padding: 10px 20px; + padding: 10px 18px; border: none; background: transparent; - color: #666; - border-radius: 8px; + color: var(--color-text-secondary); + border-radius: var(--radius-md); cursor: pointer; - font-size: 15px; - font-weight: 500; - transition: all 0.2s; + font-size: var(--text-md); + font-weight: var(--font-medium); + transition: all var(--transition-fast); display: flex; align-items: center; - gap: 8px; + gap: var(--space-sm); } .tabs button:hover { - background: #f5f5f5; - color: #333; + background: var(--color-bg-secondary); + color: var(--color-text-primary); } .tabs button.active { - background: #00BCD4; - color: white; - border: none; + background: var(--gradient-primary); + color: var(--color-text-inverse); + box-shadow: var(--shadow-glow-primary); } .tabs .entry-btn { - border: 2px solid #ddd; - background: white; + border: 2px solid var(--color-border); + background: var(--color-bg-primary); } .tabs .entry-btn:hover { - border-color: #00BCD4; - color: #00BCD4; - background: white; + border-color: var(--color-primary); + color: var(--color-primary); + background: var(--color-bg-primary); } .tabs .entry-btn.active { - border-color: #00BCD4; - background: white; - color: #00BCD4; + border: none; + background: var(--gradient-success); + color: var(--color-text-inverse); + box-shadow: var(--shadow-glow-success); } -.tabs .entry-btn.active:hover { - border-color: #00BCD4; - background: white; - color: #00BCD4; +/* Super Admin Button - Special Style */ +.tabs button.super-admin-btn { + background: var(--gradient-warning); + color: var(--color-text-inverse); + box-shadow: var(--shadow-glow-warning); } +.tabs button.super-admin-btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.tabs button.super-admin-btn.active { + background: var(--gradient-warning); + color: var(--color-text-inverse); + box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4); + transform: translateY(-1px); +} + +/* Super Admin Header Button - Next to Profile */ +.super-admin-header-btn { + padding: 10px 18px; + border: none; + background: var(--gradient-warning); + color: var(--color-text-inverse); + border-radius: var(--radius-md); + cursor: pointer; + font-size: var(--text-md); + font-weight: var(--font-medium); + transition: all var(--transition-fast); + display: flex; + align-items: center; + gap: var(--space-sm); + box-shadow: var(--shadow-glow-warning); + flex-shrink: 0; +} + +.super-admin-header-btn:hover { + opacity: 0.9; + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4); +} + +.super-admin-header-btn.active { + box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4); + transform: translateY(-1px); +} + +/* ===== MAIN CONTENT ===== */ .app-main { - padding: 30px 20px; + padding: var(--space-lg) var(--space-lg); width: 100%; max-width: 1400px; margin: 0 auto; @@ -166,89 +386,331 @@ min-height: calc(100vh - 200px); } -/* Profile Menu Styles */ +/* ===== PROFILE MENU ===== */ .profile-menu-container { position: relative; - z-index: 1000; - padding-bottom: 8px; + z-index: var(--z-dropdown); + padding-bottom: var(--space-sm); flex-shrink: 0; - /* Padding creates hover bridge to dropdown */ } .profile-trigger { display: flex; align-items: center; - gap: 10px; + gap: var(--space-sm); cursor: pointer; - padding: 4px 8px; - border-radius: 4px; + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-md); + transition: background var(--transition-fast); +} + +.profile-trigger:hover { + background: var(--color-bg-secondary); } .profile-avatar { - width: 36px; - height: 36px; + width: 40px; + height: 40px; border-radius: 50%; - background-color: #333; - color: white; + background: var(--gradient-primary); + color: var(--color-text-inverse); display: flex; align-items: center; justify-content: center; - font-weight: 600; - font-size: 14px; + font-weight: var(--font-bold); + font-size: var(--text-md); flex-shrink: 0; + box-shadow: var(--shadow-glow-primary); } .profile-name { - color: #333; - font-size: 14px; - font-weight: 500; + color: var(--color-text-primary); + font-size: var(--text-base); + font-weight: var(--font-medium); white-space: nowrap; } +/* Hide profile name and entry button text on screens narrower than 1200px */ +@media (max-width: 1200px) { + .profile-name { + display: none; + } + + .entry-btn-text { + display: none; + } + + .entry-btn { + padding: 10px 12px; /* Reduce padding when text is hidden */ + min-width: auto; + } +} + +/* Hide Financial, Locations, and Services in profile dropdown by default */ +.profile-dropdown-financial, +.profile-dropdown-locations, +.profile-dropdown-services { + display: none !important; +} + +/* Explicitly hide when visible in desktop nav (above breakpoints where they move) */ +/* Above 1200px: Financial is in desktop nav for super admin, so hide from profile dropdown */ +@media (min-width: 1201px) { + .super-admin-active .profile-dropdown-financial { + display: none !important; + } +} + +/* Above 900px: Financial, Locations and Services are in desktop nav, so hide from profile dropdown */ +@media (min-width: 901px) { + .profile-dropdown-financial, + .profile-dropdown-locations, + .profile-dropdown-services { + display: none !important; + } +} + +/* Hide Financial from desktop nav and show in profile menu at 1200px for Super Admin */ +/* Only show in profile dropdown when profile menu is visible (above 768px) */ +/* This rule comes AFTER the min-width rules so it can override them */ +@media (max-width: 1200px) and (min-width: 769px) { + .super-admin-active .tabs button.financial-nav-btn, + .super-admin-active .desktop-nav button.financial-nav-btn, + .super-admin-active .tabs .financial-nav-btn, + .super-admin-active .desktop-nav .financial-nav-btn { + display: none !important; + } + + .super-admin-active .profile-dropdown-financial { + display: flex !important; + } +} + +/* Hide Financial, Locations, and Services from desktop nav and show in profile menu at 900px */ +/* Only show in profile dropdown when profile menu is visible (above 768px) */ +/* This rule comes AFTER the min-width rules so it can override them */ +@media (max-width: 900px) and (min-width: 769px) { + .tabs button.financial-nav-btn, + .desktop-nav button.financial-nav-btn, + .tabs .financial-nav-btn, + .desktop-nav .financial-nav-btn, + .tabs button.locations-nav-btn, + .desktop-nav button.locations-nav-btn, + .tabs .locations-nav-btn, + .desktop-nav .locations-nav-btn, + .tabs button.services-nav-btn, + .desktop-nav button.services-nav-btn, + .tabs .services-nav-btn, + .desktop-nav .services-nav-btn { + display: none !important; + } + + .profile-dropdown-financial, + .profile-dropdown-locations, + .profile-dropdown-services { + display: flex !important; + } +} + +/* At 768px and below, profile menu is hidden, so show items in mobile menu instead */ +/* This applies to all users */ +@media (max-width: 768px) { + /* Hide from desktop nav (all users) */ + .tabs button.financial-nav-btn, + .desktop-nav button.financial-nav-btn, + .tabs .financial-nav-btn, + .desktop-nav .financial-nav-btn, + .tabs button.locations-nav-btn, + .desktop-nav button.locations-nav-btn, + .tabs .locations-nav-btn, + .desktop-nav .locations-nav-btn, + .tabs button.services-nav-btn, + .desktop-nav button.services-nav-btn, + .tabs .services-nav-btn, + .desktop-nav .services-nav-btn { + display: none !important; + } + + /* Hide from profile dropdown (profile menu is hidden at this breakpoint) */ + .profile-dropdown-financial, + .profile-dropdown-locations, + .profile-dropdown-services { + display: none !important; + } + + /* Show in mobile menu (mobile menu is visible at this breakpoint) */ + .mobile-nav-financial, + .mobile-nav-locations, + .mobile-nav-services { + display: flex !important; + } +} + +/* Hide Financial, Locations, and Services in mobile menu by default (show only when profile menu is hidden) */ +.mobile-nav-financial, +.mobile-nav-locations, +.mobile-nav-services { + display: none; +} + .profile-dropdown-bridge { position: absolute; top: 100%; right: 0; left: 0; - height: 8px; - /* Invisible bridge to prevent mouse leave when moving to dropdown */ - z-index: 1000; + height: var(--space-sm); + z-index: var(--z-dropdown); } .profile-dropdown { position: absolute; - top: calc(100% + 8px); + top: calc(100% + var(--space-sm)); right: 0; - background: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - min-width: 160px; - padding: 4px 0; - z-index: 1001; + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + min-width: 200px; + padding: var(--space-sm) 0; + z-index: calc(var(--z-dropdown) + 1); overflow: hidden; + border: 1px solid var(--color-border); } .profile-dropdown-item { width: 100%; - padding: 10px 16px; + padding: 12px var(--space-lg); border: none; background: none; text-align: left; cursor: pointer; display: flex; align-items: center; - gap: 10px; - color: #333; - font-size: 14px; - transition: background-color 0.2s; + gap: var(--space-md); + color: var(--color-text-primary); + font-size: var(--text-base); + transition: background var(--transition-fast); } .profile-dropdown-item:hover { - background-color: #f5f5f5; + background: var(--color-bg-secondary); } .profile-dropdown-item svg { - width: 16px; - height: 16px; - color: #666; + width: 18px; + height: 18px; + color: var(--color-text-secondary); +} + +.profile-dropdown-item.active { + background: var(--color-bg-secondary); + color: var(--color-primary); + font-weight: var(--font-semibold); +} + +/* ===== SMOOTH TAB TRANSITIONS ===== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.nav-tab { + transition: opacity 0.2s ease-in, transform 0.2s ease-in; +} + +/* ===== AUTH FORMS ===== */ +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-page); + padding: var(--space-lg); +} + +.auth-box { + background: var(--color-bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + padding: var(--space-2xl); + width: 100%; + max-width: 420px; + border: 1px solid var(--color-border); +} + +.auth-box h2 { + text-align: center; + margin-bottom: var(--space-xl); + font-size: var(--text-2xl); +} + +.auth-box .form-group { + margin-bottom: var(--space-lg); +} + +.auth-box .form-group label { + display: block; + margin-bottom: var(--space-sm); + font-weight: var(--font-medium); + color: var(--color-text-primary); +} + +.auth-box .form-group input { + width: 100%; + padding: 12px var(--space-md); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); + transition: all var(--transition-fast); + background: var(--color-bg-secondary); +} + +.auth-box .form-group input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-primary); +} + +.auth-box button[type="submit"] { + width: 100%; + padding: 14px; + background: var(--gradient-primary); + color: var(--color-text-inverse); + border: none; + border-radius: var(--radius-md); + font-size: var(--text-lg); + font-weight: var(--font-semibold); + cursor: pointer; + transition: all var(--transition-normal); + box-shadow: var(--shadow-glow-primary); +} + +.auth-box button[type="submit"]:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.35); +} + +.auth-toggle { + text-align: center; + margin-top: var(--space-lg); + color: var(--color-text-secondary); +} + +.auth-toggle button { + background: none; + border: none; + color: var(--color-primary); + font-weight: var(--font-medium); + cursor: pointer; + text-decoration: underline; +} + +.auth-toggle button:hover { + color: var(--color-primary-dark); } diff --git a/src/App.jsx b/src/App.jsx index 1abfcf9..9ce9fa0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; -import { FaPlus, FaList, FaMapMarkerAlt, FaCut, FaUser, FaSignOutAlt, FaChartLine, FaEnvelope, FaUserShield, FaArrowLeft, FaEye, FaCrown } from 'react-icons/fa'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { FaPlus, FaList, FaMapMarkerAlt, FaCut, FaUser, FaSignOutAlt, FaChartLine, FaEnvelope, FaUserShield, FaArrowLeft, FaEye, FaCrown, FaBars, FaTimes } from 'react-icons/fa'; import { useAuth } from './contexts/AuthContext'; import EntryForm from './components/EntryForm'; import AppointmentsList from './components/AppointmentsList'; @@ -17,9 +18,28 @@ import './App.css'; import { API_BASE } from './config.js'; function App() { + const navigate = useNavigate(); + const location = useLocation(); const { isAuthenticated, loading, user, logout, getAuthHeaders, isSuperAdmin } = useAuth(); const [impersonation, setImpersonation] = useState(null); const [userPlan, setUserPlan] = useState(null); + const [isLoadingPlan, setIsLoadingPlan] = useState(true); + + // Map URL paths to tab names + const pathToTab = { + '/entry': 'entry', + '/appointments': 'list', + '/locations': 'locations', + '/services': 'services', + '/financial': 'financial', + '/super-admin': 'super-admin', + '/invoice': 'invoice', + '/email-logs': 'email-logs', + '/my-plan': 'my-plan' + }; + + // Get active tab from URL + const activeTab = pathToTab[location.pathname] || 'list'; // Check for impersonation data on mount useEffect(() => { @@ -32,24 +52,56 @@ function App() { // Fetch user's subscription plan useEffect(() => { const fetchUserPlan = async () => { - if (!isAuthenticated) return; + if (!isAuthenticated) { + setUserPlan(null); + setIsLoadingPlan(false); + return; + } + setIsLoadingPlan(true); try { const response = await fetch(`${API_BASE}/subscriptions/usage`, { headers: getAuthHeaders() }); if (response.ok) { const data = await response.json(); - setUserPlan(data.plan); + console.log('[App.jsx] Subscription usage data:', data); + console.log('[App.jsx] Plan data:', data.plan); + if (data.plan) { + setUserPlan(data.plan); + } else { + console.warn('[App.jsx] No plan data in response'); + setUserPlan(null); + } + } else { + const errorText = await response.text(); + console.error('[App.jsx] Failed to fetch subscription usage:', response.status, response.statusText, errorText); + setUserPlan(null); } } catch (err) { - console.error('Error fetching user plan:', err); + console.error('[App.jsx] Error fetching user plan:', err); + setUserPlan(null); + } finally { + setIsLoadingPlan(false); } }; fetchUserPlan(); }, [isAuthenticated]); - // Check if user has access to paid features - const hasPaidPlan = userPlan && userPlan.name !== 'Free'; + // Check if user has access to paid features (case-insensitive check) + // First check user object from auth (available immediately), then fall back to userPlan + const planName = user?.plan_name || (userPlan && userPlan.name) || 'free'; + const hasPaidPlan = planName && planName.toLowerCase() !== 'free'; + + // Debug logging + useEffect(() => { + console.log('[App.jsx] User object:', user); + console.log('[App.jsx] Plan name from user:', user?.plan_name); + console.log('[App.jsx] User plan state:', userPlan); + console.log('[App.jsx] Final plan name:', planName); + console.log('[App.jsx] Has paid plan?', hasPaidPlan); + console.log('[App.jsx] Is super admin?', isSuperAdmin); + console.log('[App.jsx] Will show Financial tab?', hasPaidPlan || isSuperAdmin); + }, [user, userPlan, planName, hasPaidPlan, isSuperAdmin]); // Return to original admin account const returnToAdmin = () => { @@ -75,14 +127,23 @@ function App() { console.log('isSuperAdmin:', isSuperAdmin); } }, [user, isSuperAdmin]); - const [activeTab, setActiveTab] = useState('list'); + + // Redirect to /appointments if on root path + useEffect(() => { + if (isAuthenticated && location.pathname === '/') { + navigate('/appointments', { replace: true }); + } + }, [isAuthenticated, location.pathname, navigate]); + const [refreshTrigger, setRefreshTrigger] = useState(0); const [newAppointmentIds, setNewAppointmentIds] = useState(null); const [pageTitle, setPageTitle] = useState("HairManager"); const [invoiceAppointments, setInvoiceAppointments] = useState(null); const [profileSettings, setProfileSettings] = useState(null); const [showProfileMenu, setShowProfileMenu] = useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const profileMenuRef = useRef(null); + const mobileMenuRef = useRef(null); useEffect(() => { if (isAuthenticated) { @@ -92,10 +153,10 @@ function App() { // Refetch profile settings when Profile tab becomes active useEffect(() => { - if (activeTab === 'admin' && isAuthenticated) { + if (location.pathname === '/admin' && isAuthenticated) { fetchProfileSettings(); } - }, [activeTab, isAuthenticated]); + }, [location.pathname, isAuthenticated]); // Close profile menu when clicking outside useEffect(() => { @@ -103,16 +164,25 @@ function App() { if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) { setShowProfileMenu(false); } + if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target) && + !event.target.closest('.mobile-menu-toggle')) { + setIsMobileMenuOpen(false); + } }; - if (showProfileMenu) { + if (showProfileMenu || isMobileMenuOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [showProfileMenu]); + }, [showProfileMenu, isMobileMenuOpen]); + + // Close mobile menu when navigating + useEffect(() => { + setIsMobileMenuOpen(false); + }, [location.pathname]); const fetchProfileSettings = async () => { try { @@ -172,18 +242,18 @@ function App() { const handleAppointmentsAdded = (newIds) => { setNewAppointmentIds(newIds); setRefreshTrigger(prev => prev + 1); - setActiveTab('list'); + navigate('/appointments'); }; const handleCreateInvoice = (appointments) => { console.log('handleCreateInvoice called with appointments:', appointments); setInvoiceAppointments(appointments); - setActiveTab('invoice'); - console.log('Active tab set to invoice'); + navigate('/invoice'); + console.log('Navigated to invoice'); }; return ( -
+
{impersonation && (
@@ -201,146 +271,274 @@ function App() {

{pageTitle}

-
handleSort('price')} style={{ width: columnWidths.price, position: 'relative' }} > @@ -1205,39 +1786,39 @@ function AppointmentsList({ refreshTrigger, newAppointmentIds, onCreateInvoice } onMouseDown={(e) => handleMouseDown(e, 'price')} >
- handleSort('distance')} - style={{ width: columnWidths.distance, position: 'relative' }} - > - Distance {sortConfig.column === 'distance' && (sortConfig.direction === 'asc' ? '↑' : '↓')} -
handleMouseDown(e, 'distance')} - >
- - handleSort('paid')} - style={{ width: columnWidths.paid, position: 'relative' }} - > - Paid {sortConfig.column === 'paid' && (sortConfig.direction === 'asc' ? '↑' : '↓')} -
handleMouseDown(e, 'paid')} - >
- - handleSort('payment_date')} - style={{ width: columnWidths.payment_date, position: 'relative' }} - > - Payment Date {sortConfig.column === 'payment_date' && (sortConfig.direction === 'asc' ? '↑' : '↓')} -
handleMouseDown(e, 'payment_date')} - >
- + handleSort('distance')} + style={{ width: columnWidths.distance, position: 'relative' }} + > + Distance {sortConfig.column === 'distance' && (sortConfig.direction === 'asc' ? '↑' : '↓')} +
handleMouseDown(e, 'distance')} + >
+ + handleSort('paid')} + style={{ width: columnWidths.paid, position: 'relative' }} + > + Paid {sortConfig.column === 'paid' && (sortConfig.direction === 'asc' ? '↑' : '↓')} +
handleMouseDown(e, 'paid')} + >
+ + handleSort('payment_date')} + style={{ width: columnWidths.payment_date, position: 'relative' }} + > + Payment Date {sortConfig.column === 'payment_date' && (sortConfig.direction === 'asc' ? '↑' : '↓')} +
handleMouseDown(e, 'payment_date')} + >
+ {adminMode && ( Actions @@ -1253,20 +1834,334 @@ function AppointmentsList({ refreshTrigger, newAppointmentIds, onCreateInvoice } handleFilterChange('id', e.target.value)} + placeholder="Go to appointment #..." + value={goToId} + onChange={handleGoToIdChange} className="filter-input" /> - handleFilterChange('date', e.target.value)} - className="filter-input" - /> +
+ + {showDatePicker && ( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: `${datePickerPosition.top}px`, + left: `${datePickerPosition.left}px` + }} + > +
+ Select Date + +
+
+ + + +
+
+ {dateFilterMode === 'day' && ( +
+
+ + + +
+
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
Su
+
+
+ {generateCalendarDays(calendarView.month, calendarView.year).map((dateInfo, index) => { + const { day, month, year, isCurrentMonth } = dateInfo; + const dayIsToday = isToday(day, month, year); + const dayIsSelected = isSelected(day, month, year); + + return ( + + ); + })} +
+
+ )} + {dateFilterMode === 'month' && ( +
+
+ + + +
+
+ {[ + 'January', 'February', 'March', 'April', + 'May', 'June', 'July', 'August', + 'September', 'October', 'November', 'December' + ].map((monthName, index) => { + const monthValue = `${monthPickerYear}-${String(index + 1).padStart(2, '0')}`; + const isSelected = dateFilterMonth === monthValue; + + return ( + + ); + })} +
+
+ )} + {dateFilterMode === 'year' && ( +
+
+ +
+ {yearPickerDecade}s +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => { + const year = yearPickerDecade + i; + const isSelected = dateFilterYear === year.toString(); + + return ( + + ); + })} +
+
+ )} +
+
+ +
+
+ + {dateFilterMode === 'day' && ( + + )} +
+
+ )} +
- + - + - + - + - + Unpaid - + + { appointmentRowRefs.current[apt.id] = el; }} + className={`${apt.paid ? 'paid' : 'unpaid'} ${adminMode ? 'admin-mode' : ''} ${invoiceMode && selectedForInvoice.includes(apt.id) ? 'selected-for-invoice' : ''} ${calculatorMode && selectedForCalculator.has(apt.id) ? 'selected-for-calculator' : ''} ${isNew ? 'new-appointment' : ''}`} + > {(invoiceMode || calculatorMode) && (
invoiceMode ? handleInvoiceToggle(apt.id) : handleCalculatorToggle(apt.id)} + readOnly + onClick={(e) => { + e.stopPropagation(); + // onClick should have shiftKey - pass the event + invoiceMode ? handleInvoiceToggle(apt.id, e) : handleCalculatorToggle(apt.id, e); + }} className="invoice-checkbox" /> {invoiceMode && selectedForInvoice.includes(apt.id) && ( @@ -1426,7 +2330,7 @@ function AppointmentsList({ refreshTrigger, newAppointmentIds, onCreateInvoice } )} handleCellClick(apt.id, 'service')} style={{ width: columnWidths.service }} > @@ -1459,9 +2363,9 @@ function AppointmentsList({ refreshTrigger, newAppointmentIds, onCreateInvoice } apt.service )} - {editValues[apt.id]?.type ?? apt.type} + {editValues[apt.id]?.type ?? apt.type} handleCellClick(apt.id, 'location')} style={{ width: columnWidths.location }} > @@ -1488,7 +2392,7 @@ function AppointmentsList({ refreshTrigger, newAppointmentIds, onCreateInvoice } )} handleCellClick(apt.id, 'price')} style={{ width: columnWidths.price }} > @@ -1522,7 +2426,7 @@ function AppointmentsList({ refreshTrigger, newAppointmentIds, onCreateInvoice } )} handleCellClick(apt.id, 'distance')} style={{ width: columnWidths.distance }} > @@ -1563,7 +2467,7 @@ function AppointmentsList({ refreshTrigger, newAppointmentIds, onCreateInvoice } {apt.paid ? '✓ Paid' : 'Unpaid'} - {apt.payment_date ? formatDate(apt.payment_date) : '-'} + {apt.payment_date ? formatDate(apt.payment_date) : '-'} {adminMode && ( {hasChanges && ( diff --git a/src/components/EmailLogs.css b/src/components/EmailLogs.css index 58f3146..30355e8 100644 --- a/src/components/EmailLogs.css +++ b/src/components/EmailLogs.css @@ -1,10 +1,10 @@ .email-logs { - padding: 30px; + padding: var(--space-xl) var(--space-lg); max-width: 1400px; margin: 0 auto; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: var(--color-bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-md); } .email-logs-header { @@ -23,89 +23,90 @@ /* Make all EmailLogs button styles scoped to .email-logs to avoid conflicts */ .email-logs .admin-btn { padding: 8px 16px; - background: #424242; - color: white; - border: none; - border-radius: 20px; + background: var(--color-bg-primary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; - font-weight: 500; + font-size: var(--text-sm); + font-weight: var(--font-medium); display: flex; align-items: center; gap: 6px; - transition: all 0.2s; - height: 35px; + transition: all var(--transition-fast); + height: 36px; box-sizing: border-box; } .email-logs .admin-btn:hover { - background: #616161; + background: var(--color-bg-secondary); + border-color: var(--color-border-dark); } .email-logs .admin-btn svg { - color: #00BCD4; + color: var(--color-info); } .email-logs .admin-btn:hover svg { - color: #00BCD4; + color: var(--color-info-hover); } .email-logs .admin-btn.active { - background: #424242; + background: var(--color-text-primary); + color: white; + border-color: transparent; } .email-logs .admin-btn.active svg { - color: #00BCD4; + color: var(--color-info); } .email-logs .delete-btn { padding: 8px 16px; - background: #f44336; + background: var(--gradient-danger); color: white; border: none; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; - font-weight: 500; + font-size: var(--text-sm); + font-weight: var(--font-semibold); display: flex; align-items: center; gap: 6px; - transition: all 0.2s; + transition: all var(--transition-fast); white-space: nowrap; min-width: fit-content; width: auto; - height: 35px; + height: 36px; box-sizing: border-box; - box-shadow: 0 2px 4px rgba(244, 67, 54, 0.3); + box-shadow: var(--shadow-glow-danger); } .email-logs .delete-btn:hover { - background: #d32f2f; - box-shadow: 0 3px 6px rgba(244, 67, 54, 0.4); + box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); transform: translateY(-1px); } .email-logs .delete-btn-small { - padding: 6px 12px; - background: #f44336; + padding: 0; + background: var(--gradient-danger); color: white; border: none; - border-radius: 8px; + border-radius: var(--radius-sm); cursor: pointer; - font-size: 14px; - font-weight: 500; + font-size: var(--text-sm); + font-weight: var(--font-medium); display: flex; align-items: center; justify-content: center; - transition: all 0.2s; - min-width: 32px; - height: 32px; - box-shadow: 0 2px 4px rgba(244, 67, 54, 0.3); + transition: all var(--transition-fast); + width: 30px; + height: 30px; + box-shadow: var(--shadow-sm); } .email-logs .delete-btn-small:hover { - background: #d32f2f; - box-shadow: 0 3px 6px rgba(244, 67, 54, 0.4); + box-shadow: 0 3px 10px rgba(239, 68, 68, 0.35); transform: translateY(-1px); } @@ -116,39 +117,44 @@ } .email-logs .selected-row { - background-color: #fff3cd !important; + background-color: var(--color-warning-light) !important; } .email-logs-header h2 { margin: 0; - color: #333; + color: var(--color-text-primary); + font-size: 26px; + font-weight: 700; + letter-spacing: -0.5px; } .email-logs .refresh-btn { padding: 0; - background: #00BCD4; + background: var(--gradient-cyan); color: white; border: none; - border-radius: 20px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; - width: 40px; - height: 40px; + font-size: var(--text-sm); + width: 36px; + height: 36px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; white-space: nowrap; - transition: all 0.2s; + transition: all var(--transition-fast); + box-shadow: 0 2px 8px rgba(6, 182, 212, 0.2); } .email-logs .refresh-btn:hover { - background: #00ACC1; + box-shadow: 0 4px 14px rgba(6, 182, 212, 0.35); + transform: translateY(-1px); } .email-logs .refresh-btn svg { - width: 14px; - height: 14px; + width: 16px; + height: 16px; color: white; } @@ -167,45 +173,57 @@ } .filter-group label { - font-size: 12px; - color: #666; - font-weight: 500; + font-size: var(--text-xs); + color: var(--color-text-secondary); + font-weight: var(--font-medium); } .filter-group input, .filter-group select { padding: 6px 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + background: var(--color-bg-primary); + transition: border-color var(--transition-fast); +} + +.filter-group input:focus, +.filter-group select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); } .email-logs .clear-filters-btn { - padding: 6px 12px; - background: #f5f5f5; - color: #333; - border: 1px solid #ddd; - border-radius: 8px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--color-bg-primary); + color: var(--color-danger); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); cursor: pointer; - font-size: 14px; - font-weight: 500; + transition: all var(--transition-fast); align-self: flex-end; - transition: all 0.2s; } .email-logs .clear-filters-btn:hover { - background: #e0e0e0; - border-color: #bbb; - transform: translateY(-1px); + background: var(--color-bg-secondary); + border-color: var(--color-danger); } .email-logs-stats { display: flex; gap: 20px; - margin-bottom: 20px; - padding: 15px; - background: #f5f5f5; - border-radius: 4px; + margin-bottom: 24px; + padding: 20px; + background: var(--color-bg-secondary); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); } .stat { @@ -215,50 +233,56 @@ } .stat-label { - font-size: 12px; - color: #666; - font-weight: 500; + font-size: var(--text-xs); + color: var(--color-text-secondary); + font-weight: var(--font-medium); + text-transform: uppercase; + letter-spacing: 0.5px; } .stat-value { - font-size: 24px; - font-weight: 600; - color: #333; + font-size: var(--text-2xl); + font-weight: var(--font-bold); + color: var(--color-text-primary); } .email-logs-table-container { overflow-x: auto; - border: 1px solid #ddd; - border-radius: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); } .email-logs-table { width: 100%; - min-width: 1200px; /* Minimum width to accommodate all columns, especially in admin mode (50+80+100+220+300+100+150+150+100+100 = 1250px) */ + min-width: 1200px; border-collapse: collapse; - background: white; - table-layout: fixed; /* Fixed layout prevents column width changes */ + background: var(--color-bg-primary); + table-layout: fixed; } .email-logs-table thead { - background: #f5f5f5; + background: var(--color-bg-secondary); position: sticky; top: 0; } .email-logs-table th { - padding: 12px; + padding: 14px 12px; text-align: left; - font-weight: 600; - color: #333; - border-bottom: 2px solid #ddd; + font-weight: var(--font-semibold); + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.5px; } .sortable-header:hover { - background: #e8e8e8; + background: var(--color-bg-tertiary); } /* Fixed column widths - all columns must have explicit widths for table-layout: fixed */ @@ -319,16 +343,17 @@ } .email-logs-table td { - padding: 12px; - border-bottom: 1px solid #eee; - font-size: 14px; + padding: 14px 12px; + border-bottom: 1px solid var(--color-border-light); + font-size: var(--text-base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--color-text-primary); } .email-logs-table tbody tr:hover { - background: #f9f9f9; + background: var(--color-bg-secondary); } .status-badge { @@ -369,13 +394,13 @@ } .error-message { - color: #F44336; - font-size: 12px; + color: var(--color-danger); + font-size: var(--text-xs); cursor: help; } .pdf-link { - color: #2196F3; + color: var(--color-info); text-decoration: none; font-size: 14px; } @@ -399,7 +424,7 @@ } .error { - color: #F44336; + color: var(--color-danger); } .expand-btn { @@ -417,16 +442,16 @@ } .expanded-row { - background: #f0f7ff; + background: var(--color-info-light); } .expanded-details-row { - background: #f9f9f9; + background: var(--color-bg-secondary); } .expanded-details-cell { padding: 20px !important; - background: #f9f9f9; + background: var(--color-bg-secondary); width: 100%; min-width: 100%; white-space: normal; /* Allow wrapping in expanded details */ @@ -473,8 +498,8 @@ } .webhook-event-type { - font-weight: 600; - color: #2196F3; + font-weight: var(--font-semibold); + color: var(--color-info); text-transform: uppercase; font-size: 13px; } @@ -490,8 +515,8 @@ .webhook-event-details summary { cursor: pointer; - color: #2196F3; - font-size: 13px; + color: var(--color-info); + font-size: var(--text-sm); user-select: none; } @@ -500,8 +525,8 @@ } .webhook-json { - background: #f5f5f5; - border: 1px solid #ddd; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); border-radius: 4px; padding: 12px; overflow-x: auto; @@ -530,8 +555,8 @@ .latest-webhook-data summary { cursor: pointer; - color: #2196F3; - font-size: 13px; + color: var(--color-info); + font-size: var(--text-sm); user-select: none; } diff --git a/src/components/EntryForm.css b/src/components/EntryForm.css index 0bc636e..4cbde19 100644 --- a/src/components/EntryForm.css +++ b/src/components/EntryForm.css @@ -1,28 +1,51 @@ .entry-form { - max-width: 900px; + max-width: 1400px; margin: 0 auto; - padding: 30px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--space-xl) var(--space-lg); + color: var(--color-text-primary); overflow: visible; } .entry-form h2 { margin-top: 0; - margin-bottom: 18px; - color: #333; - font-size: 24px; - font-weight: 600; + margin-bottom: 8px; + color: var(--color-text-primary); + font-size: var(--text-2xl); + font-weight: var(--font-bold); + letter-spacing: -0.5px; +} + +.entry-form .page-subtitle { + color: var(--color-text-secondary); + margin: 0 0 var(--space-lg) 0; + font-size: var(--text-base); } .form-header { display: grid; grid-template-columns: 1fr 1fr; - gap: 15px; - margin-bottom: 20px; - padding-bottom: 18px; - border-bottom: 2px solid #f0f0f0; + gap: 24px; + margin-bottom: 24px; + padding: 0; + background: transparent; + border-radius: 0; + border: none; + box-shadow: none; +} + +.form-header .form-group { + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); + padding: 0; + overflow: hidden; + transition: all var(--transition-fast); +} + +.form-header .form-group:hover { + border-color: var(--color-primary); + box-shadow: var(--shadow-md); } .form-group { @@ -40,39 +63,88 @@ font-size: 16px; } +.form-header .form-group label { + display: flex; + align-items: center; + margin-bottom: 0; + padding: 14px 20px; + font-weight: var(--font-semibold); + color: var(--color-text-inverse); + font-size: var(--text-sm); + background: var(--gradient-primary); + border-bottom: 1px solid var(--color-primary); +} + +.form-header .form-group label .label-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.2); + border-radius: var(--radius-sm); + margin-right: 10px; + font-size: 14px; +} + .form-group label { display: flex; align-items: center; - margin-bottom: 6px; - font-weight: 600; - color: #333; - font-size: 13px; + margin-bottom: var(--space-sm); + font-weight: var(--font-semibold); + color: var(--color-text-primary); + font-size: var(--text-sm); +} + +.form-header .form-group input, +.form-header .form-group select { + width: 100%; + padding: 14px 20px; + border: none; + border-radius: 0; + font-size: var(--text-base); + box-sizing: border-box; + transition: all var(--transition-fast); + background: var(--color-bg-primary); + height: 52px; +} + +.form-header .form-group input:hover, +.form-header .form-group select:hover { + background: var(--color-bg-secondary); +} + +.form-header .form-group input:focus, +.form-header .form-group select:focus { + outline: none; + background: var(--color-bg-secondary); + box-shadow: inset 0 0 0 2px var(--color-primary-light); } .form-group input, .form-group select { width: 100%; - padding: 10px 12px; - border: 2px solid #e0e0e0; - border-radius: 6px; - font-size: 14px; + padding: 11px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); box-sizing: border-box; - transition: all 0.2s; - background: white; + transition: all var(--transition-fast); + background: var(--color-bg-secondary); height: 43px; } .form-group input:hover, .form-group select:hover { - border-color: #bdbdbd; + border-color: var(--color-border-dark); } .form-group input:focus, .form-group select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } .location-select, @@ -84,25 +156,31 @@ .appointments-section { margin: 20px 0; overflow: visible; + width: 100%; } .section-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 15px; - padding-bottom: 12px; - border-bottom: 2px solid #f0f0f0; + margin-bottom: 20px; + padding: 16px 20px; + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); + flex-wrap: wrap; + gap: 12px; } .section-header h3 { margin: 0; - color: #333; - font-size: 18px; - font-weight: 600; + color: var(--color-text-primary); + font-size: var(--text-xl); + font-weight: var(--font-semibold); display: flex; align-items: center; - gap: 8px; + gap: 10px; } .section-icon { @@ -113,29 +191,30 @@ display: flex; gap: 12px; align-items: center; + flex-wrap: wrap; } .appointment-count { - color: #666; - font-size: 14px; - font-weight: 600; - background: #f5f5f5; + color: var(--color-text-secondary); + font-size: var(--text-sm); + font-weight: var(--font-semibold); + background: var(--color-bg-tertiary); padding: 5px 12px; - border-radius: 4px; - border: 1px solid #e0e0e0; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); } .total-price { - color: #2c5f2d; - font-size: 14px; - font-weight: 600; - background: #e8f5e9; + color: var(--color-success-hover); + font-size: var(--text-sm); + font-weight: var(--font-semibold); + background: var(--color-success-light); padding: 5px 12px; - border-radius: 4px; - border: 1px solid #c8e6c9; + border-radius: var(--radius-sm); + border: 1px solid var(--color-success); } -.appointments-list { +.entry-form .appointments-list { margin-bottom: 15px; position: relative; z-index: 0; @@ -143,23 +222,26 @@ max-width: 100%; box-sizing: border-box; overflow: visible; + padding: 0 !important; } -.appointments-header { +.entry-form .appointments-header { display: flex; gap: 12px; align-items: center; - padding: 10px 14px; - background: #f5f5f5; - border-radius: 8px 8px 0 0; - border: 2px solid #e8e8e8; + padding: 14px 20px; + background: var(--gradient-primary) !important; + border-radius: var(--radius-md) var(--radius-md) 0 0; + border: 1px solid var(--color-primary); border-bottom: none; - font-weight: 600; - font-size: 13px; - color: #333; + font-weight: var(--font-semibold); + font-size: var(--text-xs); + color: var(--color-text-inverse) !important; margin-bottom: 0; width: 100%; box-sizing: border-box; + text-transform: uppercase; + letter-spacing: 0.5px; } .header-number { @@ -168,16 +250,18 @@ } .header-client-name { - flex: 1; + flex: 1 1 0; + min-width: 0; } .header-service { - flex: 1; + flex: 1 1 0; + min-width: 0; } .header-price { - flex: 0 0 120px; - min-width: 120px; + flex: 0 0 100px; + min-width: 100px; } .header-actions { @@ -189,12 +273,11 @@ display: flex; gap: 12px; align-items: center; - padding: 14px; - background: white; - border: 2px solid #e8e8e8; - border: 2px solid #e8e8e8; - border: 2px solid #e8e8e8; - transition: all 0.2s; + padding: 14px 20px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-top: none; + transition: all var(--transition-fast); position: relative; margin-top: 0; width: 100%; @@ -219,17 +302,17 @@ } .appointment-row:hover { - border-color: #4CAF50; - box-shadow: 0 2px 8px rgba(76, 175, 80, 0.1); + border-color: var(--color-primary); + box-shadow: 0 2px 8px var(--color-primary-light); } .appointment-row:focus-within { - border-left-color: #4CAF50 !important; - border-right-color: #4CAF50 !important; - border-bottom-color: #4CAF50 !important; - border-top-color: #4CAF50 !important; - border-top: 2px solid #4CAF50 !important; - box-shadow: 0 2px 8px rgba(76, 175, 80, 0.1); + border-left-color: var(--color-primary) !important; + border-right-color: var(--color-primary) !important; + border-bottom-color: var(--color-primary) !important; + border-top-color: var(--color-primary) !important; + border-top: 2px solid var(--color-primary) !important; + box-shadow: 0 2px 8px var(--color-primary-light); } .appointment-number { @@ -239,66 +322,67 @@ display: flex; align-items: center; justify-content: center; - background: #4CAF50; - color: white; + background: var(--gradient-primary); + color: var(--color-text-inverse); border-radius: 50%; - font-weight: 600; - font-size: 13px; + font-weight: var(--font-semibold); + font-size: var(--text-sm); + box-shadow: var(--shadow-sm); } .appointment-client-name { - flex: 1; + flex: 1 1 0; display: flex; flex-direction: column; min-width: 0; } .appointment-service { - flex: 1; + flex: 1 1 0; min-width: 0; } .appointment-price { - flex: 0 0 120px; - min-width: 120px; + flex: 0 0 100px; + min-width: 100px; } .price-input { width: 100%; - padding: 10px 12px; - border: 2px solid #e0e0e0; - border-radius: 6px; - font-size: 14px; + padding: 11px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); font-family: inherit; box-sizing: border-box; - transition: all 0.2s; - background: white; + transition: all var(--transition-fast); + background: var(--color-bg-secondary); height: 42px; } .price-input:hover { - border-color: #bdbdbd; + border-color: var(--color-border-dark); } .price-input:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } .appointment-service select { width: 100%; - padding: 10px 12px; + padding: 11px 14px; padding-right: 30px; - border: 2px solid #e0e0e0; - border-radius: 6px; - font-size: 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); font-family: inherit; box-sizing: border-box; - transition: all 0.2s; - background: white; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + transition: all var(--transition-fast); + background: var(--color-bg-secondary); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 12px; @@ -314,14 +398,14 @@ } .appointment-service select:hover { - border-color: #bdbdbd; + border-color: var(--color-border-dark); } .appointment-service select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } .remove-btn { @@ -329,26 +413,28 @@ width: 36px; height: 36px; padding: 0; - background: #f44336; - color: white; + background: var(--gradient-danger); + color: var(--color-text-inverse); border: none; - border-radius: 6px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; + font-size: var(--text-base); display: flex; align-items: center; justify-content: center; - transition: all 0.2s; + transition: all var(--transition-fast); + box-shadow: var(--shadow-sm); } .remove-btn:hover:not(:disabled) { - background: #d32f2f; - transform: scale(1.05); + background: var(--color-danger-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-glow-danger); } .remove-btn:disabled { - background: #e0e0e0; - color: #bdbdbd; + background: var(--color-bg-tertiary); + color: var(--color-text-muted); cursor: not-allowed; opacity: 0.5; } @@ -356,26 +442,25 @@ .add-appointment-btn { width: 100%; padding: 14px; - background: #2196F3; - color: white; + background: var(--gradient-info); + color: var(--color-text-inverse); border: none; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 16px; - font-weight: 600; + font-size: var(--text-lg); + font-weight: var(--font-semibold); display: flex; align-items: center; justify-content: center; - gap: 8px; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(33, 150, 243, 0.2); - margin-bottom: 20px; + gap: var(--space-sm); + transition: all var(--transition-normal); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25); + margin-bottom: var(--space-lg); } .add-appointment-btn:hover { - background: #1976D2; transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(33, 150, 243, 0.3); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.35); } .entry-form .submit-btn, @@ -384,25 +469,24 @@ form .submit-btn, form button.submit-btn { width: 100%; padding: 14px; - background: #4CAF50 !important; - color: white !important; + background: var(--gradient-success) !important; + color: var(--color-text-inverse) !important; border: none !important; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 16px; - font-weight: 600; - margin-top: 20px; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(76, 175, 80, 0.2); + font-size: var(--text-lg); + font-weight: var(--font-semibold); + margin-top: var(--space-lg); + transition: all var(--transition-normal); + box-shadow: var(--shadow-glow-success); } .entry-form .submit-btn:hover:not(:disabled), .entry-form button.submit-btn:hover:not(:disabled), form .submit-btn:hover:not(:disabled), form button.submit-btn:hover:not(:disabled) { - background: #45a049 !important; transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(76, 175, 80, 0.4); + box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4) !important; } .submit-btn:disabled { @@ -413,21 +497,25 @@ form button.submit-btn:hover:not(:disabled) { } .error-message { - padding: 12px; - background: #ffebee; - color: #c62828; - border-radius: 4px; - margin: 15px 0; - border-left: 4px solid #c62828; + padding: var(--space-md) var(--space-lg); + background: var(--color-danger-light); + color: var(--color-danger-hover); + border-radius: var(--radius-md); + margin: var(--space-md) 0; + border-left: 4px solid var(--color-danger); + font-size: var(--text-sm); + font-weight: var(--font-medium); } .success-message { - padding: 12px; - background: #e8f5e9; - color: #2e7d32; - border-radius: 4px; - margin: 12px 0; - border-left: 4px solid #2e7d32; + padding: var(--space-md) var(--space-lg); + background: var(--color-success-light); + color: var(--color-success); + border-radius: var(--radius-md); + margin: var(--space-md) 0; + border-left: 4px solid var(--color-success); + font-size: var(--text-sm); + font-weight: var(--font-medium); } /* Autocomplete styles */ @@ -447,55 +535,56 @@ form button.submit-btn:hover:not(:disabled) { .autocomplete-input { width: 100%; - padding: 10px 12px; - border: 2px solid #e0e0e0; - border-radius: 6px; - font-size: 14px; + padding: 11px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); font-family: inherit; box-sizing: border-box; - transition: all 0.2s; - background: white; + transition: all var(--transition-fast); + background: var(--color-bg-secondary); height: 42px; } .autocomplete-input:hover { - border-color: #bdbdbd; + border-color: var(--color-border-dark); } .autocomplete-input:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } .autocomplete-suggestions { position: fixed !important; z-index: 99999 !important; - background: white; - border: 2px solid #4CAF50; - border-radius: 6px; + background: var(--color-bg-primary); + border: 2px solid var(--color-primary); + border-radius: var(--radius-md); max-height: 200px; overflow-y: auto; margin: 0; padding: 0; list-style: none; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-lg); margin-top: 2px; } .autocomplete-suggestion { - padding: 10px 12px; + padding: 10px 14px; cursor: pointer; - border-bottom: 1px solid #f0f0f0; - font-size: 14px; - transition: background 0.15s; + border-bottom: 1px solid var(--color-border-light); + font-size: var(--text-base); + transition: background var(--transition-fast); } .autocomplete-suggestion:hover, .autocomplete-suggestion.selected { - background: #e8f5e9; - font-weight: 600; + background: var(--color-primary-light); + color: var(--color-primary-dark); + font-weight: var(--font-semibold); } .autocomplete-suggestion:last-child { @@ -508,14 +597,14 @@ form button.submit-btn:hover:not(:disabled) { } .autocomplete-group-header { - padding: 8px 12px; - background: #f5f5f5; - font-weight: 600; - font-size: 12px; - color: #666; + padding: 8px 14px; + background: var(--color-bg-secondary); + font-weight: var(--font-semibold); + font-size: var(--text-xs); + color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; - border-bottom: 2px solid #e0e0e0; + border-bottom: 1px solid var(--color-border); cursor: default; position: sticky; top: 0; @@ -523,31 +612,64 @@ form button.submit-btn:hover:not(:disabled) { } .autocomplete-group-header:hover { - background: #f5f5f5; + background: var(--color-bg-secondary); +} + +/* Field labels for stacked layout */ +.field-label-stacked { + display: none; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--color-text-primary); + margin-bottom: 6px; } /* Responsive design */ -@media (max-width: 768px) { - .entry-form { - padding: 15px; +/* Stack rows when layout changes to vertical */ +@media (max-width: 1000px) { + .appointment-row { + flex-direction: column; + gap: 15px; + align-items: stretch; + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + margin-bottom: 12px; + border-top: 2px solid var(--color-border); + padding: 16px; } - .form-header { - grid-template-columns: 1fr; - gap: 15px; + .field-label-stacked { + display: block; } - .appointments-header { - display: none; + .appointment-client-name, + .appointment-service, + .appointment-price { + display: flex; + flex-direction: column; } +} - .appointment-row { +@media (max-width: 900px) { + .section-header { flex-direction: column; + align-items: flex-start; + } + + .section-stats { + width: 100%; + margin-top: 8px; + } +} + +@media (max-width: 768px) { + .entry-form { + padding: 15px; + } + + .form-header { + grid-template-columns: 1fr; gap: 15px; - align-items: stretch; - border: 2px solid #e8e8e8; - border-radius: 8px; - margin-bottom: 12px; } .appointment-row:first-child { diff --git a/src/components/EntryForm.jsx b/src/components/EntryForm.jsx index 8a59b78..c9a4787 100644 --- a/src/components/EntryForm.jsx +++ b/src/components/EntryForm.jsx @@ -425,6 +425,7 @@ function EntryForm({ onAppointmentsAdded }) { return (

New Appointment Entry

+

Add new client appointments and services

{ // Prevent Enter key from submitting form @@ -503,6 +504,7 @@ function EntryForm({ onAppointmentsAdded }) { >
{index + 1}
+
+
+ 0) { - const mostRecent = data.financialYear[data.financialYear.length - 1].key; - setExpandedFinancialYears(new Set([mostRecent])); - } - if (data.calendarYear && data.calendarYear.length > 0) { - const mostRecent = data.calendarYear[data.calendarYear.length - 1].key; - setExpandedCalendarYears(new Set([mostRecent])); - } setIsInitialLoad(false); } } catch (err) { @@ -207,6 +199,48 @@ function Financial() { } const monthOrder = ['Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar']; + const calendarMonthOrder = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + // Helper functions to determine current periods + const getCurrentMonth = () => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return months[new Date().getMonth()]; + }; + + const getCurrentYear = () => { + return new Date().getFullYear().toString(); + }; + + const getCurrentFinancialYear = () => { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); // 0-11, where 0 is January + + // UK financial year runs from April (month 3) to March + // If month is Jan-Mar (0-2), financial year is previous year to current year + // If month is Apr-Dec (3-11), financial year is current year to next year + if (month < 3) { + return `${year - 1}-${year}`; + } else { + return `${year}-${year + 1}`; + } + }; + + const isCurrentMonth = (month) => { + return month === getCurrentMonth(); + }; + + const isCurrentYear = (year) => { + return year.toString() === getCurrentYear(); + }; + + const isCurrentFinancialYear = (fyKey) => { + return fyKey === getCurrentFinancialYear(); + }; + + const CurrentBadge = () => ( + Current + ); return (
@@ -260,25 +294,28 @@ function Financial() { return ( <> {/* Financial Year Header Row */} - + {fy.key} Total + {isCurrentFinancialYear(fy.key) && } {formatCurrency(fy.total)} {/* Monthly Breakdown (if expanded) */} {isExpanded && monthEntries.map(([month, amount]) => ( - - {month} + + + {month} + {isCurrentFinancialYear(fy.key) && isCurrentMonth(month) && } + {formatCurrency(amount)} ))} @@ -333,25 +370,28 @@ function Financial() { return ( <> {/* Calendar Year Header Row */} - + {cy.key} Total + {isCurrentYear(cy.key) && } {formatCurrency(cy.total)} {/* Monthly Breakdown (if expanded) */} {isExpanded && monthEntries.map(([month, amount]) => ( - - {month} + + + {month} + {isCurrentYear(cy.key) && isCurrentMonth(month) && } + {formatCurrency(amount)} ))} @@ -403,7 +443,6 @@ function Financial() { type="button" className="expand-button" onClick={() => toggleLocation(item.location)} - style={{ marginRight: '10px' }} > {isExpanded ? '−' : '+'} @@ -418,23 +457,26 @@ function Financial() { const monthTotal = monthEntries.reduce((sum, [, amount]) => sum + amount, 0); return ( <> - + {yearData.year} + {isCurrentYear(yearData.year) && } {formatCurrency(yearData.total)} {isYearExpanded && monthEntries.map(([month, amount]) => ( - - {month} + + + {month} + {isCurrentYear(yearData.year) && isCurrentMonth(month) && } + {formatCurrency(amount)} ))} @@ -485,7 +527,6 @@ function Financial() { type="button" className="expand-button" onClick={() => toggleServiceType(item.type)} - style={{ marginRight: '10px' }} > {isExpanded ? '−' : '+'} @@ -500,23 +541,26 @@ function Financial() { const monthTotal = monthEntries.reduce((sum, [, amount]) => sum + amount, 0); return ( <> - + {yearData.year} + {isCurrentYear(yearData.year) && } {formatCurrency(yearData.total)} {isYearExpanded && monthEntries.map(([month, amount]) => ( - - {month} + + + {month} + {isCurrentYear(yearData.year) && isCurrentMonth(month) && } + {formatCurrency(amount)} ))} @@ -567,7 +611,6 @@ function Financial() { type="button" className="expand-button" onClick={() => toggleServiceName(item.name)} - style={{ marginRight: '10px' }} > {isExpanded ? '−' : '+'} @@ -582,23 +625,26 @@ function Financial() { const monthTotal = monthEntries.reduce((sum, [, amount]) => sum + amount, 0); return ( <> - + {yearData.year} + {isCurrentYear(yearData.year) && } {formatCurrency(yearData.total)} {isYearExpanded && monthEntries.map(([month, amount]) => ( - - {month} + + + {month} + {isCurrentYear(yearData.year) && isCurrentMonth(month) && } + {formatCurrency(amount)} ))} diff --git a/src/components/LocationsManager.css b/src/components/LocationsManager.css index 42dc420..fdff381 100644 --- a/src/components/LocationsManager.css +++ b/src/components/LocationsManager.css @@ -1,17 +1,20 @@ .locations-manager { max-width: 1400px; margin: 0 auto; - padding: 30px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--space-xl) var(--space-lg); + color: var(--color-text-primary); } .locations-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; + margin-bottom: 24px; + background: var(--color-bg-primary); + padding: 24px 28px; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); } .header-actions { @@ -20,56 +23,63 @@ align-items: center; } -.clear-filters-btn { - padding: 8px 16px; - background: #ff9800; - color: white; - border: none; - border-radius: 4px; +.locations-manager .clear-filters-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--color-bg-primary); + color: var(--color-danger); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); cursor: pointer; - font-size: 14px; + transition: all var(--transition-fast); } -.clear-filters-btn:hover { - background: #f57c00; +.locations-manager .clear-filters-btn:hover { + background: var(--color-bg-secondary); + border-color: var(--color-danger); } .filter-info { - padding: 10px; - background: #f5f5f5; - border-bottom: 1px solid #ddd; - font-size: 14px; - color: #666; - font-weight: 500; + padding: 12px 16px; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border-light); + font-size: var(--text-sm); + color: var(--color-text-secondary); + font-weight: var(--font-medium); } .filter-row { - background: #fafafa; + background: var(--color-bg-tertiary); } .filter-row th { - padding: 8px; - border-bottom: 1px solid #ddd; + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--color-border); } .filter-input { width: 100%; - padding: 6px 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 12px; + padding: 6px 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + background: var(--color-bg-primary); box-sizing: border-box; + transition: border-color var(--transition-fast); } .filter-input:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); } .filter-input::placeholder { - color: #999; - font-size: 11px; + color: var(--color-text-muted); } th.sortable { @@ -78,7 +88,7 @@ th.sortable { } th.sortable:hover { - background: #e8e8e8; + background: var(--color-bg-tertiary); } th.resizable { @@ -97,18 +107,21 @@ th.resizable { } .resize-handle:hover { - background: #2196F3; + background: var(--color-info); opacity: 0.5; } th.resizable:hover .resize-handle { - background: #2196F3; + background: var(--color-info); opacity: 0.3; } .locations-header h2 { margin: 0; - color: #333; + color: var(--color-text-primary); + font-size: 26px; + font-weight: 700; + letter-spacing: -0.5px; } .header-buttons { @@ -118,16 +131,16 @@ th.resizable:hover .resize-handle { .import-btn, .add-btn { - padding: 10px 18px; + padding: 11px 20px; border: none; - border-radius: 20px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; - font-weight: 500; + font-size: var(--text-sm); + font-weight: var(--font-semibold); display: inline-flex; align-items: center; gap: 8px; - transition: all 0.2s; + transition: all var(--transition-fast); flex-shrink: 0; white-space: nowrap; position: relative; @@ -136,38 +149,42 @@ th.resizable:hover .resize-handle { } .import-btn { - background: #FF9800; + background: var(--gradient-warning); color: white; + box-shadow: var(--shadow-glow-warning); } .import-btn:hover { - background: #F57C00; + box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4); + transform: translateY(-1px); } .add-btn { - background: #4CAF50; + background: var(--gradient-success); color: white; + box-shadow: var(--shadow-glow-success); } .add-btn:hover { - background: #45a049; + box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4); + transform: translateY(-1px); } .location-form-container { - background: white; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - margin-bottom: 20px; - border: 1px solid #e0e0e0; + background: var(--color-bg-primary); + padding: 28px; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + margin-bottom: 24px; + border: 1px solid var(--color-border); } .location-form-container h3 { margin-top: 0; - margin-bottom: 15px; - color: #333; - font-size: 16px; - font-weight: 600; + margin-bottom: 20px; + color: var(--color-text-primary); + font-size: var(--text-xl); + font-weight: var(--font-semibold); } .location-form { @@ -188,30 +205,31 @@ th.resizable:hover .resize-handle { } .form-group label { - margin-bottom: 5px; - font-weight: 500; - color: #555; - font-size: 14px; + margin-bottom: var(--space-sm); + font-weight: var(--font-medium); + color: var(--color-text-primary); + font-size: var(--text-sm); } .form-group input, .form-group textarea, .form-group select { - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; + padding: 11px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); font-family: inherit; - transition: all 0.2s; - background: white; + transition: all var(--transition-fast); + background: var(--color-bg-secondary); } .form-group input:focus, .form-group textarea:focus, .form-group select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-primary); } .form-group textarea { @@ -230,17 +248,17 @@ th.resizable:hover .resize-handle { .location-submit-btn, .location-cancel-btn { - padding: 10px 20px; + padding: 11px 20px; margin: 0; border: none; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; - font-weight: 500; + font-size: var(--text-sm); + font-weight: var(--font-semibold); line-height: 1.4; height: 40px; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all var(--transition-fast); + box-shadow: var(--shadow-sm); display: inline-flex; align-items: center; justify-content: center; @@ -250,32 +268,32 @@ th.resizable:hover .resize-handle { } .location-submit-btn { - background: #4CAF50; + background: var(--gradient-success); color: white; } .location-submit-btn:hover { - background: #45a049; - box-shadow: 0 3px 6px rgba(76, 175, 80, 0.4); + box-shadow: var(--shadow-glow-success); transform: translateY(-1px); } .location-cancel-btn { - background: #9E9E9E; - color: white; + background: var(--color-bg-primary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); } .location-cancel-btn:hover { - background: #757575; - box-shadow: 0 3px 6px rgba(158, 158, 158, 0.4); - transform: translateY(-1px); + background: var(--color-bg-secondary); + border-color: var(--color-border-dark); } .locations-table-container { - background: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); overflow-x: auto; + border: 1px solid var(--color-border); } table { @@ -284,42 +302,50 @@ table { } thead { - background: #f5f5f5; + background: var(--color-bg-secondary); } th { - padding: 12px; + padding: 14px 16px; text-align: left; - font-weight: 600; - color: #555; - border-bottom: 2px solid #ddd; - font-size: 14px; + font-weight: var(--font-semibold); + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.5px; } td { - padding: 12px; - border-bottom: 1px solid #eee; - font-size: 14px; + padding: 14px 16px; + border-bottom: 1px solid var(--color-border-light); + font-size: var(--text-base); + color: var(--color-text-primary); +} + +tbody tr { + transition: background-color var(--transition-fast); } tbody tr:hover { - background: #f9f9f9; + background: var(--color-bg-secondary); } .edit-btn, .delete-btn { - padding: 8px; + padding: 0; border: none; - border-radius: 4px; + border-radius: var(--radius-sm); cursor: pointer; - font-size: 14px; - margin-right: 5px; + font-size: var(--text-sm); + margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 30px; + height: 30px; vertical-align: middle; + transition: all var(--transition-fast); } .actions-cell { @@ -328,21 +354,43 @@ tbody tr:hover { } .edit-btn { - background: #2196F3; + background: var(--gradient-info); color: white; } .edit-btn:hover { - background: #1976D2; + box-shadow: 0 3px 10px rgba(59, 130, 246, 0.35); + transform: translateY(-1px); } .delete-btn { - background: #f44336; + background: var(--gradient-danger); color: white; } .delete-btn:hover { - background: #d32f2f; + box-shadow: 0 3px 10px rgba(239, 68, 68, 0.35); + transform: translateY(-1px); +} + +.email-action-btn { + margin-top: 4px; + padding: 6px 10px; + font-size: var(--text-xs); + background: var(--gradient-info); + color: var(--color-text-inverse); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition-base); + display: inline-flex; + align-items: center; + gap: 4px; +} + +.email-action-btn:hover { + box-shadow: var(--shadow-glow-info); + transform: translateY(-1px); } .no-data { @@ -359,20 +407,20 @@ tbody tr:hover { .error-message { padding: 12px; - background: #ffebee; - color: #c62828; - border-radius: 4px; + background: var(--color-danger-light); + color: var(--color-danger-hover); + border-radius: var(--radius-sm); margin-bottom: 15px; - border-left: 4px solid #c62828; + border-left: 4px solid var(--color-danger); } .success-message { padding: 12px; - background: #e8f5e9; - color: #2e7d32; - border-radius: 4px; + background: var(--color-success-light); + color: var(--color-success); + border-radius: var(--radius-sm); margin-bottom: 15px; - border-left: 4px solid #2e7d32; + border-left: 4px solid var(--color-success); } .import-dialog-overlay { @@ -425,8 +473,8 @@ tbody tr:hover { .import-textarea:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + border-color: var(--color-success); + box-shadow: 0 0 0 2px var(--color-success-light); } .import-dialog-actions { @@ -437,8 +485,8 @@ tbody tr:hover { .import-confirm-btn { padding: 10px 20px; - background: #4CAF50; - color: white; + background: var(--gradient-success); + color: var(--color-text-inverse); border: none; border-radius: 8px; cursor: pointer; @@ -449,8 +497,8 @@ tbody tr:hover { } .import-confirm-btn:hover { - background: #45a049; - box-shadow: 0 3px 6px rgba(76, 175, 80, 0.4); + background: var(--color-success-hover); + box-shadow: var(--shadow-glow-success); transform: translateY(-1px); } @@ -466,8 +514,8 @@ tbody tr:hover { .lookup-btn { padding: 8px 16px; - background: #2196F3; - color: white; + background: var(--gradient-info); + color: var(--color-text-inverse); border: none; border-radius: 8px; cursor: pointer; @@ -479,13 +527,13 @@ tbody tr:hover { } .lookup-btn:hover { - background: #1976D2; - box-shadow: 0 3px 6px rgba(33, 150, 243, 0.4); + background: var(--color-info-hover); + box-shadow: var(--shadow-glow-info); transform: translateY(-1px); } .lookup-btn:active { - background: #1565C0; + background: var(--color-info-hover); transform: translateY(0); } @@ -519,8 +567,8 @@ tbody tr:hover { .address-select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + border-color: var(--color-success); + box-shadow: 0 0 0 2px var(--color-success-light); } .address-note { @@ -532,14 +580,14 @@ tbody tr:hover { .email-link, .phone-link { - color: #2196F3; + color: var(--color-info); text-decoration: none; transition: color 0.2s; } .email-link:hover, .phone-link:hover { - color: #1976D2; + color: var(--color-info-hover); text-decoration: underline; } @@ -558,16 +606,16 @@ tbody tr:hover { } .email-input-container:focus-within { - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); + border-color: var(--color-success); + box-shadow: 0 0 0 3px var(--color-success-light); } .email-tag { display: inline-flex; align-items: center; gap: 6px; - background: #e3f2fd; - color: #1976d2; + background: var(--color-info-light); + color: var(--color-info); padding: 6px 12px; border-radius: 20px; font-size: 13px; @@ -577,7 +625,7 @@ tbody tr:hover { .email-tag-remove { background: none; border: none; - color: #1976d2; + color: var(--color-info); cursor: pointer; padding: 0; margin-left: 4px; @@ -594,8 +642,8 @@ tbody tr:hover { } .email-tag-remove:hover { - background: rgba(25, 118, 210, 0.1); - color: #1565c0; + background: var(--color-info-light); + color: var(--color-info-hover); } .email-input-field { @@ -621,13 +669,13 @@ tbody tr:hover { /* Inline editor row styles */ .inline-editor-row { - background: #f9f9f9; + background: var(--color-bg-secondary); } .inline-editor-cell { padding: 0 !important; - border-top: 2px solid #4CAF50; - border-bottom: 2px solid #4CAF50; + border-top: 2px solid var(--color-success); + border-bottom: 2px solid var(--color-success); } .inline-editor-container { @@ -644,3 +692,86 @@ tbody tr:hover { font-weight: 600; } +/* Responsive header styling - scoped to locations-manager only */ +.locations-manager .locations-header { + flex-wrap: wrap; + gap: 12px; +} + +.locations-manager .locations-header h2 { + font-size: clamp(20px, 2.2vw, 26px); +} + +.locations-manager .header-actions { + flex-wrap: wrap; +} + +/* Responsive table column hiding - scoped to locations-manager only */ +/* Hide Distance, Post Code, City/Town at 1200px */ +@media (max-width: 1200px) { + .locations-manager .column-distance, + .locations-manager .column-post-code, + .locations-manager .column-city-town { + display: none !important; + } +} + +/* Hide Address, Contact at 1000px */ +@media (max-width: 1000px) { + .locations-manager .column-address, + .locations-manager .column-contact-name { + display: none !important; + } + + .locations-manager th, + .locations-manager td { + padding: 12px 10px; + font-size: clamp(13px, 1.8vw, 15px); + } +} + +/* Hide Email, Phone at 900px */ +@media (max-width: 900px) { + .locations-manager .column-email, + .locations-manager .column-phone { + display: none !important; + } + + .locations-manager th, + .locations-manager td { + padding: 10px 8px; + font-size: clamp(12px, 2.5vw, 14px); + } +} + +/* Tighter spacing at 768px */ +@media (max-width: 768px) { + .locations-manager th, + .locations-manager td { + padding: 8px 6px; + font-size: clamp(11px, 3vw, 13px); + } +} + +/* Even tighter at 480px */ +@media (max-width: 480px) { + .locations-manager th, + .locations-manager td { + padding: 6px 4px; + font-size: clamp(10px, 3.5vw, 12px); + } +} + +/* Responsive header adjustments - scoped to locations-manager only */ +@media (max-width: 900px) { + .locations-manager .locations-header { + flex-direction: column; + align-items: flex-start; + } + + .locations-manager .header-actions { + width: 100%; + justify-content: space-between; + } +} + diff --git a/src/components/LocationsManager.jsx b/src/components/LocationsManager.jsx index 3944729..524b04e 100644 --- a/src/components/LocationsManager.jsx +++ b/src/components/LocationsManager.jsx @@ -982,7 +982,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; handleSort('id')} style={{ width: columnWidths.id, position: 'relative' }} > @@ -993,7 +993,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; >
handleSort('location_name')} style={{ width: columnWidths.location_name, position: 'relative' }} > @@ -1004,7 +1004,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; >
handleSort('address')} style={{ width: columnWidths.address, position: 'relative' }} > @@ -1015,7 +1015,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; >
handleSort('city_town')} style={{ width: columnWidths.city_town, position: 'relative' }} > @@ -1026,7 +1026,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; >
handleSort('post_code')} style={{ width: columnWidths.post_code, position: 'relative' }} > @@ -1037,7 +1037,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; >
handleSort('distance')} style={{ width: columnWidths.distance, position: 'relative' }} > @@ -1048,7 +1048,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; >
handleSort('contact_name')} style={{ width: columnWidths.contact_name, position: 'relative' }} > @@ -1059,7 +1059,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; >
handleSort('email_address')} style={{ width: columnWidths.email_address, position: 'relative' }} > @@ -1070,7 +1070,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; >
handleSort('phone')} style={{ width: columnWidths.phone, position: 'relative' }} > @@ -1080,7 +1080,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; onMouseDown={(e) => handleMouseDown(e, 'phone')} >
- + Actions
- - + + - + - + - + - + - + - + - + - + @@ -1176,14 +1176,14 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; filteredAndSortedLocations.map((loc) => ( - {loc.id} - {loc.location_name} - {loc.address} - {loc.city_town} - {loc.post_code} - {loc.distance ? `${loc.distance} mi` : '-'} - {loc.contact_name || '-'} - + {loc.id} + {loc.location_name} + {loc.address} + {loc.city_town} + {loc.post_code} + {loc.distance ? `${loc.distance} mi` : '-'} + {loc.contact_name || '-'} + {loc.email_address && (Array.isArray(loc.email_address) ? loc.email_address.length > 0 : loc.email_address) ? (
{Array.isArray(loc.email_address) ? ( @@ -1203,16 +1203,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; const mailtoLink = `mailto:${emails.join(',')}`; window.location.href = mailtoLink; }} - style={{ - marginTop: '4px', - padding: '4px 8px', - fontSize: '11px', - background: '#2196F3', - color: 'white', - border: 'none', - borderRadius: '3px', - cursor: 'pointer' - }} + className="email-action-btn" title="Open Email App" > 📧 Open Email App @@ -1222,7 +1213,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; '-' )} - + {loc.phone ? ( {loc.phone} @@ -1231,7 +1222,7 @@ Kings Court Hempstead Rd Holt NR25 6DQ 52.0 mi Emily Marie`; '-' )} - + diff --git a/src/components/Login.css b/src/components/Login.css index 4c8d2e5..8c42f8f 100644 --- a/src/components/Login.css +++ b/src/components/Login.css @@ -3,161 +3,163 @@ justify-content: center; align-items: center; min-height: 100vh; - background: #e5e5e5; - padding: 20px; + background: var(--color-bg-page); + padding: var(--space-lg); } .login-box { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - padding: 40px; + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: var(--space-2xl); width: 100%; - max-width: 450px; + max-width: 420px; } .login-box h1 { text-align: center; - color: #333; - margin: 0 0 10px 0; + color: var(--color-text-primary); + margin: 0 0 var(--space-sm) 0; font-size: 1.8em; - font-weight: 700; + font-weight: var(--font-bold); letter-spacing: -0.5px; } .login-box h2 { text-align: center; - color: #666; - margin: 0 0 30px 0; - font-size: 24px; - font-weight: 600; + color: var(--color-text-secondary); + margin: 0 0 var(--space-xl) 0; + font-size: var(--text-2xl); + font-weight: var(--font-semibold); } -.form-group { - margin-bottom: 20px; +.login-box .form-group { + margin-bottom: var(--space-lg); } -.form-group label { +.login-box .form-group label { display: block; - margin-bottom: 8px; - color: #333; - font-weight: 500; + margin-bottom: var(--space-sm); + color: var(--color-text-primary); + font-weight: var(--font-medium); + font-size: var(--text-base); } -.form-group input { +.login-box .form-group input { width: 100%; - padding: 10px 12px; - border: 2px solid #e0e0e0; - border-radius: 6px; - font-size: 14px; + padding: 12px 14px; + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); box-sizing: border-box; - transition: all 0.2s; - background: white; - height: 43px; + transition: all var(--transition-fast); + background: var(--color-bg-primary); + height: 46px; } -.form-group input:hover { - border-color: #bdbdbd; +.login-box .form-group input:hover { + border-color: var(--color-border-dark); } -.form-group input:focus { +.login-box .form-group input:focus { outline: none; - border-color: #00BCD4; - box-shadow: 0 0 0 3px rgba(0, 188, 212, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } -.form-group.remember-me-group { - margin-bottom: 20px; +.login-box .form-group.remember-me-group { + margin-bottom: var(--space-lg); } .remember-me-label { display: flex; align-items: center; - gap: 12px; + gap: var(--space-sm); cursor: pointer; - font-weight: 400; + font-weight: var(--font-normal); margin-bottom: 0; - color: #333; - font-size: 14px; + color: var(--color-text-primary); + font-size: var(--text-base); } .remember-me-label input[type="checkbox"] { width: 18px; height: 18px; - margin-right: 6px; cursor: pointer; - accent-color: #00BCD4; + accent-color: var(--color-primary); flex-shrink: 0; } -.error-message { - background: #fee; - color: #c33; - padding: 12px; - border-radius: 4px; - margin-bottom: 20px; - border: 1px solid #fcc; +.login-box .error-message { + background: var(--color-danger-light); + color: var(--color-danger); + padding: var(--space-md); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + border-left: 4px solid var(--color-danger); + font-size: var(--text-sm); } -.success-message { - background: #efe; - color: #3c3; - padding: 12px; - border-radius: 4px; - margin-bottom: 20px; - border: 1px solid #cfc; +.login-box .success-message { + background: var(--color-success-light); + color: var(--color-success); + padding: var(--space-md); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + border-left: 4px solid var(--color-success); + font-size: var(--text-sm); } .submit-button { width: 100%; - padding: 12px 24px; - background: #00BCD4; - color: white; + padding: 14px var(--space-lg); + background: var(--color-primary); + color: var(--color-text-inverse); border: none; - border-radius: 20px; - font-size: 15px; - font-weight: 500; + border-radius: var(--radius-full); + font-size: var(--text-md); + font-weight: var(--font-medium); cursor: pointer; - transition: all 0.2s; - height: 44px; + transition: all var(--transition-fast); + height: 48px; display: flex; align-items: center; justify-content: center; } .submit-button:hover:not(:disabled) { - background: #00ACC1; + background: var(--color-primary-hover); } .submit-button:disabled { - background: #ccc; + background: var(--color-border-dark); cursor: not-allowed; } .switch-mode { text-align: center; - margin-top: 20px; - color: #666; + margin-top: var(--space-lg); + color: var(--color-text-secondary); + font-size: var(--text-base); } .switch-mode > div { - margin-bottom: 8px; + margin-bottom: var(--space-sm); } .link-button { background: none; border: none; - color: #00BCD4; + color: var(--color-primary); cursor: pointer; text-decoration: underline; font-size: inherit; padding: 0; margin-left: 5px; - transition: color 0.2s; + transition: color var(--transition-fast); } .link-button:hover { - color: #00ACC1; + color: var(--color-primary-hover); } - diff --git a/src/components/MyPlan.css b/src/components/MyPlan.css index b7f9ccb..8551ffb 100644 --- a/src/components/MyPlan.css +++ b/src/components/MyPlan.css @@ -1,48 +1,55 @@ .my-plan-page { - max-width: 1200px; + max-width: 1400px; margin: 0 auto; - padding: 0 20px; + padding: var(--space-xl) var(--space-lg); + color: var(--color-text-primary); } .my-plan-page .loading, .my-plan-page .error { text-align: center; padding: 60px; - color: #6b7280; - font-size: 15px; + color: var(--color-text-secondary); + font-size: var(--text-md); } /* Header */ .my-plan-page .plan-header { margin-bottom: 32px; + background: var(--color-bg-primary); + padding: 28px; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); } .my-plan-page .plan-title-section h2 { display: flex; align-items: center; gap: 12px; - font-size: 24px; + font-size: 26px; font-weight: 700; - color: #1a1a2e; + color: var(--color-text-primary); margin: 0 0 8px 0; + letter-spacing: -0.5px; } .my-plan-page .plan-title-section .crown { - color: #f59e0b; + color: var(--color-accent); } .my-plan-page .plan-title-section p { - color: #6b7280; + color: var(--color-text-secondary); margin: 0; - font-size: 14px; + font-size: var(--text-base); } /* Current Plan Card */ .my-plan-page .current-plan-card { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 20px; + background: var(--gradient-primary); + border-radius: var(--radius-xl); padding: 32px; - color: white; + color: var(--color-text-inverse); margin-bottom: 32px; position: relative; overflow: hidden; @@ -126,29 +133,29 @@ } .my-plan-page .usage-section h3 { - font-size: 18px; - font-weight: 600; - color: #1a1a2e; + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--color-text-primary); margin: 0 0 20px 0; } .my-plan-page .usage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; + gap: var(--space-lg); } .my-plan-page .usage-card { - background: white; - border: 1px solid #e5e7eb; - border-radius: 16px; - padding: 24px; - transition: all 0.2s; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-xl); + transition: var(--transition-base); } .my-plan-page .usage-card:hover { - border-color: #d1d5db; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + border-color: var(--color-border-dark); + box-shadow: var(--shadow-md); } .my-plan-page .usage-card-header { @@ -169,9 +176,9 @@ } .my-plan-page .usage-label { - font-size: 15px; - font-weight: 600; - color: #374151; + font-size: var(--text-base); + font-weight: var(--font-semibold); + color: var(--color-text-primary); } .my-plan-page .usage-numbers { @@ -183,46 +190,46 @@ .my-plan-page .usage-numbers .current { font-size: 36px; - font-weight: 700; - color: #1a1a2e; + font-weight: var(--font-bold); + color: var(--color-text-primary); } .my-plan-page .usage-numbers .separator { font-size: 20px; - color: #9ca3af; + color: var(--color-text-muted); margin: 0 4px; } .my-plan-page .usage-numbers .max { - font-size: 18px; - color: #6b7280; + font-size: var(--text-lg); + color: var(--color-text-secondary); display: flex; align-items: center; gap: 4px; } .my-plan-page .usage-numbers .max svg { - font-size: 14px; - color: #10b981; + font-size: var(--text-sm); + color: var(--color-success); } .my-plan-page .usage-progress { height: 8px; - background: #f3f4f6; - border-radius: 4px; + background: var(--color-bg-tertiary); + border-radius: var(--radius-sm); overflow: hidden; margin-bottom: 8px; } .my-plan-page .usage-progress-fill { height: 100%; - border-radius: 4px; + border-radius: var(--radius-sm); transition: width 0.5s ease; } .my-plan-page .usage-remaining { - font-size: 12px; - color: #9ca3af; + font-size: var(--text-xs); + color: var(--color-text-muted); } /* Plans Section */ @@ -231,9 +238,9 @@ } .my-plan-page .plans-section h3 { - font-size: 18px; - font-weight: 600; - color: #1a1a2e; + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--color-text-primary); margin: 0 0 20px 0; display: flex; align-items: center; @@ -241,36 +248,44 @@ } .my-plan-page .plans-section h3 svg { - color: #f59e0b; + color: var(--color-warning); } .my-plan-page .plans-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 24px; + gap: var(--space-xl); + align-items: stretch; } .my-plan-page .plan-option { - background: white; - border: 2px solid #e5e7eb; - border-radius: 20px; + background: var(--color-bg-primary); + border: 2px solid var(--color-border); + border-radius: var(--radius-xl); padding: 28px; position: relative; - transition: all 0.2s; + transition: var(--transition-base); + display: flex; + flex-direction: column; + height: 100%; } .my-plan-page .plan-option:hover { - border-color: #d1d5db; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + border-color: var(--color-border-dark); + box-shadow: var(--shadow-lg); } .my-plan-page .plan-option.current { - border-color: #667eea; - background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%); + border-color: var(--color-primary); + background: var(--color-primary-light); } .my-plan-page .plan-option.featured { - border-color: #f59e0b; + border-color: var(--color-warning); +} + +.my-plan-page .plan-option.current.featured { + border-color: var(--color-warning); } .my-plan-page .plan-option .featured-badge { @@ -278,14 +293,16 @@ top: -12px; left: 50%; transform: translateX(-50%); - background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); - color: white; + background: var(--gradient-warning); + color: var(--color-text-inverse); padding: 6px 16px; - border-radius: 20px; - font-size: 11px; - font-weight: 600; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.5px; + z-index: 1; + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3); } .my-plan-page .plan-option .current-badge { @@ -293,20 +310,34 @@ top: -12px; left: 50%; transform: translateX(-50%); - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 6px 16px; - border-radius: 20px; - font-size: 11px; - font-weight: 600; + color: var(--color-text-inverse); + padding: 6px 20px; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.5px; + z-index: 10; + min-width: 120px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +/* Match badge color to border color */ +.my-plan-page .plan-option.current .current-badge { + background: var(--gradient-primary); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.my-plan-page .plan-option.current.featured .current-badge { + background: var(--gradient-warning); + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3); } .my-plan-page .plan-option h4 { - font-size: 22px; - font-weight: 700; - color: #1a1a2e; + font-size: var(--text-xl); + font-weight: var(--font-bold); + color: var(--color-text-primary); margin: 12px 0 12px 0; } @@ -316,24 +347,24 @@ .my-plan-page .plan-option-price .amount { font-size: 36px; - font-weight: 700; - color: #1a1a2e; + font-weight: var(--font-bold); + color: var(--color-text-primary); } .my-plan-page .plan-option-price .period { - font-size: 14px; - color: #6b7280; + font-size: var(--text-sm); + color: var(--color-text-secondary); } .my-plan-page .plan-option-price .free { font-size: 28px; - font-weight: 600; - color: #10b981; + font-weight: var(--font-semibold); + color: var(--color-success); } .my-plan-page .plan-description { - font-size: 14px; - color: #6b7280; + font-size: var(--text-sm); + color: var(--color-text-secondary); margin: 0 0 20px 0; line-height: 1.5; } @@ -343,22 +374,22 @@ flex-direction: column; gap: 10px; margin-bottom: 20px; - padding: 16px; - background: #f8fafc; - border-radius: 12px; + padding: var(--space-md); + background: var(--color-bg-secondary); + border-radius: var(--radius-lg); } .my-plan-page .limit-row { display: flex; align-items: center; gap: 10px; - font-size: 14px; - color: #374151; + font-size: var(--text-sm); + color: var(--color-text-primary); } .my-plan-page .limit-row svg { - color: #6b7280; - font-size: 12px; + color: var(--color-text-secondary); + font-size: var(--text-xs); width: 16px; } @@ -369,19 +400,20 @@ display: flex; flex-direction: column; gap: 8px; + flex-grow: 1; } .my-plan-page .plan-features li { display: flex; align-items: center; gap: 8px; - font-size: 13px; - color: #374151; + font-size: var(--text-sm); + color: var(--color-text-primary); } .my-plan-page .plan-features li svg { - color: #10b981; - font-size: 11px; + color: var(--color-success); + font-size: var(--text-xs); } .my-plan-page .plan-action-btn { @@ -397,32 +429,34 @@ align-items: center; justify-content: center; gap: 8px; + margin-top: auto; } .my-plan-page .plan-action-btn.current { - background: #e5e7eb; - color: #9ca3af; + background: var(--color-bg-tertiary); + color: var(--color-text-muted); cursor: not-allowed; } .my-plan-page .plan-action-btn.upgrade { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: white; + background: var(--gradient-success); + color: var(--color-text-inverse); } .my-plan-page .plan-action-btn.upgrade:hover { - box-shadow: 0 4px 12px rgba(16, 185, 129, 0.35); + box-shadow: var(--shadow-glow-success); transform: translateY(-2px); } .my-plan-page .plan-action-btn.downgrade { - background: white; - color: #6b7280; - border: 1px solid #d1d5db; + background: var(--color-bg-primary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); } .my-plan-page .plan-action-btn.downgrade:hover { - background: #f9fafb; + background: var(--color-bg-secondary); + border-color: var(--color-border-dark); } /* Responsive */ diff --git a/src/components/ServicesManager.css b/src/components/ServicesManager.css index 47f7f17..8927f26 100644 --- a/src/components/ServicesManager.css +++ b/src/components/ServicesManager.css @@ -1,17 +1,20 @@ .services-manager { max-width: 1400px; margin: 0 auto; - padding: 30px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--space-xl) var(--space-lg); + color: var(--color-text-primary); } .services-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; + margin-bottom: 24px; + background: var(--color-bg-primary); + padding: 24px 28px; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); } .header-actions { @@ -20,58 +23,65 @@ align-items: center; } -.clear-filters-btn { - padding: 8px 16px; - background: #ff9800; - color: white; - border: none; - border-radius: 4px; +.services-manager .clear-filters-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--color-bg-primary); + color: var(--color-danger); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); cursor: pointer; - font-size: 14px; + transition: all var(--transition-fast); } -.clear-filters-btn:hover { - background: #f57c00; +.services-manager .clear-filters-btn:hover { + background: var(--color-bg-secondary); + border-color: var(--color-danger); } .filter-info { - padding: 10px; - background: #f5f5f5; - border-bottom: 1px solid #ddd; - font-size: 14px; - color: #666; - font-weight: 500; + padding: 12px 16px; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border-light); + font-size: var(--text-sm); + color: var(--color-text-secondary); + font-weight: var(--font-medium); } .filter-row { - background: #fafafa; + background: var(--color-bg-tertiary); } .filter-row th { - padding: 8px; - border-bottom: 1px solid #ddd; + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--color-border); } .filter-input, .filter-select { width: 100%; - padding: 6px 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 12px; + padding: 6px 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + background: var(--color-bg-primary); box-sizing: border-box; + transition: border-color var(--transition-fast); } .filter-input:focus, .filter-select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); } .filter-input::placeholder { - color: #999; - font-size: 11px; + color: var(--color-text-muted); } th.sortable { @@ -80,7 +90,7 @@ th.sortable { } th.sortable:hover { - background: #e8e8e8; + background: var(--color-bg-tertiary); } th.resizable { @@ -99,18 +109,21 @@ th.resizable { } .resize-handle:hover { - background: #2196F3; + background: var(--color-info); opacity: 0.5; } th.resizable:hover .resize-handle { - background: #2196F3; + background: var(--color-info); opacity: 0.3; } .services-header h2 { margin: 0; - color: #333; + color: var(--color-text-primary); + font-size: 26px; + font-weight: 700; + letter-spacing: -0.5px; } .header-buttons { @@ -120,16 +133,16 @@ th.resizable:hover .resize-handle { .import-btn, .add-btn { - padding: 10px 18px; + padding: 11px 20px; border: none; - border-radius: 20px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; - font-weight: 500; + font-size: var(--text-sm); + font-weight: var(--font-semibold); display: inline-flex; align-items: center; gap: 8px; - transition: all 0.2s; + transition: all var(--transition-fast); flex-shrink: 0; white-space: nowrap; position: relative; @@ -138,34 +151,41 @@ th.resizable:hover .resize-handle { } .import-btn { - background: #FF9800; + background: var(--gradient-warning); color: white; + box-shadow: var(--shadow-glow-warning); } .import-btn:hover { - background: #F57C00; + box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4); + transform: translateY(-1px); } .add-btn { - background: #4CAF50; + background: var(--gradient-success); color: white; + box-shadow: var(--shadow-glow-success); } .add-btn:hover { - background: #45a049; + box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4); + transform: translateY(-1px); } .service-form-container { - background: white; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - margin-bottom: 20px; + background: var(--color-bg-primary); + padding: 28px; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + margin-bottom: 24px; + border: 1px solid var(--color-border); } .service-form-container h3 { margin-top: 0; - color: #333; + color: var(--color-text-primary); + font-size: var(--text-xl); + font-weight: var(--font-semibold); } .service-form { @@ -186,26 +206,29 @@ th.resizable:hover .resize-handle { } .form-group label { - margin-bottom: 5px; - font-weight: 500; - color: #555; - font-size: 14px; + margin-bottom: var(--space-sm); + font-weight: var(--font-medium); + color: var(--color-text-primary); + font-size: var(--text-sm); } .form-group input, .form-group select { - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; + padding: 11px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); font-family: inherit; + transition: all var(--transition-fast); + background: var(--color-bg-secondary); } .form-group input:focus, .form-group select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-primary); } .form-actions { @@ -216,17 +239,17 @@ th.resizable:hover .resize-handle { .service-submit-btn, .service-cancel-btn { - padding: 10px 20px; + padding: 11px 20px; margin: 0; border: none; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; - font-weight: 500; + font-size: var(--text-sm); + font-weight: var(--font-semibold); line-height: 1.4; height: 40px; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all var(--transition-fast); + box-shadow: var(--shadow-sm); display: inline-flex; align-items: center; justify-content: center; @@ -236,32 +259,32 @@ th.resizable:hover .resize-handle { } .service-submit-btn { - background: #4CAF50; + background: var(--gradient-success); color: white; } .service-submit-btn:hover { - background: #45a049; - box-shadow: 0 3px 6px rgba(76, 175, 80, 0.4); + box-shadow: var(--shadow-glow-success); transform: translateY(-1px); } .service-cancel-btn { - background: #9E9E9E; - color: white; + background: var(--color-bg-primary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); } .service-cancel-btn:hover { - background: #757575; - box-shadow: 0 3px 6px rgba(158, 158, 158, 0.4); - transform: translateY(-1px); + background: var(--color-bg-secondary); + border-color: var(--color-border-dark); } .services-table-container { - background: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); overflow-x: auto; + border: 1px solid var(--color-border); } table { @@ -270,42 +293,50 @@ table { } thead { - background: #f5f5f5; + background: var(--color-bg-secondary); } th { - padding: 12px; + padding: 14px 16px; text-align: left; - font-weight: 600; - color: #555; - border-bottom: 2px solid #ddd; - font-size: 14px; + font-weight: var(--font-semibold); + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.5px; } td { - padding: 12px; - border-bottom: 1px solid #eee; - font-size: 14px; + padding: 14px 16px; + border-bottom: 1px solid var(--color-border-light); + font-size: var(--text-base); + color: var(--color-text-primary); +} + +tbody tr { + transition: background-color var(--transition-fast); } tbody tr:hover { - background: #f9f9f9; + background: var(--color-bg-secondary); } .edit-btn, .delete-btn { - padding: 8px; + padding: 0; border: none; - border-radius: 4px; + border-radius: var(--radius-sm); cursor: pointer; - font-size: 14px; - margin-right: 5px; + font-size: var(--text-sm); + margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 30px; + height: 30px; vertical-align: middle; + transition: all var(--transition-fast); } .actions-cell { @@ -314,21 +345,23 @@ tbody tr:hover { } .edit-btn { - background: #2196F3; + background: var(--gradient-info); color: white; } .edit-btn:hover { - background: #1976D2; + box-shadow: 0 3px 10px rgba(59, 130, 246, 0.35); + transform: translateY(-1px); } .delete-btn { - background: #f44336; + background: var(--gradient-danger); color: white; } .delete-btn:hover { - background: #d32f2f; + box-shadow: 0 3px 10px rgba(239, 68, 68, 0.35); + transform: translateY(-1px); } .no-data { @@ -345,19 +378,91 @@ tbody tr:hover { .error-message { padding: 12px; - background: #ffebee; - color: #c62828; - border-radius: 4px; + background: var(--color-danger-light); + color: var(--color-danger-hover); + border-radius: var(--radius-sm); margin-bottom: 15px; - border-left: 4px solid #c62828; + border-left: 4px solid var(--color-danger); } .success-message { padding: 12px; - background: #e8f5e9; - color: #2e7d32; - border-radius: 4px; + background: var(--color-success-light); + color: var(--color-success); + border-radius: var(--radius-sm); margin-bottom: 15px; - border-left: 4px solid #2e7d32; + border-left: 4px solid var(--color-success); +} + +/* Responsive header styling - scoped to services-manager only */ +.services-manager .services-header { + flex-wrap: wrap; + gap: 12px; +} + +.services-manager .services-header h2 { + font-size: clamp(20px, 2.2vw, 26px); +} + +.services-manager .header-actions { + flex-wrap: wrap; +} + +/* Responsive table column hiding - scoped to services-manager only */ +/* Hide Type at 1000px */ +@media (max-width: 1000px) { + .services-manager .column-type { + display: none !important; + } + + .services-manager th, + .services-manager td { + padding: 12px 10px; + font-size: clamp(13px, 1.8vw, 15px); + } +} + +/* Hide Price at 768px (keep ID, Service Name, Actions) */ +@media (max-width: 768px) { + .services-manager .column-price { + display: none !important; + } + + .services-manager th, + .services-manager td { + padding: 10px 8px; + font-size: clamp(12px, 2.5vw, 14px); + } +} + +/* Tighter spacing at 480px */ +@media (max-width: 480px) { + .services-manager th, + .services-manager td { + padding: 8px 6px; + font-size: clamp(11px, 3vw, 13px); + } +} + +/* Even tighter at 390px */ +@media (max-width: 390px) { + .services-manager th, + .services-manager td { + padding: 6px 4px; + font-size: clamp(10px, 3.5vw, 12px); + } +} + +/* Responsive header adjustments - scoped to services-manager only */ +@media (max-width: 900px) { + .services-manager .services-header { + flex-direction: column; + align-items: flex-start; + } + + .services-manager .header-actions { + width: 100%; + justify-content: space-between; + } } diff --git a/src/components/ServicesManager.jsx b/src/components/ServicesManager.jsx index dcc745c..b62581c 100644 --- a/src/components/ServicesManager.jsx +++ b/src/components/ServicesManager.jsx @@ -429,7 +429,7 @@ Hair wash Hair £5.00`; handleSort('id')} style={{ width: columnWidths.id, position: 'relative' }} > @@ -440,7 +440,7 @@ Hair wash Hair £5.00`; >
handleSort('service_name')} style={{ width: columnWidths.service_name, position: 'relative' }} > @@ -451,7 +451,7 @@ Hair wash Hair £5.00`; >
handleSort('type')} style={{ width: columnWidths.type, position: 'relative' }} > @@ -462,7 +462,7 @@ Hair wash Hair £5.00`; >
handleSort('price')} style={{ width: columnWidths.price, position: 'relative' }} > @@ -472,7 +472,7 @@ Hair wash Hair £5.00`; onMouseDown={(e) => handleMouseDown(e, 'price')} >
- + Actions
- - + + - + - + - + @@ -525,11 +525,11 @@ Hair wash Hair £5.00`; ) : ( filteredAndSortedServices.map((service) => ( - {service.id} - {service.service_name} - {service.type} - {formatCurrency(service.price)} - + {service.id} + {service.service_name} + {service.type} + {formatCurrency(service.price)} + diff --git a/src/components/SubscriptionManager.css b/src/components/SubscriptionManager.css index 6a6cbff..4f2c6a9 100644 --- a/src/components/SubscriptionManager.css +++ b/src/components/SubscriptionManager.css @@ -1,66 +1,67 @@ .subscription-manager { max-width: 1400px; margin: 0 auto; + color: var(--color-text-primary); } /* Messages */ .subscription-manager .error-message { - background: #fef2f2; - color: #dc2626; - padding: 14px 18px; - border-radius: 10px; - margin-bottom: 20px; - border-left: 4px solid #dc2626; - font-size: 13px; - font-weight: 500; + background: var(--color-danger-light); + color: var(--color-danger); + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + border-left: 4px solid var(--color-danger); + font-size: var(--text-sm); + font-weight: var(--font-medium); } .subscription-manager .success-message { - background: #f0fdf4; - color: #16a34a; - padding: 14px 18px; - border-radius: 10px; - margin-bottom: 20px; - border-left: 4px solid #16a34a; - font-size: 13px; - font-weight: 500; + background: var(--color-success-light); + color: var(--color-success); + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + border-left: 4px solid var(--color-success); + font-size: var(--text-sm); + font-weight: var(--font-medium); } .subscription-manager .loading { text-align: center; padding: 60px; - color: #6b7280; + color: var(--color-text-secondary); } /* Section */ .subscription-manager .section { - background: white; - border: 1px solid #e5e7eb; - border-radius: 16px; - padding: 24px; - margin-bottom: 24px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-xl); + margin-bottom: var(--space-xl); + box-shadow: var(--shadow-sm); } .subscription-manager .section-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; + margin-bottom: var(--space-lg); } .subscription-manager .section-header h3 { margin: 0; - font-size: 18px; - font-weight: 600; - color: #1a1a2e; + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--color-text-primary); display: flex; align-items: center; gap: 10px; } .subscription-manager .section-header h3 svg { - color: #f59e0b; + color: var(--color-warning); } .subscription-manager .add-btn { @@ -68,47 +69,47 @@ align-items: center; gap: 6px; padding: 10px 16px; - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: white; + background: var(--gradient-success); + color: var(--color-text-inverse); border: none; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 13px; - font-weight: 600; - transition: all 0.2s; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + transition: var(--transition-base); } .subscription-manager .add-btn:hover { - box-shadow: 0 4px 12px rgba(16, 185, 129, 0.35); + box-shadow: var(--shadow-glow-success); transform: translateY(-1px); } /* Plan Form */ .subscription-manager .plan-form-container { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 24px; - margin-bottom: 24px; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-xl); + margin-bottom: var(--space-xl); } .subscription-manager .plan-form-container h4 { - margin: 0 0 20px 0; - font-size: 16px; - font-weight: 600; - color: #1a1a2e; + margin: 0 0 var(--space-lg) 0; + font-size: var(--text-md); + font-weight: var(--font-semibold); + color: var(--color-text-primary); } .subscription-manager .plan-form { display: flex; flex-direction: column; - gap: 16px; + gap: var(--space-md); } .subscription-manager .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 16px; + gap: var(--space-md); } .subscription-manager .form-group { @@ -118,46 +119,46 @@ } .subscription-manager .form-group label { - font-size: 13px; - font-weight: 500; - color: #374151; + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-text-primary); } .subscription-manager .form-group input[type="text"], .subscription-manager .form-group input[type="number"], .subscription-manager .form-group textarea { padding: 10px 12px; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 14px; - background: white; - transition: border-color 0.2s; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + background: var(--color-bg-primary); + transition: var(--transition-base); } .subscription-manager .form-group input:focus, .subscription-manager .form-group textarea:focus { - border-color: #10b981; + border-color: var(--color-success); outline: none; - box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); + box-shadow: 0 0 0 3px var(--color-success-light); } .subscription-manager .form-group input:disabled { - background: #f3f4f6; - color: #9ca3af; + background: var(--color-bg-tertiary); + color: var(--color-text-muted); } .subscription-manager .limits-section, .subscription-manager .features-section { - border-top: 1px solid #e2e8f0; - padding-top: 16px; + border-top: 1px solid var(--color-border); + padding-top: var(--space-md); } .subscription-manager .limits-section h5, .subscription-manager .features-section h5 { margin: 0 0 12px 0; - font-size: 13px; - font-weight: 600; - color: #64748b; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; } @@ -171,23 +172,24 @@ .subscription-manager .feature-input input { flex: 1; padding: 10px 12px; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); } .subscription-manager .add-feature-btn { padding: 10px 14px; - background: #3b82f6; - color: white; + background: var(--gradient-info); + color: var(--color-text-inverse); border: none; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - transition: background 0.2s; + transition: var(--transition-base); } .subscription-manager .add-feature-btn:hover { - background: #2563eb; + box-shadow: var(--shadow-glow-info); + transform: translateY(-1px); } .subscription-manager .features-list { @@ -204,14 +206,14 @@ align-items: center; gap: 8px; padding: 8px 12px; - background: white; - border: 1px solid #e2e8f0; - border-radius: 6px; - font-size: 13px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-sm); } .subscription-manager .features-list .check-icon { - color: #10b981; + color: var(--color-success); } .subscription-manager .features-list span { @@ -221,9 +223,9 @@ .subscription-manager .remove-feature-btn { padding: 4px 6px; background: transparent; - color: #ef4444; + color: var(--color-danger); border: none; - border-radius: 4px; + border-radius: var(--radius-sm); cursor: pointer; opacity: 0.6; transition: opacity 0.2s; @@ -249,14 +251,14 @@ .subscription-manager .checkbox-group input[type="checkbox"] { width: 18px; height: 18px; - accent-color: #10b981; + accent-color: var(--color-success); } .subscription-manager .form-actions { display: flex; gap: 12px; - padding-top: 16px; - border-top: 1px solid #e2e8f0; + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); } .subscription-manager .save-btn { @@ -264,59 +266,61 @@ align-items: center; gap: 6px; padding: 10px 20px; - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: white; + background: var(--gradient-success); + color: var(--color-text-inverse); border: none; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 13px; - font-weight: 600; - transition: all 0.2s; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + transition: var(--transition-base); } .subscription-manager .save-btn:hover { - box-shadow: 0 4px 12px rgba(16, 185, 129, 0.35); + box-shadow: var(--shadow-glow-success); + transform: translateY(-1px); } .subscription-manager .cancel-btn { padding: 10px 20px; - background: white; - color: #6b7280; - border: 1px solid #d1d5db; - border-radius: 8px; + background: var(--color-bg-primary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - font-size: 13px; - font-weight: 500; - transition: all 0.2s; + font-size: var(--text-sm); + font-weight: var(--font-medium); + transition: var(--transition-base); } .subscription-manager .cancel-btn:hover { - background: #f9fafb; + background: var(--color-bg-secondary); + border-color: var(--color-border-dark); } /* Plans Grid */ .subscription-manager .plans-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 20px; + gap: var(--space-lg); } .subscription-manager .plan-card { - background: white; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 20px; - transition: all 0.2s; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-lg); + transition: var(--transition-base); } .subscription-manager .plan-card:hover { - border-color: #d1d5db; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + border-color: var(--color-border-dark); + box-shadow: var(--shadow-md); } .subscription-manager .plan-card.inactive { opacity: 0.6; - background: #f9fafb; + background: var(--color-bg-secondary); } .subscription-manager .plan-header { @@ -328,18 +332,18 @@ .subscription-manager .plan-header h4 { margin: 0; - font-size: 18px; - font-weight: 600; - color: #1a1a2e; + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--color-text-primary); } .subscription-manager .inactive-badge { - font-size: 11px; + font-size: var(--text-xs); padding: 3px 8px; - background: #fee2e2; - color: #dc2626; - border-radius: 4px; - font-weight: 500; + background: var(--color-danger-light); + color: var(--color-danger); + border-radius: var(--radius-sm); + font-weight: var(--font-medium); } .subscription-manager .plan-price { @@ -348,19 +352,19 @@ .subscription-manager .plan-price .amount { font-size: 28px; - font-weight: 700; - color: #10b981; + font-weight: var(--font-bold); + color: var(--color-success); } .subscription-manager .plan-price .period { - font-size: 14px; - color: #6b7280; + font-size: var(--text-sm); + color: var(--color-text-secondary); } .subscription-manager .plan-description { - font-size: 13px; - color: #6b7280; - margin: 0 0 16px 0; + font-size: var(--text-sm); + color: var(--color-text-secondary); + margin: 0 0 var(--space-md) 0; line-height: 1.5; } @@ -369,8 +373,8 @@ flex-direction: column; gap: 8px; padding: 12px; - background: #f8fafc; - border-radius: 8px; + background: var(--color-bg-secondary); + border-radius: var(--radius-md); margin-bottom: 12px; } @@ -378,23 +382,23 @@ display: flex; justify-content: space-between; align-items: center; - font-size: 13px; + font-size: var(--text-sm); } .subscription-manager .limit-label { - color: #64748b; + color: var(--color-text-secondary); } .subscription-manager .limit-value { - font-weight: 600; - color: #1a1a2e; + font-weight: var(--font-semibold); + color: var(--color-text-primary); display: flex; align-items: center; gap: 4px; } .subscription-manager .limit-value svg { - color: #10b981; + color: var(--color-success); } .subscription-manager .plan-features { @@ -407,18 +411,18 @@ gap: 6px; padding: 8px 12px; background: transparent; - border: 1px solid #e2e8f0; - border-radius: 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - font-size: 12px; - color: #64748b; + font-size: var(--text-xs); + color: var(--color-text-secondary); width: 100%; justify-content: center; - transition: all 0.2s; + transition: var(--transition-base); } .subscription-manager .toggle-features:hover { - background: #f8fafc; + background: var(--color-bg-secondary); } .subscription-manager .plan-features ul { @@ -434,12 +438,12 @@ display: flex; align-items: center; gap: 8px; - font-size: 12px; - color: #374151; + font-size: var(--text-xs); + color: var(--color-text-primary); } .subscription-manager .plan-features li svg { - color: #10b981; + color: var(--color-success); flex-shrink: 0; } @@ -457,39 +461,41 @@ gap: 6px; padding: 8px 12px; border: none; - border-radius: 6px; + border-radius: var(--radius-sm); cursor: pointer; - font-size: 12px; - font-weight: 500; - transition: all 0.2s; + font-size: var(--text-xs); + font-weight: var(--font-medium); + transition: var(--transition-base); } .subscription-manager .plan-actions .edit-btn { - background: #eff6ff; - color: #2563eb; + background: var(--gradient-info); + color: var(--color-text-inverse); } .subscription-manager .plan-actions .edit-btn:hover { - background: #dbeafe; + box-shadow: var(--shadow-glow-info); + transform: translateY(-1px); } .subscription-manager .plan-actions .delete-btn { - background: #fef2f2; - color: #dc2626; + background: var(--gradient-danger); + color: var(--color-text-inverse); } .subscription-manager .plan-actions .delete-btn:hover { - background: #fee2e2; + box-shadow: var(--shadow-glow-danger); + transform: translateY(-1px); } .subscription-manager .plan-subscribers { display: flex; align-items: center; gap: 6px; - font-size: 12px; - color: #64748b; + font-size: var(--text-xs); + color: var(--color-text-secondary); padding-top: 12px; - border-top: 1px solid #f3f4f6; + border-top: 1px solid var(--color-border); } /* Subscriptions Table */ @@ -500,79 +506,79 @@ .subscription-manager .subscriptions-table { width: 100%; border-collapse: collapse; - font-size: 13px; + font-size: var(--text-sm); } .subscription-manager .subscriptions-table th { padding: 12px 16px; text-align: left; - font-weight: 600; - color: #64748b; - font-size: 11px; + font-weight: var(--font-semibold); + color: var(--color-text-secondary); + font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.5px; - background: #f8fafc; - border-bottom: 1px solid #e5e7eb; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); } .subscription-manager .subscriptions-table td { padding: 14px 16px; - border-bottom: 1px solid #f3f4f6; + border-bottom: 1px solid var(--color-border); vertical-align: middle; } .subscription-manager .subscriptions-table tbody tr:hover { - background: #f8fafc; + background: var(--color-bg-secondary); } .subscription-manager .plan-badge { display: inline-block; padding: 4px 10px; - background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); - color: white; - border-radius: 20px; - font-size: 11px; - font-weight: 600; + background: var(--gradient-purple); + color: var(--color-text-inverse); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--font-semibold); } .subscription-manager .plan-badge.free { - background: #e5e7eb; - color: #6b7280; + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); } .subscription-manager .status-badge { display: inline-block; padding: 4px 10px; - border-radius: 20px; - font-size: 11px; - font-weight: 600; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--font-semibold); text-transform: capitalize; } .subscription-manager .status-badge.active { - background: #dcfce7; - color: #16a34a; + background: var(--color-success-light); + color: var(--color-success); } .subscription-manager .status-badge.cancelled { - background: #fee2e2; - color: #dc2626; + background: var(--color-danger-light); + color: var(--color-danger); } .subscription-manager .assign-btn { padding: 6px 12px; - background: #f3f4f6; - color: #374151; - border: 1px solid #e5e7eb; - border-radius: 6px; + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - font-size: 12px; - font-weight: 500; - transition: all 0.2s; + font-size: var(--text-xs); + font-weight: var(--font-medium); + transition: var(--transition-base); } .subscription-manager .assign-btn:hover { - background: #e5e7eb; + background: var(--color-bg-secondary); } .subscription-manager .assign-dropdown { @@ -583,10 +589,10 @@ .subscription-manager .assign-dropdown select { padding: 6px 10px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 12px; - background: white; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + background: var(--color-bg-primary); cursor: pointer; } @@ -594,14 +600,14 @@ padding: 6px; background: transparent; border: none; - color: #9ca3af; + color: var(--color-text-muted); cursor: pointer; - border-radius: 4px; + border-radius: var(--radius-sm); transition: color 0.2s; } .subscription-manager .cancel-assign:hover { - color: #6b7280; + color: var(--color-text-secondary); } /* Clear filters button */ @@ -610,18 +616,19 @@ align-items: center; gap: 6px; padding: 8px 14px; - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; - border-radius: 8px; - font-size: 12px; - font-weight: 500; + background: var(--color-bg-primary); + color: var(--color-danger); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-xs); + font-weight: var(--font-medium); cursor: pointer; - transition: all 0.2s; + transition: var(--transition-base); } .subscription-manager .clear-filters-btn:hover { - background: #fee2e2; + background: var(--color-bg-secondary); + border-color: var(--color-danger); } /* Sortable headers */ @@ -633,7 +640,7 @@ } .subscription-manager .subscriptions-table th.sortable:hover { - background: #e5e7eb; + background: var(--color-bg-tertiary); } .subscription-manager .sort-icon { @@ -647,35 +654,35 @@ } .subscription-manager .sort-icon.active { - color: #10b981; + color: var(--color-success); opacity: 1; } /* Filter row */ .subscription-manager .subscriptions-table .filter-row th { padding: 8px 16px; - background: #f8fafc; - border-bottom: 1px solid #e5e7eb; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); } .subscription-manager .filter-input { width: 100%; padding: 6px 10px; - border: 1px solid #e5e7eb; - border-radius: 6px; - font-size: 12px; - background: white; - transition: border-color 0.2s; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + background: var(--color-bg-primary); + transition: var(--transition-base); } .subscription-manager .filter-input:focus { outline: none; - border-color: #10b981; - box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.1); + border-color: var(--color-success); + box-shadow: 0 0 0 2px var(--color-success-light); } .subscription-manager .filter-input::placeholder { - color: #9ca3af; + color: var(--color-text-muted); } /* Responsive */ diff --git a/src/components/SuperAdminManager.css b/src/components/SuperAdminManager.css index d2a8c34..07b6512 100644 --- a/src/components/SuperAdminManager.css +++ b/src/components/SuperAdminManager.css @@ -1,34 +1,40 @@ .super-admin-manager { max-width: 1400px; margin: 0 auto; - padding: 32px 24px; + padding: var(--space-xl) var(--space-lg); + color: var(--color-text-primary); } /* Header */ .super-admin-manager .super-admin-header { margin-bottom: 32px; + background: var(--color-bg-primary); + padding: 28px; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); } .super-admin-manager .super-admin-header h2 { - margin: 0 0 6px 0; - color: #1a1a2e; + margin: 0 0 8px 0; + color: var(--color-text-primary); font-size: 26px; font-weight: 700; letter-spacing: -0.5px; } .super-admin-manager .admin-subtitle { - color: #6b7280; + color: var(--color-text-secondary); margin: 0; - font-size: 14px; + font-size: var(--text-base); } /* Admin Tabs */ .super-admin-manager .admin-tabs { display: flex; gap: 8px; - margin-bottom: 24px; - border-bottom: 1px solid #e5e7eb; + margin-bottom: var(--space-xl); + border-bottom: 1px solid var(--color-border); padding-bottom: 0; } @@ -38,23 +44,23 @@ gap: 8px; padding: 12px 20px; background: transparent; - color: #6b7280; + color: var(--color-text-secondary); border: none; border-bottom: 2px solid transparent; cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.2s; + font-size: var(--text-sm); + font-weight: var(--font-medium); + transition: var(--transition-base); margin-bottom: -1px; } .super-admin-manager .admin-tab:hover { - color: #374151; + color: var(--color-text-primary); } .super-admin-manager .admin-tab.active { - color: #10b981; - border-bottom-color: #10b981; + color: var(--color-success); + border-bottom-color: var(--color-success); } .super-admin-manager .admin-tab svg { @@ -95,23 +101,23 @@ } .super-admin-manager .stat-card.users { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - box-shadow: 0 4px 14px rgba(16, 185, 129, 0.25); + background: var(--gradient-success); + box-shadow: var(--shadow-glow-success); } .super-admin-manager .stat-card.appointments { - background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%); - box-shadow: 0 4px 14px rgba(244, 63, 94, 0.25); + background: var(--gradient-danger); + box-shadow: var(--shadow-glow-danger); } .super-admin-manager .stat-card.locations { - background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); - box-shadow: 0 4px 14px rgba(6, 182, 212, 0.25); + background: var(--gradient-cyan); + box-shadow: var(--shadow-glow-cyan); } .super-admin-manager .stat-card.services { - background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); - box-shadow: 0 4px 14px rgba(245, 158, 11, 0.25); + background: var(--gradient-warning); + box-shadow: var(--shadow-glow-warning); } .super-admin-manager .stat-card .stat-icon { @@ -159,8 +165,8 @@ } .super-admin-manager .create-user-btn { - background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important; - color: white !important; + background: var(--gradient-success) !important; + color: var(--color-text-inverse) !important; } .super-admin-manager .create-user-btn:hover { @@ -169,8 +175,8 @@ } .super-admin-manager .export-btn { - background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; - color: white !important; + background: var(--gradient-info) !important; + color: var(--color-text-inverse) !important; } .super-admin-manager .export-btn:hover { @@ -180,19 +186,19 @@ /* Form Container */ .super-admin-manager .user-form-container { - background: white; - border: 1px solid #e5e7eb; - border-radius: 16px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); padding: 28px; - margin-bottom: 24px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + margin-bottom: var(--space-xl); + box-shadow: var(--shadow-sm); } .super-admin-manager .user-form-container h3 { - margin: 0 0 24px 0; - color: #1a1a2e; - font-size: 18px; - font-weight: 600; + margin: 0 0 var(--space-xl) 0; + color: var(--color-text-primary); + font-size: var(--text-lg); + font-weight: var(--font-semibold); } .super-admin-manager .user-form { @@ -217,29 +223,29 @@ } .super-admin-manager .user-form .form-group label { - font-weight: 500; - color: #374151; - font-size: 13px; + font-weight: var(--font-medium); + color: var(--color-text-primary); + font-size: var(--text-sm); } .super-admin-manager .user-form .form-group input[type="text"], .super-admin-manager .user-form .form-group input[type="email"], .super-admin-manager .user-form .form-group input[type="password"] { padding: 11px 14px !important; - border: 1px solid #d1d5db !important; - border-radius: 10px !important; - font-size: 14px !important; - transition: all 0.2s ease !important; + border: 1px solid var(--color-border) !important; + border-radius: var(--radius-md) !important; + font-size: var(--text-sm) !important; + transition: var(--transition-base) !important; width: 100% !important; box-sizing: border-box !important; - background: #f9fafb !important; + background: var(--color-bg-secondary) !important; } .super-admin-manager .user-form .form-group input:focus { - border-color: #10b981 !important; + border-color: var(--color-success) !important; outline: none !important; - box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12) !important; - background: white !important; + box-shadow: 0 0 0 3px var(--color-success-light) !important; + background: var(--color-bg-primary) !important; } .super-admin-manager .user-form .checkbox-group > label { @@ -247,21 +253,21 @@ align-items: center !important; gap: 10px !important; cursor: pointer !important; - font-weight: 500 !important; - color: #374151 !important; + font-weight: var(--font-medium) !important; + color: var(--color-text-primary) !important; } .super-admin-manager .user-form .checkbox-group input[type="checkbox"] { width: 18px !important; height: 18px !important; cursor: pointer !important; - accent-color: #10b981; - border-radius: 4px; + accent-color: var(--color-success); + border-radius: var(--radius-sm); } .super-admin-manager .field-help { - font-size: 12px; - color: #9ca3af; + font-size: var(--text-xs); + color: var(--color-text-muted); margin: 0; } @@ -270,8 +276,8 @@ display: flex !important; gap: 12px !important; margin-top: 8px !important; - padding-top: 20px; - border-top: 1px solid #f3f4f6; + padding-top: var(--space-lg); + border-top: 1px solid var(--color-border); } .super-admin-manager .form-actions .submit-btn, @@ -287,8 +293,8 @@ } .super-admin-manager .form-actions .submit-btn { - background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important; - color: white !important; + background: var(--gradient-success) !important; + color: var(--color-text-inverse) !important; border: none !important; } @@ -298,32 +304,32 @@ } .super-admin-manager .form-actions .cancel-btn { - background: white !important; - color: #6b7280 !important; - border: 1px solid #e5e7eb !important; + background: var(--color-bg-primary) !important; + color: var(--color-text-secondary) !important; + border: 1px solid var(--color-border) !important; } .super-admin-manager .form-actions .cancel-btn:hover { - background: #f9fafb !important; - border-color: #d1d5db !important; + background: var(--color-bg-secondary) !important; + border-color: var(--color-border-dark) !important; } /* Users Table Container */ .super-admin-manager .users-table-container { - background: white; - border: 1px solid #e5e7eb; - border-radius: 16px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); overflow-x: auto; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); } .super-admin-manager .users-table-container h3 { margin: 0; - padding: 20px 24px; - color: #1a1a2e; - font-size: 16px; - font-weight: 600; - border-bottom: 1px solid #f3f4f6; + padding: var(--space-lg) var(--space-xl); + color: var(--color-text-primary); + font-size: var(--text-md); + font-weight: var(--font-semibold); + border-bottom: 1px solid var(--color-border); } /* Users Table */ @@ -336,18 +342,18 @@ } .super-admin-manager .users-table thead { - background: #f8fafc; + background: var(--color-bg-secondary); } .super-admin-manager .users-table th { padding: 12px 16px; text-align: left; - font-weight: 600; - color: #64748b; - font-size: 11px; + font-weight: var(--font-semibold); + color: var(--color-text-secondary); + font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.5px; - border-bottom: 1px solid #e5e7eb; + border-bottom: 1px solid var(--color-border); } .super-admin-manager .users-table th:first-child { @@ -356,15 +362,15 @@ .super-admin-manager .users-table td { padding: 16px; - border-bottom: 1px solid #f3f4f6; + border-bottom: 1px solid var(--color-border); vertical-align: middle; - color: #374151; + color: var(--color-text-primary); } .super-admin-manager .users-table td:first-child { - padding-left: 24px; - color: #9ca3af; - font-size: 12px; + padding-left: var(--space-xl); + color: var(--color-text-muted); + font-size: var(--text-xs); } /* Column widths */ @@ -422,9 +428,9 @@ .super-admin-manager .users-table-header h3 { margin: 0; - font-size: 16px; - font-weight: 600; - color: #1a1a2e; + font-size: var(--text-md); + font-weight: var(--font-semibold); + color: var(--color-text-primary); } .super-admin-manager .clear-filters-btn { @@ -432,53 +438,54 @@ align-items: center; gap: 6px; padding: 8px 14px; - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; - border-radius: 8px; - font-size: 12px; - font-weight: 500; + background: var(--color-bg-primary); + color: var(--color-danger); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-xs); + font-weight: var(--font-medium); cursor: pointer; - transition: all 0.2s; + transition: var(--transition-base); } .super-admin-manager .clear-filters-btn:hover { - background: #fee2e2; + background: var(--color-bg-secondary); + border-color: var(--color-danger); } /* Filter row */ .super-admin-manager .users-table .filter-row th { padding: 8px 16px; - background: #f8fafc; - border-bottom: 1px solid #e5e7eb; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); } .super-admin-manager .filter-input { width: 100%; padding: 6px 10px; - border: 1px solid #e5e7eb; - border-radius: 6px; - font-size: 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-xs); background: white; transition: border-color 0.2s; } .super-admin-manager .filter-input:focus { outline: none; - border-color: #10b981; - box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.1); + border-color: var(--color-success); + box-shadow: 0 0 0 2px var(--color-success-light); } .super-admin-manager .filter-input::placeholder { - color: #9ca3af; + color: var(--color-text-muted); } .super-admin-manager .filter-select { width: 100%; padding: 6px 8px; - border: 1px solid #e5e7eb; - border-radius: 6px; - font-size: 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-xs); background: white; cursor: pointer; transition: border-color 0.2s; @@ -486,7 +493,7 @@ .super-admin-manager .filter-select:focus { outline: none; - border-color: #10b981; + border-color: var(--color-success); } /* Sortable headers */ @@ -498,7 +505,7 @@ } .super-admin-manager .users-table th.sortable:hover { - background: #e5e7eb; + background: var(--color-bg-tertiary); } .super-admin-manager .sort-icon { @@ -512,7 +519,7 @@ } .super-admin-manager .sort-icon.active { - color: #10b981; + color: var(--color-success); opacity: 1; } @@ -521,7 +528,7 @@ } .super-admin-manager .users-table tbody tr:hover { - background-color: #f8fafc; + background-color: var(--color-bg-secondary); } .super-admin-manager .users-table tbody tr:last-child td { @@ -529,8 +536,8 @@ } .super-admin-manager .users-table td strong { - color: #1a1a2e; - font-weight: 600; + color: var(--color-text-primary); + font-weight: var(--font-semibold); } /* Badges */ @@ -546,13 +553,13 @@ } .super-admin-manager .super-admin-badge { - background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); - color: white; + background: var(--gradient-warning); + color: var(--color-text-inverse); } .super-admin-manager .user-badge { - background: #f1f5f9; - color: #64748b; + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); } /* Action Buttons */ @@ -579,7 +586,8 @@ } .super-admin-manager .action-btn:hover { - opacity: 0.8; + transform: translateY(-1px); + box-shadow: var(--shadow-md); } .super-admin-manager .action-btn svg { @@ -589,46 +597,46 @@ } .super-admin-manager .edit-btn { - background: #3b82f6 !important; + background: var(--gradient-info) !important; color: white !important; } .super-admin-manager .password-btn { - background: #f59e0b !important; + background: var(--gradient-warning) !important; color: white !important; } .super-admin-manager .impersonate-btn { - background: #8b5cf6 !important; + background: var(--gradient-purple) !important; color: white !important; } .super-admin-manager .delete-btn { - background: #ef4444 !important; + background: var(--gradient-danger) !important; color: white !important; } /* Messages */ .super-admin-manager .error-message { - background: #fef2f2; - color: #dc2626; - padding: 14px 18px; - border-radius: 10px; - margin-bottom: 20px; - border-left: 4px solid #dc2626; - font-size: 13px; - font-weight: 500; + background: var(--color-danger-light); + color: var(--color-danger); + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + border-left: 4px solid var(--color-danger); + font-size: var(--text-sm); + font-weight: var(--font-medium); } .super-admin-manager .success-message { - background: #f0fdf4; - color: #16a34a; - padding: 14px 18px; - border-radius: 10px; - margin-bottom: 20px; - border-left: 4px solid #16a34a; - font-size: 13px; - font-weight: 500; + background: var(--color-success-light); + color: var(--color-success); + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + border-left: 4px solid var(--color-success); + font-size: var(--text-sm); + font-weight: var(--font-medium); } /* Loading State */ @@ -637,16 +645,16 @@ align-items: center; justify-content: center; padding: 80px 40px; - color: #9ca3af; - font-size: 14px; + color: var(--color-text-muted); + font-size: var(--text-sm); } /* Empty State */ .super-admin-manager .empty-state { text-align: center; - padding: 48px 24px; - color: #9ca3af; - font-size: 14px; + padding: 48px var(--space-xl); + color: var(--color-text-muted); + font-size: var(--text-sm); } /* Responsive */ diff --git a/src/components/UsageIndicator.css b/src/components/UsageIndicator.css index 5f3e14a..f7496b9 100644 --- a/src/components/UsageIndicator.css +++ b/src/components/UsageIndicator.css @@ -1,18 +1,18 @@ .usage-indicator { - background: white; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 16px; - margin-bottom: 20px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-md); + margin-bottom: var(--space-lg); } .usage-indicator .usage-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 16px; + margin-bottom: var(--space-md); padding-bottom: 12px; - border-bottom: 1px solid #f3f4f6; + border-bottom: 1px solid var(--color-border); } .usage-indicator .plan-info { @@ -22,29 +22,29 @@ } .usage-indicator .crown-icon { - color: #f59e0b; - font-size: 16px; + color: var(--color-warning); + font-size: var(--text-md); } .usage-indicator .plan-name { - font-weight: 600; - color: #1a1a2e; - font-size: 14px; + font-weight: var(--font-semibold); + color: var(--color-text-primary); + font-size: var(--text-sm); } .usage-indicator .upgrade-link { - color: #10b981; - font-size: 13px; - font-weight: 500; + color: var(--color-success); + font-size: var(--text-sm); + font-weight: var(--font-medium); text-decoration: none; padding: 6px 12px; - border-radius: 6px; - background: #f0fdf4; - transition: all 0.2s; + border-radius: var(--radius-sm); + background: var(--color-success-light); + transition: var(--transition-base); } .usage-indicator .upgrade-link:hover { - background: #dcfce7; + background: var(--color-success-light); } .usage-indicator .usage-items { @@ -63,58 +63,58 @@ display: flex; align-items: center; gap: 8px; - font-size: 13px; + font-size: var(--text-sm); } .usage-indicator .usage-icon { - color: #6b7280; - font-size: 12px; + color: var(--color-text-secondary); + font-size: var(--text-xs); } .usage-indicator .usage-label { - color: #6b7280; + color: var(--color-text-secondary); flex: 1; } .usage-indicator .usage-count { - font-weight: 600; - color: #374151; + font-weight: var(--font-semibold); + color: var(--color-text-primary); } .usage-indicator .usage-bar { height: 6px; - background: #f3f4f6; + background: var(--color-bg-tertiary); border-radius: 3px; overflow: hidden; } .usage-indicator .usage-bar-fill { height: 100%; - background: linear-gradient(90deg, #10b981 0%, #059669 100%); + background: var(--gradient-success); border-radius: 3px; transition: width 0.3s ease; } .usage-indicator .usage-item.near-limit .usage-bar-fill { - background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); + background: var(--gradient-warning); } .usage-indicator .usage-item.at-limit .usage-bar-fill { - background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); + background: var(--gradient-danger); } .usage-indicator .usage-item.near-limit .usage-count { - color: #d97706; + color: var(--color-warning-hover); } .usage-indicator .usage-item.at-limit .usage-count { - color: #dc2626; + color: var(--color-danger); } .usage-indicator .unlimited-badge { - font-size: 11px; - color: #10b981; - font-weight: 500; + font-size: var(--text-xs); + color: var(--color-success); + font-weight: var(--font-medium); display: flex; align-items: center; gap: 4px; @@ -124,12 +124,12 @@ display: flex; align-items: center; gap: 10px; - margin-top: 16px; + margin-top: var(--space-md); padding: 12px; - background: #fef2f2; - border-radius: 8px; - font-size: 13px; - color: #dc2626; + background: var(--color-danger-light); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--color-danger); } .usage-indicator .limit-warning svg { @@ -142,42 +142,42 @@ align-items: center; gap: 6px; padding: 6px 12px; - background: #f8fafc; - border-radius: 20px; - font-size: 12px; + background: var(--color-bg-secondary); + border-radius: var(--radius-full); + font-size: var(--text-xs); margin: 0; - border: 1px solid #e2e8f0; + border: 1px solid var(--color-border); } .usage-indicator.compact .plan-name { display: flex; align-items: center; gap: 6px; - font-size: 12px; - font-weight: 500; - color: #64748b; + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--color-text-secondary); } .usage-indicator.compact .plan-name svg { - color: #f59e0b; - font-size: 11px; + color: var(--color-warning); + font-size: var(--text-xs); } .usage-indicator.compact .warning-icon { - color: #f59e0b; - font-size: 12px; + color: var(--color-warning); + font-size: var(--text-xs); } .usage-indicator.compact.near-limit { - border-color: #fcd34d; - background: #fffbeb; + border-color: var(--color-warning); + background: var(--color-warning-light); } .usage-indicator.compact.at-limit { - border-color: #fca5a5; - background: #fef2f2; + border-color: var(--color-danger); + background: var(--color-danger-light); } .usage-indicator.compact.at-limit .warning-icon { - color: #dc2626; + color: var(--color-danger); } diff --git a/src/index.css b/src/index.css index d196124..e9a2eb2 100644 --- a/src/index.css +++ b/src/index.css @@ -3,31 +3,793 @@ } :root { - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + /* Typography */ + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; font-weight: 400; - color-scheme: light; - color: #213547; - background-color: #ffffff; - font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + + /* ===== DESIGN TOKENS - MyPlan Style ===== */ + + /* Primary Gradient Colors */ + --gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%); + --gradient-warning: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + --gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + --gradient-info: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + --gradient-purple: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + --gradient-cyan: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); + --gradient-rose: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%); + + /* Primary Colors */ + --color-primary: #667eea; + --color-primary-hover: #5a6fd6; + --color-primary-light: rgba(102, 126, 234, 0.1); + --color-primary-dark: #764ba2; + + /* Secondary/Accent Colors */ + --color-accent: #f59e0b; + --color-accent-hover: #d97706; + + /* Semantic Colors */ + --color-success: #10b981; + --color-success-hover: #059669; + --color-success-light: rgba(16, 185, 129, 0.1); + + --color-warning: #f59e0b; + --color-warning-hover: #d97706; + --color-warning-light: rgba(245, 158, 11, 0.1); + + --color-purple: #9C27B0; + --color-purple-hover: #7B1FA2; + --color-purple-light: rgba(156, 39, 176, 0.1); + + --color-danger: #ef4444; + --color-danger-hover: #dc2626; + --color-danger-light: rgba(239, 68, 68, 0.1); + + --color-info: #3b82f6; + --color-info-hover: #2563eb; + --color-info-light: rgba(59, 130, 246, 0.1); + + /* Neutral Colors */ + --color-text-primary: #1a1a2e; + --color-text-secondary: #6b7280; + --color-text-muted: #9ca3af; + --color-text-inverse: #ffffff; + + --color-bg-primary: #ffffff; + --color-bg-secondary: #f9fafb; + --color-bg-tertiary: #f3f4f6; + --color-bg-page: #f0f2f5; + + --color-border: #e5e7eb; + --color-border-light: #f3f4f6; + --color-border-dark: #d1d5db; + + /* Shadows - Enhanced for MyPlan style */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.12); + --shadow-glow-primary: 0 4px 14px rgba(102, 126, 234, 0.25); + --shadow-glow-success: 0 4px 14px rgba(16, 185, 129, 0.25); + --shadow-glow-warning: 0 4px 14px rgba(245, 158, 11, 0.25); + --shadow-glow-danger: 0 4px 14px rgba(239, 68, 68, 0.25); + + /* Border Radius - Increased for modern look */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Spacing */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + + /* Font Sizes */ + --text-xs: 11px; + --text-sm: 13px; + --text-base: 14px; + --text-md: 15px; + --text-lg: 16px; + --text-xl: 18px; + --text-2xl: 24px; + --text-3xl: 28px; + --text-4xl: 36px; + + /* Font Weights */ + --font-normal: 400; + --font-medium: 500; + --font-semibold: 600; + --font-bold: 700; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.2s ease; + --transition-slow: 0.3s ease; + + /* Z-Index Scale */ + --z-dropdown: 100; + --z-sticky: 200; + --z-modal: 500; + --z-popover: 600; + --z-tooltip: 700; + --z-banner: 1000; } body { margin: 0; min-width: 320px; min-height: 100vh; + color: var(--color-text-primary); + background-color: var(--color-bg-page); } h1, h2, h3, h4, h5, h6 { margin: 0; - font-weight: 600; + font-weight: var(--font-bold); + color: var(--color-text-primary); + letter-spacing: -0.5px; } button { font-family: inherit; } + +/* ===== GLOBAL PAGE CONTAINER ===== */ +.page-container { + max-width: 1400px; + margin: 0 auto; + padding: var(--space-xl) var(--space-lg); + color: var(--color-text-primary); +} + +/* ===== GLOBAL PAGE HEADER ===== */ +.page-header { + margin-bottom: var(--space-xl); +} + +.page-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 26px; + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 8px 0; +} + +.page-title svg { + color: var(--color-accent); +} + +.page-subtitle { + color: var(--color-text-secondary); + margin: 0; + font-size: var(--text-base); +} + +/* ===== HERO CARD (Gradient Background) ===== */ +.hero-card { + background: var(--gradient-primary); + border-radius: var(--radius-xl); + padding: var(--space-xl); + color: white; + position: relative; + overflow: hidden; + margin-bottom: var(--space-xl); +} + +.hero-card::before { + content: ''; + position: absolute; + top: -50%; + right: -20%; + width: 300px; + height: 300px; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; +} + +/* ===== STATS GRID ===== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-xl); +} + +.stat-card { + border-radius: var(--radius-lg); + padding: 20px 24px; + color: white; + position: relative; + overflow: hidden; + transition: transform var(--transition-normal), box-shadow var(--transition-normal); +} + +.stat-card:hover { + transform: translateY(-2px); +} + +.stat-card::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + pointer-events: none; +} + +.stat-card.success { background: var(--gradient-success); box-shadow: var(--shadow-glow-success); } +.stat-card.danger { background: var(--gradient-danger); box-shadow: var(--shadow-glow-danger); } +.stat-card.info { background: var(--gradient-cyan); box-shadow: 0 4px 14px rgba(6, 182, 212, 0.25); } +.stat-card.warning { background: var(--gradient-warning); box-shadow: var(--shadow-glow-warning); } + +.stat-icon { + font-size: 20px; + margin-bottom: 12px; + opacity: 0.9; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + margin-bottom: 4px; + line-height: 1; +} + +.stat-label { + font-size: var(--text-xs); + opacity: 0.9; + text-transform: uppercase; + letter-spacing: 1px; + font-weight: var(--font-medium); +} + +/* ===== CONTENT CARD ===== */ +.content-card { + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.content-card-header { + padding: 20px 24px; + border-bottom: 1px solid var(--color-border-light); +} + +.content-card-header h3 { + margin: 0; + font-size: var(--text-lg); + font-weight: var(--font-semibold); +} + +.content-card-body { + padding: 24px; +} + +/* ===== BUTTON SYSTEM ===== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: 11px 20px; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; + box-shadow: var(--shadow-sm); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Primary Button - Gradient */ +.btn-primary { + background: var(--gradient-success); + color: var(--color-text-inverse); +} + +.btn-primary:hover:not(:disabled) { + box-shadow: var(--shadow-glow-success); + transform: translateY(-1px); +} + +/* Success Button */ +.btn-success { + background: var(--gradient-success); + color: var(--color-text-inverse); +} + +.btn-success:hover:not(:disabled) { + box-shadow: var(--shadow-glow-success); + transform: translateY(-1px); +} + +/* Danger Button */ +.btn-danger { + background: var(--gradient-danger); + color: var(--color-text-inverse); +} + +.btn-danger:hover:not(:disabled) { + box-shadow: var(--shadow-glow-danger); + transform: translateY(-1px); +} + +/* Warning Button */ +.btn-warning { + background: var(--gradient-warning); + color: var(--color-text-inverse); +} + +.btn-warning:hover:not(:disabled) { + box-shadow: var(--shadow-glow-warning); + transform: translateY(-1px); +} + +/* Info Button */ +.btn-info { + background: var(--gradient-info); + color: var(--color-text-inverse); +} + +.btn-info:hover:not(:disabled) { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35); + transform: translateY(-1px); +} + +/* Secondary/Outline Button */ +.btn-secondary { + background: var(--color-bg-primary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-bg-secondary); + border-color: var(--color-border-dark); +} + +/* Ghost Button */ +.btn-ghost { + background: transparent; + color: var(--color-text-secondary); + box-shadow: none; +} + +.btn-ghost:hover:not(:disabled) { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +/* Button Sizes */ +.btn-sm { + padding: 8px 14px; + font-size: var(--text-xs); +} + +.btn-lg { + padding: 14px 28px; + font-size: var(--text-lg); +} + +/* Icon-only Button */ +.btn-icon { + width: 36px; + height: 36px; + padding: 0; + border-radius: var(--radius-md); +} + +.btn-icon.btn-sm { + width: 28px; + height: 28px; +} + +/* Pill Button */ +.btn-pill { + border-radius: var(--radius-full); +} + +/* ===== FORM INPUTS ===== */ +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 11px 14px; + font-size: var(--text-base); + font-family: inherit; + color: var(--color-text-primary); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.form-input:hover, +.form-select:hover, +.form-textarea:hover { + border-color: var(--color-border-dark); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-primary); +} + +.form-input::placeholder { + color: var(--color-text-muted); +} + +.form-label { + display: block; + margin-bottom: var(--space-sm); + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-text-primary); +} + +/* Filter Inputs (smaller) */ +.filter-input, +.filter-select { + width: 100%; + padding: 6px 10px; + font-size: var(--text-sm); + color: var(--color-text-primary); + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + transition: border-color var(--transition-fast); +} + +.filter-input:focus, +.filter-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); +} + +.filter-input::placeholder { + color: var(--color-text-muted); +} + +/* ===== TABLE STYLES ===== */ +.data-table { + width: 100%; + border-collapse: collapse; + background: var(--color-bg-primary); +} + +.data-table th { + padding: 14px var(--space-md); + text-align: left; + font-size: var(--text-xs); + font-weight: var(--font-semibold); + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); +} + +.data-table td { + padding: 14px var(--space-md); + font-size: var(--text-base); + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border-light); + vertical-align: middle; +} + +.data-table tbody tr { + transition: background-color var(--transition-fast); +} + +.data-table tbody tr:hover { + background: var(--color-bg-secondary); +} + +.data-table th.sortable { + cursor: pointer; + user-select: none; + transition: background var(--transition-fast); +} + +.data-table th.sortable:hover { + background: var(--color-bg-tertiary); +} + +.data-table .filter-row th { + padding: var(--space-sm) var(--space-md); + background: var(--color-bg-tertiary); +} + +/* Sort Icons */ +.sort-icon { + margin-left: var(--space-xs); + font-size: 10px; + vertical-align: middle; +} + +.sort-icon.inactive { + opacity: 0.3; +} + +.sort-icon.active { + color: var(--color-primary); + opacity: 1; +} + +/* ===== BADGES ===== */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + font-size: var(--text-xs); + font-weight: var(--font-semibold); + border-radius: var(--radius-sm); + white-space: nowrap; +} + +.badge-primary { + background: var(--gradient-primary); + color: white; +} + +.badge-success { + background: var(--gradient-success); + color: white; +} + +.badge-warning { + background: var(--gradient-warning); + color: white; +} + +.badge-danger { + background: var(--gradient-danger); + color: white; +} + +.badge-info { + background: var(--gradient-info); + color: white; +} + +.badge-neutral { + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); +} + +/* ===== MESSAGES ===== */ +.message { + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); + margin-bottom: var(--space-md); +} + +.message-success { + background: #f0fdf4; + color: #166534; + border-left: 4px solid var(--color-success); +} + +.message-error { + background: #fef2f2; + color: #991b1b; + border-left: 4px solid var(--color-danger); +} + +.message-warning { + background: #fffbeb; + color: #92400e; + border-left: 4px solid var(--color-warning); +} + +.message-info { + background: #eff6ff; + color: #1e40af; + border-left: 4px solid var(--color-info); +} + +/* ===== CLEAR FILTERS BUTTON ===== */ +.clear-filters-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: #fef2f2; + color: var(--color-danger); + border: 1px solid #fecaca; + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + transition: all var(--transition-fast); +} + +.clear-filters-btn:hover { + background: #fee2e2; +} + +/* ===== LOADING STATE ===== */ +.loading { + text-align: center; + padding: var(--space-2xl); + color: var(--color-text-secondary); + font-size: var(--text-md); +} + +/* ===== EMPTY STATE ===== */ +.empty-state { + text-align: center; + padding: var(--space-2xl); + color: var(--color-text-muted); +} + +/* ===== USAGE CARDS (Progress Indicators) ===== */ +.usage-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +.usage-card { + background: white; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; + transition: all var(--transition-normal); +} + +.usage-card:hover { + border-color: var(--color-border-dark); + box-shadow: var(--shadow-md); +} + +.usage-card-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: var(--space-md); +} + +.usage-icon-wrapper { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-xl); +} + +.usage-label { + font-size: var(--text-md); + font-weight: var(--font-semibold); + color: var(--color-text-primary); +} + +.usage-numbers { + display: flex; + align-items: baseline; + gap: 4px; + margin-bottom: 12px; +} + +.usage-numbers .current { + font-size: var(--text-4xl); + font-weight: var(--font-bold); + color: var(--color-text-primary); +} + +.usage-numbers .separator { + font-size: 20px; + color: var(--color-text-muted); + margin: 0 4px; +} + +.usage-numbers .max { + font-size: var(--text-xl); + color: var(--color-text-secondary); + display: flex; + align-items: center; + gap: 4px; +} + +.usage-progress { + height: 8px; + background: var(--color-bg-tertiary); + border-radius: 4px; + overflow: hidden; + margin-bottom: var(--space-sm); +} + +.usage-progress-fill { + height: 100%; + border-radius: 4px; + transition: width 0.5s ease; +} + +.usage-remaining { + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +/* ===== PLAN CARDS ===== */ +.plans-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; + align-items: stretch; +} + +.plan-card { + background: white; + border: 2px solid var(--color-border); + border-radius: var(--radius-xl); + padding: 28px; + position: relative; + transition: all var(--transition-normal); + display: flex; + flex-direction: column; + height: 100%; +} + +.plan-card:hover { + border-color: var(--color-border-dark); + box-shadow: var(--shadow-lg); +} + +.plan-card.featured { + border-color: var(--color-accent); +} + +.plan-card.current { + border-color: var(--color-primary); + background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%); +} + +/* ===== RESPONSIVE ===== */ +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: 1fr 1fr; + } + + .usage-grid { + grid-template-columns: 1fr; + } + + .plans-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/main.jsx b/src/main.jsx index c5daa96..8f70fc7 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,13 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.jsx' import { AuthProvider } from './contexts/AuthContext.jsx' createRoot(document.getElementById('root')).render( - - - + + + + + , )