Initial commit: SmoothSchedule multi-tenant scheduling platform

This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

View File

@@ -0,0 +1 @@
VITE_DEV_MODE=true

24
legacy_reference/frontend/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,159 @@
# SmoothSchedule Frontend Development Guide
## Local Development Domain Setup
### Why lvh.me instead of localhost?
This project uses **lvh.me** for local development instead of `localhost` due to cookie domain restrictions in RFC 6265.
**The Problem with localhost:**
- Browsers reject cookies with `domain=.localhost` for security reasons
- `localhost` is treated as a special-use domain where domain cookies don't work
- Cannot share cookies across subdomains like `platform.localhost` and `business.localhost`
**The Solution - lvh.me:**
- `lvh.me` is a public DNS service that resolves all `*.lvh.me` domains to `127.0.0.1`
- Browsers allow setting cookies with `domain=.lvh.me`
- Cookies are accessible across all subdomains (platform.lvh.me, business1.lvh.me, etc.)
- No /etc/hosts configuration needed - it just works!
### Development URLs
Use these URLs for local development:
- **Base domain:** `http://lvh.me:5173`
- **Platform dashboard:** `http://platform.lvh.me:5173`
- **Business subdomains:** `http://{subdomain}.lvh.me:5173`
### Multi-Tenant Architecture
The application uses subdomain-based multi-tenancy:
1. **Platform Users** (superuser, platform_manager, platform_support)
- Access the app at `http://platform.lvh.me:5173`
- See platform dashboard and administrative features
2. **Business Users** (owner, manager, staff, resource)
- Access the app at `http://{business_subdomain}.lvh.me:5173`
- See business-specific dashboard and features
### Cookie-Based Authentication
Tokens are stored in cookies with `domain=.lvh.me` to enable cross-subdomain access:
```typescript
// Set cookie accessible across all *.lvh.me subdomains
setCookie('access_token', token, 7); // domain=.lvh.me
// Cookie is accessible on:
// - platform.lvh.me:5173
// - business1.lvh.me:5173
// - business2.lvh.me:5173
// etc.
```
**Key Files:**
- `/src/utils/cookies.ts` - Cookie utilities with cross-subdomain support
- `/src/hooks/useAuth.ts` - Authentication hooks using cookies
- `/src/api/client.ts` - API client with cookie-based auth
### Login Flow
1. User navigates to `http://platform.lvh.me:5173`
2. If not authenticated, shows login page
3. User enters credentials and submits
4. Backend validates and returns JWT tokens + user data
5. Tokens stored in cookies with `domain=.lvh.me`
6. User data stored in React Query cache
7. App checks user role:
- Platform users: Stay on platform.lvh.me
- Business users: Redirect to {business_subdomain}.lvh.me
8. Cookies accessible on target subdomain - user sees dashboard
### Testing with Playwright
Tests use lvh.me for proper subdomain testing:
```typescript
// Start on platform subdomain
await page.goto('http://platform.lvh.me:5173');
// Login sets cookies with domain=.lvh.me
await page.getByPlaceholder(/username/i).fill('poduck');
await page.getByPlaceholder(/password/i).fill('starry12');
await page.getByRole('button', { name: /sign in/i }).click();
// Cookies accessible, dashboard loads
await expect(page.getByRole('heading', { name: /platform dashboard/i })).toBeVisible();
```
### Running the Development Server
```bash
# Install dependencies
npm install
# Start dev server
npm run dev
# Access at http://platform.lvh.me:5173
```
### Running Tests
```bash
# Run all tests
npm test
# Run E2E tests
npx playwright test
# Run specific test file
npx playwright test login-flow.spec.ts
# Run with UI
npx playwright test --ui
```
## Production Deployment
In production, replace `lvh.me` with your actual domain:
1. Update `src/utils/cookies.ts`:
```typescript
const domain = window.location.hostname.includes('yourdomain.com')
? '.yourdomain.com'
: window.location.hostname;
```
2. Configure DNS:
- `platform.yourdomain.com` → Your server IP
- `*.yourdomain.com` → Your server IP (wildcard for business subdomains)
3. SSL certificates:
- Get wildcard certificate for `*.yourdomain.com`
- Or use Let's Encrypt with wildcard support
## Troubleshooting
### Cookies not working?
- Make sure you're using `lvh.me`, not `localhost`
- Check browser DevTools → Application → Cookies
- Verify `domain=.lvh.me` is set on cookies
- Clear cookies and try again
### Redirect issues?
- Check `/src/pages/LoginPage.tsx` redirect logic
- Verify user role and business_subdomain in response
- Check browser console for navigation errors
### Can't access lvh.me?
- Verify internet connection (lvh.me requires DNS lookup)
- Try `ping lvh.me` - should resolve to `127.0.0.1`
- Alternative: Use `127.0.0.1.nip.io` (similar service)
## References
- [RFC 6265 - HTTP State Management Mechanism](https://datatracker.ietf.org/doc/html/rfc6265)
- [lvh.me DNS service](http://lvh.me)
- [Cookie Domain Attribute Rules](https://stackoverflow.com/questions/1062963/how-do-browser-cookie-domains-work)

View File

@@ -0,0 +1,29 @@
# Content Security Policy for Production
During development, CSP is disabled in `index.html` to avoid conflicts with browser extensions.
For production, configure CSP via server headers (nginx/CloudFlare):
```nginx
# nginx configuration
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://connect-js.stripe.com blob:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://api.stripe.com https://connect-js.stripe.com https://yourdomain.com;
frame-src 'self' https://js.stripe.com https://connect-js.stripe.com;
" always;
```
## Why not in HTML meta tag?
1. **Browser extensions interfere**: Extensions inject their own CSP rules causing false errors
2. **Dynamic configuration**: Production domains differ from development (lvh.me vs yourdomain.com)
3. **Better control**: Server headers can vary by environment without changing source code
4. **Standard practice**: Industry best practice is CSP via headers, not meta tags
## Testing CSP
Test your production CSP at: https://csp-evaluator.withgoogle.com/

View File

@@ -0,0 +1,16 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --network-timeout=600000 --retry 3
COPY . .
# Startup script that ensures node_modules are in sync with package.json
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 5173
ENTRYPOINT ["/docker-entrypoint.sh"]

View File

@@ -0,0 +1,42 @@
# Production Dockerfile for SmoothSchedule Frontend
# Multi-stage build: Build the app, then serve with nginx
# Build stage
FROM node:22-alpine as builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm ci --only=production=false --network-timeout=600000
# Copy source code
COPY . .
# Build arguments for environment variables
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
# Build the application
RUN npm run build
# Production stage - serve with nginx
FROM nginx:alpine
# Copy custom nginx config
COPY nginx.conf /etc/nginx/nginx.conf
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

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

View File

@@ -0,0 +1,423 @@
# Frontend Integration - Step by Step
## Current Status
**API Infrastructure Complete**
- API client with axios interceptors
- Authentication hooks
- All feature hooks (customers, services, resources, appointments, business)
- Playwright testing setup
- Login page component
- Example App.tsx with real auth
**Components Extracted**
- All components from zip copied to `src/*-extracted/` directories
- TypeScript types copied to `src/types.ts`
## Quick Start (5 Minutes)
### 1. Install Dependencies
```bash
cd frontend
npm install
npx playwright install
```
### 2. Start Backend
```bash
# In another terminal
cd backend
docker-compose up
docker-compose exec backend python manage.py migrate
docker-compose exec backend python manage.py createsuperuser
```
### 3. Start Frontend
```bash
npm run dev
```
### 4. Test Login
- Go to `http://lvh.me:5173`
- Login with your superuser credentials
- You should see a welcome screen with your user info!
## Integration Steps
### Step 1: Replace App.tsx
```bash
# Backup current App.tsx
mv src/App.tsx src/App.tsx.backup
# Use the integrated version
mv src/App-integrated.tsx src/App.tsx
```
### Step 2: Copy Components
```bash
# Copy all components at once
cp -r src/components-extracted/* src/components/ 2>/dev/null || true
cp -r src/layouts-extracted/* src/layouts/ 2>/dev/null || true
cp -r src/pages-extracted/* src/pages/ 2>/dev/null || true
# Or copy selectively, one feature at a time (recommended)
```
### Step 3: Update Component Imports
For each component you copy, replace mock data with API hooks.
#### Example: Dashboard.tsx
**Before:**
```typescript
import { APPOINTMENTS, SERVICES } from '../mockData';
const Dashboard = () => {
const [appointments] = useState(APPOINTMENTS);
const [services] = useState(SERVICES);
// ...
}
```
**After:**
```typescript
import { useAppointments } from '../hooks/useAppointments';
import { useServices } from '../hooks/useServices';
const Dashboard = () => {
const { data: appointments, isLoading: apptLoading } = useAppointments();
const { data: services, isLoading: servicesLoading } = useServices();
if (apptLoading || servicesLoading) {
return <div>Loading...</div>;
}
// ...
}
```
#### Example: Customers.tsx
**Before:**
```typescript
import { CUSTOMERS } from '../mockData';
const Customers = () => {
const [customers] = useState(CUSTOMERS);
const [searchTerm, setSearchTerm] = useState('');
// ...
}
```
**After:**
```typescript
import { useCustomers } from '../hooks/useCustomers';
const Customers = () => {
const [searchTerm, setSearchTerm] = useState('');
const { data: customers, isLoading } = useCustomers({ search: searchTerm });
if (isLoading) {
return <div>Loading...</div>;
}
// ...
}
```
#### Example: Scheduler.tsx
**Before:**
```typescript
import { APPOINTMENTS, RESOURCES, SERVICES } from '../mockData';
const Scheduler = () => {
const [appointments] = useState(APPOINTMENTS);
const [resources] = useState(RESOURCES);
const [services] = useState(SERVICES);
// ...
}
```
**After:**
```typescript
import { useAppointments } from '../hooks/useAppointments';
import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
const Scheduler = () => {
const { data: appointments, isLoading: apptLoading } = useAppointments();
const { data: resources, isLoading: resLoading } = useResources();
const { data: services, isLoading: servicesLoading } = useServices();
if (apptLoading || resLoading || servicesLoading) {
return <div>Loading...</div>;
}
// ...
}
```
### Step 4: Handle Create/Update/Delete Operations
For mutations, use the mutation hooks:
```typescript
import { useCreateCustomer, useUpdateCustomer, useDeleteCustomer } from '../hooks/useCustomers';
const Customers = () => {
const { data: customers } = useCustomers();
const createMutation = useCreateCustomer();
const updateMutation = useUpdateCustomer();
const deleteMutation = useDeleteCustomer();
const handleCreateCustomer = (data: any) => {
createMutation.mutate(data, {
onSuccess: () => {
console.log('Customer created!');
// Maybe close a modal or reset a form
},
onError: (error) => {
console.error('Failed to create customer:', error);
}
});
};
const handleUpdateCustomer = (id: string, updates: any) => {
updateMutation.mutate({ id, updates }, {
onSuccess: () => {
console.log('Customer updated!');
}
});
};
const handleDeleteCustomer = (id: string) => {
deleteMutation.mutate(id, {
onSuccess: () => {
console.log('Customer deleted!');
}
});
};
return (
// Your JSX with handlers
<button onClick={() => handleCreateCustomer(newCustomerData)}>
Create Customer
</button>
);
};
```
### Step 5: Update App.tsx with Full Routing
Once components are copied and updated, implement role-based routing in App.tsx:
```typescript
// Import all your pages
import Dashboard from './pages/Dashboard';
import Scheduler from './pages/Scheduler';
import Customers from './pages/Customers';
// ... etc
const AppContent = () => {
const { data: user } = useCurrentUser();
const { data: business } = useCurrentBusiness();
if (!user) return <LoginPage />;
// Platform users
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
return (
<Routes>
<Route element={<PlatformLayout user={user} />}>
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
<Route path="/platform/businesses" element={<PlatformBusinesses />} />
{/* ... more platform routes */}
</Route>
</Routes>
);
}
// Customer users
if (user.role === 'customer') {
return (
<Routes>
<Route element={<CustomerLayout business={business} user={user} />}>
<Route path="/" element={<CustomerDashboard />} />
<Route path="/book" element={<BookingPage />} />
{/* ... more customer routes */}
</Route>
</Routes>
);
}
// Business users (owner, manager, staff, resource)
return (
<Routes>
<Route element={<BusinessLayout business={business} user={user} />}>
<Route path="/" element={<Dashboard />} />
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/customers" element={<Customers />} />
<Route path="/resources" element={<Resources />} />
{/* ... more business routes */}
</Route>
</Routes>
);
};
```
## Testing with Playwright
### Run Visual Tests
```bash
# Run tests and compare with extracted frontend
npm run test:headed
# Or use UI mode for debugging
npm run test:ui
```
### Create New Tests
Create test files in `tests/e2e/`:
```typescript
// tests/e2e/customers.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Customers Page', () => {
test.beforeEach(async ({ page }) => {
// Login
await page.goto('/');
await page.getByLabel(/username/i).fill('testowner');
await page.getByLabel(/password/i).fill('testpass123');
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL(/\//);
});
test('should display customers list', async ({ page }) => {
await page.goto('/customers');
// Wait for customers to load
await page.waitForSelector('table tbody tr, [data-testid="customer-list"]');
// Take screenshot for comparison
await expect(page).toHaveScreenshot('customers-list.png');
});
test('should filter customers by status', async ({ page }) => {
await page.goto('/customers');
// Click status filter
await page.getByRole('button', { name: /filter/i }).click();
await page.getByRole('option', { name: /active/i }).click();
// Verify filtered results
const rows = await page.locator('table tbody tr').count();
expect(rows).toBeGreaterThan(0);
});
});
```
## Troubleshooting
### Issue: Types don't match
**Symptom**: TypeScript errors about id being string vs number
**Solution**: The hooks already handle transformation. Make sure you're using the hooks, not calling apiClient directly.
### Issue: Components look different from extracted version
**Symptom**: Visual differences in Playwright screenshots
**Solution**:
1. Check Tailwind classes are identical
2. Verify dark mode is applied correctly
3. Compare CSS files from extracted version
4. Use Playwright UI mode to inspect elements
### Issue: CORS errors in console
**Symptom**: "Access-Control-Allow-Origin" errors
**Solution**:
1. Verify backend is running
2. Check CORS settings in `backend/config/settings/base.py`
3. Ensure `X-Business-Subdomain` header is in CORS_ALLOW_HEADERS
### Issue: 404 on API calls
**Symptom**: API calls return 404
**Solution**:
1. Check you're using correct subdomain: `acme.lvh.me:5173`
2. Verify backend middleware is processing `X-Business-Subdomain` header
3. Check business exists in database with correct subdomain
### Issue: Data not loading
**Symptom**: Components show loading forever
**Solution**:
1. Check browser console for errors
2. Verify backend migrations are applied
3. Create test data in backend
4. Check React Query DevTools (install `@tanstack/react-query-devtools`)
## Adding React Query DevTools
For debugging:
```bash
npm install @tanstack/react-query-devtools
```
In App.tsx:
```typescript
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<QueryClientProvider client={queryClient}>
<Router>
<AppContent />
</Router>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
```
## Component Integration Checklist
- [ ] Login page works
- [ ] Dashboard displays
- [ ] Sidebar navigation works
- [ ] Dark mode toggle works
- [ ] Customers page loads data from API
- [ ] Can create new customer
- [ ] Can edit customer
- [ ] Can delete customer
- [ ] Services page loads data
- [ ] Resources page loads data
- [ ] Scheduler displays appointments
- [ ] Can create appointment
- [ ] Can drag-and-drop appointments
- [ ] Settings page loads business data
- [ ] Can update business settings
- [ ] Visual tests pass
- [ ] No console errors
## Next Steps After Basic Integration
1. **Error Boundaries**: Add error boundaries for better error handling
2. **Loading States**: Improve loading UI with skeletons
3. **Optimistic Updates**: Add optimistic updates for better UX
4. **Form Validation**: Add form validation with react-hook-form or similar
5. **Toast Notifications**: Add toast library for success/error messages
6. **Accessibility**: Add ARIA labels and keyboard navigation
7. **Performance**: Optimize re-renders with React.memo
8. **Tests**: Add more comprehensive Playwright tests
## Resources
- **Main Guide**: `../FRONTEND_INTEGRATION_GUIDE.md`
- **Backend API**: `../BACKEND_IMPLEMENTATION_SUMMARY.md`
- **All Hooks**: Check `src/hooks/` directory
- **Type Definitions**: `src/types.ts`
- **Example Components**: `src/*-extracted/` directories
You're ready to integrate! Start with the Dashboard, then move to individual features one by one.

View File

@@ -0,0 +1,17 @@
import { chromium } from 'playwright';
(async () => {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// Capture original frontend
await page.goto('http://localhost:3000/');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
await page.screenshot({ path: 'tests/e2e/original-main.png', fullPage: true });
console.log('Screenshot saved to tests/e2e/original-main.png');
await browser.close();
})();

View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -e
# Ensure node_modules are in sync with package.json
# This handles the case where the anonymous volume has stale dependencies
if [ ! -d "node_modules" ] || [ "package.json" -nt "node_modules" ]; then
echo "Installing npm packages..."
npm install --network-timeout=600000 --retry 3
fi
# Execute the CMD
exec "$@"

View File

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

View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- CSP: Disabled in development due to browser extension conflicts. Enable in production via server headers. -->
<title>Smooth Schedule - Multi-Tenant Scheduling</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Ensure full height for the app */
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
/* Custom scrollbar for the timeline */
.timeline-scroll::-webkit-scrollbar {
height: 8px;
width: 8px;
}
.timeline-scroll::-webkit-scrollbar-track {
background: #f1f1f1;
}
.dark .timeline-scroll::-webkit-scrollbar-track {
background: #1e293b;
}
.timeline-scroll::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.dark .timeline-scroll::-webkit-scrollbar-thumb {
background: #475569;
}
.timeline-scroll::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark .timeline-scroll::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
</style>
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white antialiased transition-colors duration-200">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,60 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# Compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/rss+xml application/atom+xml image/svg+xml;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Handle SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}

4647
legacy_reference/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
"@tanstack/react-query": "^5.90.10",
"axios": "^1.13.2",
"date-fns": "^4.1.0",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.5",
"react-phone-number-input": "^3.4.14",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
}
}

View File

@@ -0,0 +1,300 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- navigation [ref=e4]:
- generic [ref=e6]:
- link "Smooth Schedule" [ref=e7] [cursor=pointer]:
- /url: "#/"
- img [ref=e8]
- generic [ref=e14]: Smooth Schedule
- generic [ref=e15]:
- link "Features" [ref=e16] [cursor=pointer]:
- /url: "#/features"
- link "Pricing" [ref=e17] [cursor=pointer]:
- /url: "#/pricing"
- link "About" [ref=e18] [cursor=pointer]:
- /url: "#/about"
- link "Contact" [ref=e19] [cursor=pointer]:
- /url: "#/contact"
- generic [ref=e20]:
- button "🇺🇸 English" [ref=e23]:
- img [ref=e24]
- generic [ref=e27]: 🇺🇸
- generic [ref=e28]: English
- img [ref=e29]
- button "Switch to dark mode" [ref=e31]:
- img [ref=e32]
- link "Login" [ref=e34] [cursor=pointer]:
- /url: "#/login"
- link "Get Started" [ref=e35] [cursor=pointer]:
- /url: "#/signup"
- main [ref=e36]:
- generic [ref=e37]:
- generic [ref=e42]:
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e47]: Get started today
- heading "Scheduling Made Simple" [level=1] [ref=e48]
- paragraph [ref=e49]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
- generic [ref=e50]:
- link "Get Started Free" [ref=e51] [cursor=pointer]:
- /url: "#/signup"
- text: Get Started Free
- img [ref=e52]
- button "Watch Demo" [ref=e54]:
- img [ref=e55]
- text: Watch Demo
- generic [ref=e57]:
- generic [ref=e58]:
- img [ref=e59]
- generic [ref=e62]: No credit card required
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Get started today
- generic [ref=e69]:
- generic [ref=e71]:
- generic [ref=e78]: dashboard.smoothschedule.com
- generic [ref=e79]:
- generic [ref=e80]:
- generic [ref=e81]:
- generic [ref=e82]: Today
- generic [ref=e83]: "12"
- generic [ref=e84]:
- generic [ref=e85]: This Week
- generic [ref=e86]: "48"
- generic [ref=e87]:
- generic [ref=e88]: Revenue
- generic [ref=e89]: $2.4k
- generic [ref=e90]:
- generic [ref=e91]: Today's Schedule
- generic [ref=e92]:
- generic [ref=e95]:
- generic [ref=e96]: 9:00 AM
- generic [ref=e97]: Sarah J. - Haircut
- generic [ref=e100]:
- generic [ref=e101]: 10:30 AM
- generic [ref=e102]: Mike T. - Consultation
- generic [ref=e105]:
- generic [ref=e106]: 2:00 PM
- generic [ref=e107]: Emma W. - Color
- generic [ref=e109]:
- img [ref=e111]
- generic [ref=e114]:
- generic [ref=e115]: New Booking!
- generic [ref=e116]: Just now
- generic [ref=e117]:
- paragraph [ref=e118]: Trusted by 1,000+ businesses worldwide
- generic [ref=e119]:
- generic [ref=e120]: TechCorp
- generic [ref=e121]: Innovate
- generic [ref=e122]: StartupX
- generic [ref=e123]: GrowthCo
- generic [ref=e124]: ScaleUp
- generic [ref=e126]:
- generic [ref=e127]:
- heading "Everything You Need" [level=2] [ref=e128]
- paragraph [ref=e129]: Powerful features to run your service business
- generic [ref=e130]:
- generic [ref=e131]:
- img [ref=e133]
- heading "Smart Scheduling" [level=3] [ref=e135]
- paragraph [ref=e136]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
- generic [ref=e137]:
- img [ref=e139]
- heading "Resource Management" [level=3] [ref=e144]
- paragraph [ref=e145]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
- generic [ref=e146]:
- img [ref=e148]
- heading "Customer Portal" [level=3] [ref=e152]
- paragraph [ref=e153]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
- generic [ref=e154]:
- img [ref=e156]
- heading "Integrated Payments" [level=3] [ref=e158]
- paragraph [ref=e159]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
- generic [ref=e160]:
- img [ref=e162]
- heading "Multi-Location Support" [level=3] [ref=e166]
- paragraph [ref=e167]: Manage multiple locations or brands from a single dashboard with isolated data.
- generic [ref=e168]:
- img [ref=e170]
- heading "White-Label Ready" [level=3] [ref=e176]
- paragraph [ref=e177]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
- link "View All features" [ref=e179] [cursor=pointer]:
- /url: "#/features"
- text: View All features
- img [ref=e180]
- generic [ref=e183]:
- generic [ref=e184]:
- heading "Get Started in Minutes" [level=2] [ref=e185]
- paragraph [ref=e186]: Three simple steps to transform your scheduling
- generic [ref=e187]:
- generic [ref=e190]:
- generic [ref=e191]: "01"
- img [ref=e193]
- heading "Create Your Account" [level=3] [ref=e196]
- paragraph [ref=e197]: Sign up for free and set up your business profile in minutes.
- generic [ref=e200]:
- generic [ref=e201]: "02"
- img [ref=e203]
- heading "Add Your Services" [level=3] [ref=e206]
- paragraph [ref=e207]: Configure your services, pricing, and available resources.
- generic [ref=e209]:
- generic [ref=e210]: "03"
- img [ref=e212]
- heading "Start Booking" [level=3] [ref=e217]
- paragraph [ref=e218]: Share your booking link and let customers schedule instantly.
- generic [ref=e221]:
- generic [ref=e222]:
- img [ref=e224]
- generic [ref=e226]: 1M+
- generic [ref=e227]: Appointments Scheduled
- generic [ref=e228]:
- img [ref=e230]
- generic [ref=e234]: 5,000+
- generic [ref=e235]: Businesses
- generic [ref=e236]:
- img [ref=e238]
- generic [ref=e241]: 50+
- generic [ref=e242]: Countries
- generic [ref=e243]:
- img [ref=e245]
- generic [ref=e248]: 99.9%
- generic [ref=e249]: Uptime
- generic [ref=e251]:
- generic [ref=e252]:
- heading "Loved by Businesses Everywhere" [level=2] [ref=e253]
- paragraph [ref=e254]: See what our customers have to say
- generic [ref=e255]:
- generic [ref=e256]:
- generic [ref=e257]:
- img [ref=e258]
- img [ref=e260]
- img [ref=e262]
- img [ref=e264]
- img [ref=e266]
- blockquote [ref=e268]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
- generic [ref=e269]:
- generic [ref=e271]: S
- generic [ref=e272]:
- generic [ref=e273]: Sarah Johnson
- generic [ref=e274]: Owner at Luxe Salon
- generic [ref=e275]:
- generic [ref=e276]:
- img [ref=e277]
- img [ref=e279]
- img [ref=e281]
- img [ref=e283]
- img [ref=e285]
- blockquote [ref=e287]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
- generic [ref=e288]:
- generic [ref=e290]: M
- generic [ref=e291]:
- generic [ref=e292]: Michael Chen
- generic [ref=e293]: CEO at FitLife Studios
- generic [ref=e294]:
- generic [ref=e295]:
- img [ref=e296]
- img [ref=e298]
- img [ref=e300]
- img [ref=e302]
- img [ref=e304]
- blockquote [ref=e306]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
- generic [ref=e307]:
- generic [ref=e309]: E
- generic [ref=e310]:
- generic [ref=e311]: Emily Rodriguez
- generic [ref=e312]: Manager at Peak Performance Therapy
- generic [ref=e314]:
- generic [ref=e315]:
- heading "Simple, Transparent Pricing" [level=2] [ref=e316]
- paragraph [ref=e317]: Start free, upgrade as you grow. No hidden fees.
- generic [ref=e318]:
- generic [ref=e319]:
- heading "Free" [level=3] [ref=e320]
- paragraph [ref=e321]: Perfect for getting started
- generic [ref=e322]: $0/month
- link "Get Started" [ref=e323] [cursor=pointer]:
- /url: "#/signup"
- generic [ref=e324]:
- generic [ref=e325]: Most Popular
- heading "Professional" [level=3] [ref=e326]
- paragraph [ref=e327]: For growing businesses
- generic [ref=e328]: $29/month
- link "Get Started" [ref=e329] [cursor=pointer]:
- /url: "#/signup"
- generic [ref=e330]:
- heading "Business" [level=3] [ref=e331]
- paragraph [ref=e332]: For established teams
- generic [ref=e333]: $79/month
- link "Get Started" [ref=e334] [cursor=pointer]:
- /url: "#/signup"
- link "View full pricing details" [ref=e336] [cursor=pointer]:
- /url: "#/pricing"
- text: View full pricing details
- img [ref=e337]
- generic [ref=e343]:
- heading "Ready to get started?" [level=2] [ref=e344]
- paragraph [ref=e345]: Join thousands of businesses already using SmoothSchedule.
- generic [ref=e346]:
- link "Get Started Free" [ref=e347] [cursor=pointer]:
- /url: "#/signup"
- text: Get Started Free
- img [ref=e348]
- link "Talk to Sales" [ref=e350] [cursor=pointer]:
- /url: "#/contact"
- paragraph [ref=e351]: No credit card required
- contentinfo [ref=e352]:
- generic [ref=e353]:
- generic [ref=e354]:
- generic [ref=e355]:
- link "Smooth Schedule" [ref=e356] [cursor=pointer]:
- /url: "#/"
- img [ref=e357]
- generic [ref=e363]: Smooth Schedule
- paragraph [ref=e364]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
- generic [ref=e365]:
- link "Twitter" [ref=e366] [cursor=pointer]:
- /url: https://twitter.com/smoothschedule
- img [ref=e367]
- link "LinkedIn" [ref=e369] [cursor=pointer]:
- /url: https://linkedin.com/company/smoothschedule
- img [ref=e370]
- link "GitHub" [ref=e374] [cursor=pointer]:
- /url: https://github.com/smoothschedule
- img [ref=e375]
- link "YouTube" [ref=e378] [cursor=pointer]:
- /url: https://youtube.com/@smoothschedule
- img [ref=e379]
- generic [ref=e382]:
- heading "Product" [level=3] [ref=e383]
- list [ref=e384]:
- listitem [ref=e385]:
- link "Features" [ref=e386] [cursor=pointer]:
- /url: "#/features"
- listitem [ref=e387]:
- link "Pricing" [ref=e388] [cursor=pointer]:
- /url: "#/pricing"
- listitem [ref=e389]:
- link "Get Started" [ref=e390] [cursor=pointer]:
- /url: "#/signup"
- generic [ref=e391]:
- heading "Company" [level=3] [ref=e392]
- list [ref=e393]:
- listitem [ref=e394]:
- link "About" [ref=e395] [cursor=pointer]:
- /url: "#/about"
- listitem [ref=e396]:
- link "Contact" [ref=e397] [cursor=pointer]:
- /url: "#/contact"
- generic [ref=e398]:
- heading "Legal" [level=3] [ref=e399]
- list [ref=e400]:
- listitem [ref=e401]:
- link "Privacy Policy" [ref=e402] [cursor=pointer]:
- /url: "#/privacy"
- listitem [ref=e403]:
- link "Terms of Service" [ref=e404] [cursor=pointer]:
- /url: "#/terms"
- paragraph [ref=e406]: © 2025 Smooth Schedule Inc. All rights reserved.
```

View File

@@ -0,0 +1,300 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- navigation [ref=e4]:
- generic [ref=e6]:
- link "Smooth Schedule" [ref=e7] [cursor=pointer]:
- /url: "#/"
- img [ref=e8]
- generic [ref=e14]: Smooth Schedule
- generic [ref=e15]:
- link "Features" [ref=e16] [cursor=pointer]:
- /url: "#/features"
- link "Pricing" [ref=e17] [cursor=pointer]:
- /url: "#/pricing"
- link "About" [ref=e18] [cursor=pointer]:
- /url: "#/about"
- link "Contact" [ref=e19] [cursor=pointer]:
- /url: "#/contact"
- generic [ref=e20]:
- button "🇺🇸 English" [ref=e23]:
- img [ref=e24]
- generic [ref=e28]: 🇺🇸
- generic [ref=e29]: English
- img [ref=e30]
- button "Switch to dark mode" [ref=e32]:
- img [ref=e33]
- link "Login" [ref=e35] [cursor=pointer]:
- /url: "#/login"
- link "Get Started" [ref=e36] [cursor=pointer]:
- /url: "#/signup"
- main [ref=e37]:
- generic [ref=e38]:
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]:
- generic [ref=e48]: Get started today
- heading "Scheduling Made Simple" [level=1] [ref=e49]
- paragraph [ref=e50]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
- generic [ref=e51]:
- link "Get Started Free" [ref=e52] [cursor=pointer]:
- /url: "#/signup"
- text: Get Started Free
- img [ref=e53]
- button "Watch Demo" [ref=e56]:
- img [ref=e57]
- text: Watch Demo
- generic [ref=e59]:
- generic [ref=e60]:
- img [ref=e61]
- generic [ref=e64]: No credit card required
- generic [ref=e66]:
- img [ref=e67]
- generic [ref=e70]: Get started today
- generic [ref=e71]:
- generic [ref=e73]:
- generic [ref=e80]: dashboard.smoothschedule.com
- generic [ref=e81]:
- generic [ref=e82]:
- generic [ref=e83]:
- generic [ref=e84]: Today
- generic [ref=e85]: "12"
- generic [ref=e86]:
- generic [ref=e87]: This Week
- generic [ref=e88]: "48"
- generic [ref=e89]:
- generic [ref=e90]: Revenue
- generic [ref=e91]: $2.4k
- generic [ref=e92]:
- generic [ref=e93]: Today's Schedule
- generic [ref=e94]:
- generic [ref=e97]:
- generic [ref=e98]: 9:00 AM
- generic [ref=e99]: Sarah J. - Haircut
- generic [ref=e102]:
- generic [ref=e103]: 10:30 AM
- generic [ref=e104]: Mike T. - Consultation
- generic [ref=e107]:
- generic [ref=e108]: 2:00 PM
- generic [ref=e109]: Emma W. - Color
- generic [ref=e111]:
- img [ref=e113]
- generic [ref=e116]:
- generic [ref=e117]: New Booking!
- generic [ref=e118]: Just now
- generic [ref=e119]:
- paragraph [ref=e120]: Trusted by 1,000+ businesses worldwide
- generic [ref=e121]:
- generic [ref=e122]: TechCorp
- generic [ref=e123]: Innovate
- generic [ref=e124]: StartupX
- generic [ref=e125]: GrowthCo
- generic [ref=e126]: ScaleUp
- generic [ref=e128]:
- generic [ref=e129]:
- heading "Everything You Need" [level=2] [ref=e130]
- paragraph [ref=e131]: Powerful features to run your service business
- generic [ref=e132]:
- generic [ref=e133]:
- img [ref=e135]
- heading "Smart Scheduling" [level=3] [ref=e140]
- paragraph [ref=e141]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
- generic [ref=e142]:
- img [ref=e144]
- heading "Resource Management" [level=3] [ref=e149]
- paragraph [ref=e150]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
- generic [ref=e151]:
- img [ref=e153]
- heading "Customer Portal" [level=3] [ref=e157]
- paragraph [ref=e158]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
- generic [ref=e159]:
- img [ref=e161]
- heading "Integrated Payments" [level=3] [ref=e164]
- paragraph [ref=e165]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
- generic [ref=e166]:
- img [ref=e168]
- heading "Multi-Location Support" [level=3] [ref=e174]
- paragraph [ref=e175]: Manage multiple locations or brands from a single dashboard with isolated data.
- generic [ref=e176]:
- img [ref=e178]
- heading "White-Label Ready" [level=3] [ref=e184]
- paragraph [ref=e185]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
- link "View All features" [ref=e187] [cursor=pointer]:
- /url: "#/features"
- text: View All features
- img [ref=e188]
- generic [ref=e192]:
- generic [ref=e193]:
- heading "Get Started in Minutes" [level=2] [ref=e194]
- paragraph [ref=e195]: Three simple steps to transform your scheduling
- generic [ref=e196]:
- generic [ref=e199]:
- generic [ref=e200]: "01"
- img [ref=e202]
- heading "Create Your Account" [level=3] [ref=e207]
- paragraph [ref=e208]: Sign up for free and set up your business profile in minutes.
- generic [ref=e211]:
- generic [ref=e212]: "02"
- img [ref=e214]
- heading "Add Your Services" [level=3] [ref=e217]
- paragraph [ref=e218]: Configure your services, pricing, and available resources.
- generic [ref=e220]:
- generic [ref=e221]: "03"
- img [ref=e223]
- heading "Start Booking" [level=3] [ref=e228]
- paragraph [ref=e229]: Share your booking link and let customers schedule instantly.
- generic [ref=e232]:
- generic [ref=e233]:
- img [ref=e235]
- generic [ref=e240]: 1M+
- generic [ref=e241]: Appointments Scheduled
- generic [ref=e242]:
- img [ref=e244]
- generic [ref=e250]: 5,000+
- generic [ref=e251]: Businesses
- generic [ref=e252]:
- img [ref=e254]
- generic [ref=e258]: 50+
- generic [ref=e259]: Countries
- generic [ref=e260]:
- img [ref=e262]
- generic [ref=e265]: 99.9%
- generic [ref=e266]: Uptime
- generic [ref=e268]:
- generic [ref=e269]:
- heading "Loved by Businesses Everywhere" [level=2] [ref=e270]
- paragraph [ref=e271]: See what our customers have to say
- generic [ref=e272]:
- generic [ref=e273]:
- generic [ref=e274]:
- img [ref=e275]
- img [ref=e277]
- img [ref=e279]
- img [ref=e281]
- img [ref=e283]
- blockquote [ref=e285]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
- generic [ref=e286]:
- generic [ref=e288]: S
- generic [ref=e289]:
- generic [ref=e290]: Sarah Johnson
- generic [ref=e291]: Owner at Luxe Salon
- generic [ref=e292]:
- generic [ref=e293]:
- img [ref=e294]
- img [ref=e296]
- img [ref=e298]
- img [ref=e300]
- img [ref=e302]
- blockquote [ref=e304]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
- generic [ref=e305]:
- generic [ref=e307]: M
- generic [ref=e308]:
- generic [ref=e309]: Michael Chen
- generic [ref=e310]: CEO at FitLife Studios
- generic [ref=e311]:
- generic [ref=e312]:
- img [ref=e313]
- img [ref=e315]
- img [ref=e317]
- img [ref=e319]
- img [ref=e321]
- blockquote [ref=e323]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
- generic [ref=e324]:
- generic [ref=e326]: E
- generic [ref=e327]:
- generic [ref=e328]: Emily Rodriguez
- generic [ref=e329]: Manager at Peak Performance Therapy
- generic [ref=e331]:
- generic [ref=e332]:
- heading "Simple, Transparent Pricing" [level=2] [ref=e333]
- paragraph [ref=e334]: Start free, upgrade as you grow. No hidden fees.
- generic [ref=e335]:
- generic [ref=e336]:
- heading "Free" [level=3] [ref=e337]
- paragraph [ref=e338]: Perfect for getting started
- generic [ref=e339]: $0/month
- link "Get Started" [ref=e340] [cursor=pointer]:
- /url: "#/signup"
- generic [ref=e341]:
- generic [ref=e342]: Most Popular
- heading "Professional" [level=3] [ref=e343]
- paragraph [ref=e344]: For growing businesses
- generic [ref=e345]: $29/month
- link "Get Started" [ref=e346] [cursor=pointer]:
- /url: "#/signup"
- generic [ref=e347]:
- heading "Business" [level=3] [ref=e348]
- paragraph [ref=e349]: For established teams
- generic [ref=e350]: $79/month
- link "Get Started" [ref=e351] [cursor=pointer]:
- /url: "#/signup"
- link "View full pricing details" [ref=e353] [cursor=pointer]:
- /url: "#/pricing"
- text: View full pricing details
- img [ref=e354]
- generic [ref=e361]:
- heading "Ready to get started?" [level=2] [ref=e362]
- paragraph [ref=e363]: Join thousands of businesses already using SmoothSchedule.
- generic [ref=e364]:
- link "Get Started Free" [ref=e365] [cursor=pointer]:
- /url: "#/signup"
- text: Get Started Free
- img [ref=e366]
- link "Talk to Sales" [ref=e369] [cursor=pointer]:
- /url: "#/contact"
- paragraph [ref=e370]: No credit card required
- contentinfo [ref=e371]:
- generic [ref=e372]:
- generic [ref=e373]:
- generic [ref=e374]:
- link "Smooth Schedule" [ref=e375] [cursor=pointer]:
- /url: "#/"
- img [ref=e376]
- generic [ref=e382]: Smooth Schedule
- paragraph [ref=e383]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
- generic [ref=e384]:
- link "Twitter" [ref=e385] [cursor=pointer]:
- /url: https://twitter.com/smoothschedule
- img [ref=e386]
- link "LinkedIn" [ref=e388] [cursor=pointer]:
- /url: https://linkedin.com/company/smoothschedule
- img [ref=e389]
- link "GitHub" [ref=e393] [cursor=pointer]:
- /url: https://github.com/smoothschedule
- img [ref=e394]
- link "YouTube" [ref=e397] [cursor=pointer]:
- /url: https://youtube.com/@smoothschedule
- img [ref=e398]
- generic [ref=e401]:
- heading "Product" [level=3] [ref=e402]
- list [ref=e403]:
- listitem [ref=e404]:
- link "Features" [ref=e405] [cursor=pointer]:
- /url: "#/features"
- listitem [ref=e406]:
- link "Pricing" [ref=e407] [cursor=pointer]:
- /url: "#/pricing"
- listitem [ref=e408]:
- link "Get Started" [ref=e409] [cursor=pointer]:
- /url: "#/signup"
- generic [ref=e410]:
- heading "Company" [level=3] [ref=e411]
- list [ref=e412]:
- listitem [ref=e413]:
- link "About" [ref=e414] [cursor=pointer]:
- /url: "#/about"
- listitem [ref=e415]:
- link "Contact" [ref=e416] [cursor=pointer]:
- /url: "#/contact"
- generic [ref=e417]:
- heading "Legal" [level=3] [ref=e418]
- list [ref=e419]:
- listitem [ref=e420]:
- link "Privacy Policy" [ref=e421] [cursor=pointer]:
- /url: "#/privacy"
- listitem [ref=e422]:
- link "Terms of Service" [ref=e423] [cursor=pointer]:
- /url: "#/terms"
- paragraph [ref=e425]: © 2025 Smooth Schedule Inc. All rights reserved.
```

View File

@@ -0,0 +1,300 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- navigation [ref=e4]:
- generic [ref=e6]:
- link "Smooth Schedule" [ref=e7]:
- /url: "#/"
- img [ref=e8]
- generic [ref=e14]: Smooth Schedule
- generic [ref=e15]:
- link "Features" [ref=e16]:
- /url: "#/features"
- link "Pricing" [ref=e17]:
- /url: "#/pricing"
- link "About" [ref=e18]:
- /url: "#/about"
- link "Contact" [ref=e19]:
- /url: "#/contact"
- generic [ref=e20]:
- button "🇺🇸 English" [ref=e23]:
- img [ref=e24]
- generic [ref=e27]: 🇺🇸
- generic [ref=e28]: English
- img [ref=e29]
- button "Switch to dark mode" [ref=e31]:
- img [ref=e32]
- link "Login" [ref=e34]:
- /url: "#/login"
- link "Get Started" [ref=e35]:
- /url: "#/signup"
- main [ref=e36]:
- generic [ref=e37]:
- generic [ref=e42]:
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e47]: Get started today
- heading "Scheduling Made Simple" [level=1] [ref=e48]
- paragraph [ref=e49]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
- generic [ref=e50]:
- link "Get Started Free" [ref=e51]:
- /url: "#/signup"
- text: Get Started Free
- img [ref=e52]
- button "Watch Demo" [ref=e54]:
- img [ref=e55]
- text: Watch Demo
- generic [ref=e57]:
- generic [ref=e58]:
- img [ref=e59]
- generic [ref=e62]: No credit card required
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Get started today
- generic [ref=e69]:
- generic [ref=e71]:
- generic [ref=e78]: dashboard.smoothschedule.com
- generic [ref=e79]:
- generic [ref=e80]:
- generic [ref=e81]:
- generic [ref=e82]: Today
- generic [ref=e83]: "12"
- generic [ref=e84]:
- generic [ref=e85]: This Week
- generic [ref=e86]: "48"
- generic [ref=e87]:
- generic [ref=e88]: Revenue
- generic [ref=e89]: $2.4k
- generic [ref=e90]:
- generic [ref=e91]: Today's Schedule
- generic [ref=e92]:
- generic [ref=e95]:
- generic [ref=e96]: 9:00 AM
- generic [ref=e97]: Sarah J. - Haircut
- generic [ref=e100]:
- generic [ref=e101]: 10:30 AM
- generic [ref=e102]: Mike T. - Consultation
- generic [ref=e105]:
- generic [ref=e106]: 2:00 PM
- generic [ref=e107]: Emma W. - Color
- generic [ref=e109]:
- img [ref=e111]
- generic [ref=e114]:
- generic [ref=e115]: New Booking!
- generic [ref=e116]: Just now
- generic [ref=e117]:
- paragraph [ref=e118]: Trusted by 1,000+ businesses worldwide
- generic [ref=e119]:
- generic [ref=e120]: TechCorp
- generic [ref=e121]: Innovate
- generic [ref=e122]: StartupX
- generic [ref=e123]: GrowthCo
- generic [ref=e124]: ScaleUp
- generic [ref=e126]:
- generic [ref=e127]:
- heading "Everything You Need" [level=2] [ref=e128]
- paragraph [ref=e129]: Powerful features to run your service business
- generic [ref=e130]:
- generic [ref=e131]:
- img [ref=e133]
- heading "Smart Scheduling" [level=3] [ref=e135]
- paragraph [ref=e136]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
- generic [ref=e137]:
- img [ref=e139]
- heading "Resource Management" [level=3] [ref=e144]
- paragraph [ref=e145]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
- generic [ref=e146]:
- img [ref=e148]
- heading "Customer Portal" [level=3] [ref=e152]
- paragraph [ref=e153]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
- generic [ref=e154]:
- img [ref=e156]
- heading "Integrated Payments" [level=3] [ref=e158]
- paragraph [ref=e159]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
- generic [ref=e160]:
- img [ref=e162]
- heading "Multi-Location Support" [level=3] [ref=e166]
- paragraph [ref=e167]: Manage multiple locations or brands from a single dashboard with isolated data.
- generic [ref=e168]:
- img [ref=e170]
- heading "White-Label Ready" [level=3] [ref=e176]
- paragraph [ref=e177]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
- link "View All features" [ref=e179]:
- /url: "#/features"
- text: View All features
- img [ref=e180]
- generic [ref=e183]:
- generic [ref=e184]:
- heading "Get Started in Minutes" [level=2] [ref=e185]
- paragraph [ref=e186]: Three simple steps to transform your scheduling
- generic [ref=e187]:
- generic [ref=e190]:
- generic [ref=e191]: "01"
- img [ref=e193]
- heading "Create Your Account" [level=3] [ref=e196]
- paragraph [ref=e197]: Sign up for free and set up your business profile in minutes.
- generic [ref=e200]:
- generic [ref=e201]: "02"
- img [ref=e203]
- heading "Add Your Services" [level=3] [ref=e206]
- paragraph [ref=e207]: Configure your services, pricing, and available resources.
- generic [ref=e209]:
- generic [ref=e210]: "03"
- img [ref=e212]
- heading "Start Booking" [level=3] [ref=e217]
- paragraph [ref=e218]: Share your booking link and let customers schedule instantly.
- generic [ref=e221]:
- generic [ref=e222]:
- img [ref=e224]
- generic [ref=e226]: 1M+
- generic [ref=e227]: Appointments Scheduled
- generic [ref=e228]:
- img [ref=e230]
- generic [ref=e234]: 5,000+
- generic [ref=e235]: Businesses
- generic [ref=e236]:
- img [ref=e238]
- generic [ref=e241]: 50+
- generic [ref=e242]: Countries
- generic [ref=e243]:
- img [ref=e245]
- generic [ref=e248]: 99.9%
- generic [ref=e249]: Uptime
- generic [ref=e251]:
- generic [ref=e252]:
- heading "Loved by Businesses Everywhere" [level=2] [ref=e253]
- paragraph [ref=e254]: See what our customers have to say
- generic [ref=e255]:
- generic [ref=e256]:
- generic [ref=e257]:
- img [ref=e258]
- img [ref=e260]
- img [ref=e262]
- img [ref=e264]
- img [ref=e266]
- blockquote [ref=e268]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
- generic [ref=e269]:
- generic [ref=e271]: S
- generic [ref=e272]:
- generic [ref=e273]: Sarah Johnson
- generic [ref=e274]: Owner at Luxe Salon
- generic [ref=e275]:
- generic [ref=e276]:
- img [ref=e277]
- img [ref=e279]
- img [ref=e281]
- img [ref=e283]
- img [ref=e285]
- blockquote [ref=e287]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
- generic [ref=e288]:
- generic [ref=e290]: M
- generic [ref=e291]:
- generic [ref=e292]: Michael Chen
- generic [ref=e293]: CEO at FitLife Studios
- generic [ref=e294]:
- generic [ref=e295]:
- img [ref=e296]
- img [ref=e298]
- img [ref=e300]
- img [ref=e302]
- img [ref=e304]
- blockquote [ref=e306]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
- generic [ref=e307]:
- generic [ref=e309]: E
- generic [ref=e310]:
- generic [ref=e311]: Emily Rodriguez
- generic [ref=e312]: Manager at Peak Performance Therapy
- generic [ref=e314]:
- generic [ref=e315]:
- heading "Simple, Transparent Pricing" [level=2] [ref=e316]
- paragraph [ref=e317]: Start free, upgrade as you grow. No hidden fees.
- generic [ref=e318]:
- generic [ref=e319]:
- heading "Free" [level=3] [ref=e320]
- paragraph [ref=e321]: Perfect for getting started
- generic [ref=e322]: $0/month
- link "Get Started" [ref=e323]:
- /url: "#/signup"
- generic [ref=e324]:
- generic [ref=e325]: Most Popular
- heading "Professional" [level=3] [ref=e326]
- paragraph [ref=e327]: For growing businesses
- generic [ref=e328]: $29/month
- link "Get Started" [ref=e329]:
- /url: "#/signup"
- generic [ref=e330]:
- heading "Business" [level=3] [ref=e331]
- paragraph [ref=e332]: For established teams
- generic [ref=e333]: $79/month
- link "Get Started" [ref=e334]:
- /url: "#/signup"
- link "View full pricing details" [ref=e336]:
- /url: "#/pricing"
- text: View full pricing details
- img [ref=e337]
- generic [ref=e343]:
- heading "Ready to get started?" [level=2] [ref=e344]
- paragraph [ref=e345]: Join thousands of businesses already using SmoothSchedule.
- generic [ref=e346]:
- link "Get Started Free" [ref=e347]:
- /url: "#/signup"
- text: Get Started Free
- img [ref=e348]
- link "Talk to Sales" [ref=e350]:
- /url: "#/contact"
- paragraph [ref=e351]: No credit card required
- contentinfo [ref=e352]:
- generic [ref=e353]:
- generic [ref=e354]:
- generic [ref=e355]:
- link "Smooth Schedule" [ref=e356]:
- /url: "#/"
- img [ref=e357]
- generic [ref=e363]: Smooth Schedule
- paragraph [ref=e364]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
- generic [ref=e365]:
- link "Twitter" [ref=e366]:
- /url: https://twitter.com/smoothschedule
- img [ref=e367]
- link "LinkedIn" [ref=e369]:
- /url: https://linkedin.com/company/smoothschedule
- img [ref=e370]
- link "GitHub" [ref=e374]:
- /url: https://github.com/smoothschedule
- img [ref=e375]
- link "YouTube" [ref=e378]:
- /url: https://youtube.com/@smoothschedule
- img [ref=e379]
- generic [ref=e382]:
- heading "Product" [level=3] [ref=e383]
- list [ref=e384]:
- listitem [ref=e385]:
- link "Features" [ref=e386]:
- /url: "#/features"
- listitem [ref=e387]:
- link "Pricing" [ref=e388]:
- /url: "#/pricing"
- listitem [ref=e389]:
- link "Get Started" [ref=e390]:
- /url: "#/signup"
- generic [ref=e391]:
- heading "Company" [level=3] [ref=e392]
- list [ref=e393]:
- listitem [ref=e394]:
- link "About" [ref=e395]:
- /url: "#/about"
- listitem [ref=e396]:
- link "Contact" [ref=e397]:
- /url: "#/contact"
- generic [ref=e398]:
- heading "Legal" [level=3] [ref=e399]
- list [ref=e400]:
- listitem [ref=e401]:
- link "Privacy Policy" [ref=e402]:
- /url: "#/privacy"
- listitem [ref=e403]:
- link "Terms of Service" [ref=e404]:
- /url: "#/terms"
- paragraph [ref=e406]: © 2025 Smooth Schedule Inc. All rights reserved.
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,58 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for SmoothSchedule
* Tests the React frontend with proper subdomain support
*/
export default defineConfig({
testDir: './tests/e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Reporter to use */
reporter: 'html',
/* Shared settings for all the projects below */
use: {
/* Base URL for all tests */
baseURL: 'http://lvh.me:5174',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
/* Screenshot on failure */
screenshot: 'only-on-failure',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://lvh.me:5174',
reuseExistingServer: !process.env.CI,
},
});

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar for timeline */
.timeline-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.timeline-scroll::-webkit-scrollbar-track {
background: transparent;
}
.timeline-scroll::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 4px;
}
.timeline-scroll::-webkit-scrollbar-thumb:hover {
background: #a0aec0;
}
/* Dark mode scrollbar */
.dark .timeline-scroll::-webkit-scrollbar-thumb {
background: #4a5568;
}
.dark .timeline-scroll::-webkit-scrollbar-thumb:hover {
background: #68768a;
}
/* Preserve existing app styles */
.app {
min-height: 100vh;
}

View File

@@ -0,0 +1,555 @@
/**
* Main App Component - Integrated with Real API
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
import { useCurrentBusiness } from './hooks/useBusiness';
import { useUpdateBusiness } from './hooks/useBusiness';
import { setCookie } from './utils/cookies';
// Import Login Page
import LoginPage from './pages/LoginPage';
import OAuthCallback from './pages/OAuthCallback';
// Import layouts
import BusinessLayout from './layouts/BusinessLayout';
import PlatformLayout from './layouts/PlatformLayout';
import CustomerLayout from './layouts/CustomerLayout';
import MarketingLayout from './layouts/MarketingLayout';
// Import marketing pages
import HomePage from './pages/marketing/HomePage';
import FeaturesPage from './pages/marketing/FeaturesPage';
import PricingPage from './pages/marketing/PricingPage';
import AboutPage from './pages/marketing/AboutPage';
import ContactPage from './pages/marketing/ContactPage';
import SignupPage from './pages/marketing/SignupPage';
// Import pages
import Dashboard from './pages/Dashboard';
import Scheduler from './pages/Scheduler';
import Customers from './pages/Customers';
import Settings from './pages/Settings';
import Payments from './pages/Payments';
import Resources from './pages/Resources';
import Services from './pages/Services';
import Staff from './pages/Staff';
import CustomerDashboard from './pages/customer/CustomerDashboard';
import ResourceDashboard from './pages/resource/ResourceDashboard';
import BookingPage from './pages/customer/BookingPage';
import TrialExpired from './pages/TrialExpired';
import Upgrade from './pages/Upgrade';
// Import platform pages
import PlatformDashboard from './pages/platform/PlatformDashboard';
import PlatformBusinesses from './pages/platform/PlatformBusinesses';
import PlatformSupport from './pages/platform/PlatformSupport';
import PlatformUsers from './pages/platform/PlatformUsers';
import PlatformSettings from './pages/platform/PlatformSettings';
import ProfileSettings from './pages/ProfileSettings';
import VerifyEmail from './pages/VerifyEmail';
import EmailVerificationRequired from './pages/EmailVerificationRequired';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30000, // 30 seconds
},
},
});
/**
* Loading Component
*/
const LoadingScreen: React.FC = () => {
const { t } = useTranslation();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">{t('common.loading')}</p>
</div>
</div>
);
};
/**
* Error Component
*/
const ErrorScreen: React.FC<{ error: Error }> = ({ error }) => {
const { t } = useTranslation();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md">
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">{t('common.error')}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">{error.message}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{t('common.reload')}
</button>
</div>
</div>
);
};
/**
* App Content - Handles routing based on auth state
*/
const AppContent: React.FC = () => {
// Check for tokens in URL FIRST - before any queries execute
// This handles login/masquerade redirects that pass tokens in the URL
const [processingUrlTokens] = useState(() => {
const params = new URLSearchParams(window.location.search);
return !!(params.get('access_token') && params.get('refresh_token'));
});
const { data: user, isLoading: userLoading, error: userError } = useCurrentUser();
const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness();
const [darkMode, setDarkMode] = useState(false);
const updateBusinessMutation = useUpdateBusiness();
const masqueradeMutation = useMasquerade();
const logoutMutation = useLogout();
// Apply dark mode class
React.useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode);
}, [darkMode]);
// Handle tokens in URL (from login or masquerade redirect)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
if (accessToken && refreshToken) {
// Extract masquerade stack if present (for masquerade banner)
const masqueradeStackParam = params.get('masquerade_stack');
if (masqueradeStackParam) {
try {
const masqueradeStack = JSON.parse(decodeURIComponent(masqueradeStackParam));
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
} catch (e) {
console.error('Failed to parse masquerade stack', e);
}
}
// For backward compatibility, also check for original_user parameter
const originalUserParam = params.get('original_user');
if (originalUserParam && !masqueradeStackParam) {
try {
const originalUser = JSON.parse(decodeURIComponent(originalUserParam));
// Convert old format to new stack format (single entry)
const stack = [{
user_id: originalUser.id,
username: originalUser.username,
role: originalUser.role,
business_id: originalUser.business,
business_subdomain: originalUser.business_subdomain,
}];
localStorage.setItem('masquerade_stack', JSON.stringify(stack));
} catch (e) {
console.error('Failed to parse original user', e);
}
}
// Set cookies using helper (handles domain correctly)
setCookie('access_token', accessToken, 7);
setCookie('refresh_token', refreshToken, 7);
// Clear session cookie to prevent interference with JWT
// (Django session cookie might take precedence over JWT)
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Clean URL
const newUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', newUrl);
// Force reload to ensure auth state is picked up
window.location.reload();
}
}, []);
// Show loading while processing URL tokens (before reload happens)
if (processingUrlTokens) {
return <LoadingScreen />;
}
// Loading state
if (userLoading) {
return <LoadingScreen />;
}
// Helper to detect root domain (for marketing site)
const isRootDomain = (): boolean => {
const hostname = window.location.hostname;
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
};
// Not authenticated - show public routes
if (!user) {
// On root domain, show marketing site
if (isRootDomain()) {
return (
<Routes>
<Route element={<MarketingLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// On business subdomain, show login
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}
// Error state
if (userError) {
return <ErrorScreen error={userError as Error} />;
}
// Handlers
const toggleTheme = () => setDarkMode((prev) => !prev);
const handleSignOut = () => {
logoutMutation.mutate();
};
const handleUpdateBusiness = (updates: Partial<any>) => {
updateBusinessMutation.mutate(updates);
};
const handleMasquerade = (targetUser: any) => {
// Call the masquerade API with the target user's username
// Fallback to email prefix if username is not available
const username = targetUser.username || targetUser.email?.split('@')[0];
if (!username) {
console.error('Cannot masquerade: no username or email available', targetUser);
return;
}
masqueradeMutation.mutate(username);
};
// Helper to check access based on roles
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
// Platform users (superuser, platform_manager, platform_support)
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
if (isPlatformUser) {
return (
<Routes>
<Route
element={
<PlatformLayout
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
onSignOut={handleSignOut}
/>
}
>
{(user.role === 'superuser' || user.role === 'platform_manager') && (
<>
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
</>
)}
<Route path="/platform/support" element={<PlatformSupport />} />
{user.role === 'superuser' && (
<Route path="/platform/settings" element={<PlatformSettings />} />
)}
<Route path="/platform/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route
path="*"
element={
<Navigate
to={
user.role === 'superuser' || user.role === 'platform_manager'
? '/platform/dashboard'
: '/platform/support'
}
/>
}
/>
</Route>
</Routes>
);
}
// Customer users
if (user.role === 'customer') {
return (
<Routes>
<Route
element={
<CustomerLayout
business={business || ({} as any)}
user={user}
/>
}
>
<Route path="/" element={<CustomerDashboard />} />
<Route path="/book" element={<BookingPage />} />
<Route path="/payments" element={<Payments />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
);
}
// Business loading - show loading with user info
if (businessLoading) {
return <LoadingScreen />;
}
// Check if we're on root/platform domain without proper business context
const currentHostname = window.location.hostname;
const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me';
// Business error or no business found
if (businessError || !business) {
// If user is a business owner on root domain, redirect to their business
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
// If on root/platform and shouldn't be here, show appropriate message
if (isRootOrPlatform) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md p-6">
<h2 className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{user.business_subdomain
? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me`
: 'Your account is not associated with a business. Please contact support.'}
</p>
<div className="flex gap-4 justify-center">
{user.business_subdomain && (
<button
onClick={() => {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Go to Business
</button>
)}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Sign Out
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md p-6">
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">Business Not Found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{businessError instanceof Error ? businessError.message : 'Unable to load business data. Please check your subdomain or try again.'}
</p>
<div className="flex gap-4 justify-center">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Reload
</button>
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Sign Out
</button>
</div>
</div>
</div>
);
}
// Business users (owner, manager, staff, resource)
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) {
// Check if email verification is required
if (!user.email_verified) {
return (
<Routes>
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
</Routes>
);
}
// Check if trial has expired
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
// Allowed routes when trial is expired
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
const currentPath = window.location.pathname;
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
// If trial expired and not on allowed route, redirect to trial-expired
if (isTrialExpired && !isOnAllowedRoute) {
return (
<Routes>
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
/>
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
</Routes>
);
}
return (
<Routes>
<Route
element={
<BusinessLayout
business={business}
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
onSignOut={handleSignOut}
updateBusiness={handleUpdateBusiness}
/>
}
>
{/* Trial and Upgrade Routes */}
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
{/* Regular Routes */}
<Route
path="/"
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route
path="/customers"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/services"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Services />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/resources"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/staff"
element={
hasAccess(['owner', 'manager']) ? (
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/payments"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
}
/>
<Route
path="/messages"
element={
hasAccess(['owner', 'manager']) ? (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Messages</h1>
<p className="text-gray-600">Messages feature coming soon...</p>
</div>
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
/>
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
);
}
// Fallback
return <Navigate to="/" />;
};
/**
* Main App Component
*/
const App: React.FC = () => {
return (
<QueryClientProvider client={queryClient}>
<Router>
<AppContent />
</Router>
</QueryClientProvider>
);
};
export default App;

View File

@@ -0,0 +1,113 @@
/**
* Authentication API
*/
import apiClient from './client';
export interface LoginCredentials {
username: string;
password: string;
}
import { UserRole } from '../types';
export interface MasqueradeStackEntry {
user_id: number;
username: string;
role: UserRole;
business_id?: number;
business_subdomain?: string;
}
export interface LoginResponse {
access: string;
refresh: string;
user: {
id: number;
username: string;
email: string;
name: string;
role: UserRole;
avatar_url?: string;
email_verified?: boolean;
is_staff: boolean;
is_superuser: boolean;
business?: number;
business_name?: string;
business_subdomain?: string;
};
masquerade_stack?: MasqueradeStackEntry[];
}
export interface User {
id: number;
username: string;
email: string;
name: string;
role: UserRole;
avatar_url?: string;
email_verified?: boolean;
is_staff: boolean;
is_superuser: boolean;
business?: number;
business_name?: string;
business_subdomain?: string;
}
/**
* Login user
*/
export const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/api/auth/login/', credentials);
return response.data;
};
/**
* Logout user
*/
export const logout = async (): Promise<void> => {
await apiClient.post('/api/auth/logout/');
};
/**
* Get current user
*/
export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<User>('/api/auth/me/');
return response.data;
};
/**
* Refresh access token
*/
export const refreshToken = async (refresh: string): Promise<{ access: string }> => {
const response = await apiClient.post('/api/auth/refresh/', { refresh });
return response.data;
};
/**
* Masquerade as another user
*/
export const masquerade = async (
username: string,
masquerade_stack?: MasqueradeStackEntry[]
): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>(
`/api/users/${username}/masquerade/`,
{ masquerade_stack }
);
return response.data;
};
/**
* Stop masquerading and return to previous user
*/
export const stopMasquerade = async (
masquerade_stack: MasqueradeStackEntry[]
): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>(
'/api/users/stop_masquerade/',
{ masquerade_stack }
);
return response.data;
};

View File

@@ -0,0 +1,106 @@
/**
* Business API - Resources and Users
*/
import apiClient from './client';
import { User, Resource, BusinessOAuthSettings, BusinessOAuthSettingsResponse, BusinessOAuthCredentials } from '../types';
/**
* Get all resources for the current business
*/
export const getResources = async (): Promise<Resource[]> => {
const response = await apiClient.get<Resource[]>('/api/resources/');
return response.data;
};
/**
* Get all users for the current business
*/
export const getBusinessUsers = async (): Promise<User[]> => {
const response = await apiClient.get<User[]>('/api/business/users/');
return response.data;
};
/**
* Get business OAuth settings and available platform providers
*/
export const getBusinessOAuthSettings = async (): Promise<BusinessOAuthSettingsResponse> => {
const response = await apiClient.get<{
business_settings: {
oauth_enabled_providers: string[];
oauth_allow_registration: boolean;
oauth_auto_link_by_email: boolean;
};
available_providers: string[];
}>('/api/business/oauth-settings/');
// Transform snake_case to camelCase
return {
businessSettings: {
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
allowRegistration: response.data.business_settings.oauth_allow_registration,
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
},
availableProviders: response.data.available_providers || [],
};
};
/**
* Update business OAuth settings
*/
export const updateBusinessOAuthSettings = async (
settings: Partial<BusinessOAuthSettings>
): Promise<BusinessOAuthSettingsResponse> => {
// Transform camelCase to snake_case for backend
const backendData: Record<string, any> = {};
if (settings.enabledProviders !== undefined) {
backendData.oauth_enabled_providers = settings.enabledProviders;
}
if (settings.allowRegistration !== undefined) {
backendData.oauth_allow_registration = settings.allowRegistration;
}
if (settings.autoLinkByEmail !== undefined) {
backendData.oauth_auto_link_by_email = settings.autoLinkByEmail;
}
const response = await apiClient.patch<{
business_settings: {
oauth_enabled_providers: string[];
oauth_allow_registration: boolean;
oauth_auto_link_by_email: boolean;
};
available_providers: string[];
}>('/api/business/oauth-settings/update/', backendData);
// Transform snake_case to camelCase
return {
businessSettings: {
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
allowRegistration: response.data.business_settings.oauth_allow_registration,
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
},
availableProviders: response.data.available_providers || [],
};
};
/**
* Get business OAuth credentials (custom credentials for paid tiers)
*/
export const getBusinessOAuthCredentials = async (): Promise<BusinessOAuthCredentials> => {
const response = await apiClient.get<BusinessOAuthCredentials>('/api/business/oauth-credentials/');
return response.data;
};
/**
* Update business OAuth credentials (custom credentials for paid tiers)
*/
export const updateBusinessOAuthCredentials = async (
credentials: Partial<BusinessOAuthCredentials>
): Promise<BusinessOAuthCredentials> => {
const response = await apiClient.patch<BusinessOAuthCredentials>(
'/api/business/oauth-credentials/update/',
credentials
);
return response.data;
};

View File

@@ -0,0 +1,85 @@
/**
* API Client
* Axios instance configured for SmoothSchedule API
*/
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { API_BASE_URL, getSubdomain } from './config';
import { getCookie } from '../utils/cookies';
// Create axios instance
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // For CORS with credentials
});
// Request interceptor - add auth token and business subdomain
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Add business subdomain header if on business site
const subdomain = getSubdomain();
if (subdomain && subdomain !== 'platform') {
config.headers['X-Business-Subdomain'] = subdomain;
}
// Add auth token if available (from cookie)
const token = getCookie('access_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - handle errors and token refresh
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle 401 Unauthorized - token expired
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Try to refresh token (from cookie)
const refreshToken = getCookie('refresh_token');
if (refreshToken) {
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh/`, {
refresh: refreshToken,
});
const { access } = response.data;
// Import setCookie dynamically to avoid circular dependency
const { setCookie } = await import('../utils/cookies');
setCookie('access_token', access, 7);
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers['Authorization'] = `Bearer ${access}`;
}
return apiClient(originalRequest);
}
} catch (refreshError) {
// Refresh failed - clear tokens and redirect to login
const { deleteCookie } = await import('../utils/cookies');
deleteCookie('access_token');
deleteCookie('refresh_token');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,59 @@
/**
* API Configuration
* Centralized configuration for API endpoints and settings
*/
// Determine API base URL based on environment
const getApiBaseUrl = (): string => {
// In production, this would be set via environment variable
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
// Development: use api subdomain
return 'http://api.lvh.me:8000';
};
export const API_BASE_URL = getApiBaseUrl();
/**
* Extract subdomain from current hostname
* Returns null if on root domain or invalid subdomain
*/
export const getSubdomain = (): string | null => {
const hostname = window.location.hostname;
const parts = hostname.split('.');
// lvh.me without subdomain (root domain) - no business context
if (hostname === 'lvh.me') {
return null;
}
// Has subdomain
if (parts.length > 1) {
const subdomain = parts[0];
// Exclude special subdomains
if (['www', 'api', 'platform'].includes(subdomain)) {
return subdomain === 'platform' ? null : subdomain;
}
return subdomain;
}
return null;
};
/**
* Check if current page is platform site
*/
export const isPlatformSite = (): boolean => {
const hostname = window.location.hostname;
return hostname.startsWith('platform.');
};
/**
* Check if current page is business site
*/
export const isBusinessSite = (): boolean => {
const subdomain = getSubdomain();
return subdomain !== null && subdomain !== 'platform';
};

View File

@@ -0,0 +1,51 @@
/**
* Custom Domains API - Manage custom domains for businesses
*/
import apiClient from './client';
import { CustomDomain } from '../types';
/**
* Get all custom domains for the current business
*/
export const getCustomDomains = async (): Promise<CustomDomain[]> => {
const response = await apiClient.get<CustomDomain[]>('/api/business/domains/');
return response.data;
};
/**
* Add a new custom domain
*/
export const addCustomDomain = async (domain: string): Promise<CustomDomain> => {
const response = await apiClient.post<CustomDomain>('/api/business/domains/', {
domain: domain.toLowerCase().trim(),
});
return response.data;
};
/**
* Delete a custom domain
*/
export const deleteCustomDomain = async (domainId: number): Promise<void> => {
await apiClient.delete(`/api/business/domains/${domainId}/`);
};
/**
* Verify a custom domain by checking DNS
*/
export const verifyCustomDomain = async (domainId: number): Promise<{ verified: boolean; message: string }> => {
const response = await apiClient.post<{ verified: boolean; message: string }>(
`/api/business/domains/${domainId}/verify/`
);
return response.data;
};
/**
* Set a custom domain as the primary domain
*/
export const setPrimaryDomain = async (domainId: number): Promise<CustomDomain> => {
const response = await apiClient.post<CustomDomain>(
`/api/business/domains/${domainId}/set-primary/`
);
return response.data;
};

View File

@@ -0,0 +1,181 @@
/**
* Domains API - NameSilo Integration for Domain Registration
*/
import apiClient from './client';
// Types
export interface DomainAvailability {
domain: string;
available: boolean;
price: number | null;
premium: boolean;
premium_price: number | null;
}
export interface DomainPrice {
tld: string;
registration: number;
renewal: number;
transfer: number;
}
export interface RegistrantContact {
first_name: string;
last_name: string;
email: string;
phone: string;
address: string;
city: string;
state: string;
zip_code: string;
country: string;
}
export interface DomainRegisterRequest {
domain: string;
years: number;
whois_privacy: boolean;
auto_renew: boolean;
nameservers?: string[];
contact: RegistrantContact;
auto_configure: boolean;
}
export interface DomainRegistration {
id: number;
domain: string;
status: 'pending' | 'active' | 'expired' | 'transferred' | 'failed';
registered_at: string | null;
expires_at: string | null;
auto_renew: boolean;
whois_privacy: boolean;
purchase_price: number | null;
renewal_price: number | null;
nameservers: string[];
days_until_expiry: number | null;
is_expiring_soon: boolean;
created_at: string;
// Detail fields
registrant_first_name?: string;
registrant_last_name?: string;
registrant_email?: string;
}
export interface DomainSearchHistory {
id: number;
searched_domain: string;
was_available: boolean;
price: number | null;
searched_at: string;
}
// API Functions
/**
* Search for domain availability
*/
export const searchDomains = async (
query: string,
tlds: string[] = ['.com', '.net', '.org']
): Promise<DomainAvailability[]> => {
const response = await apiClient.post<DomainAvailability[]>('/api/domains/search/search/', {
query,
tlds,
});
return response.data;
};
/**
* Get TLD pricing
*/
export const getDomainPrices = async (): Promise<DomainPrice[]> => {
const response = await apiClient.get<DomainPrice[]>('/api/domains/search/prices/');
return response.data;
};
/**
* Register a new domain
*/
export const registerDomain = async (
data: DomainRegisterRequest
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>('/api/domains/search/register/', data);
return response.data;
};
/**
* Get all registered domains for current business
*/
export const getRegisteredDomains = async (): Promise<DomainRegistration[]> => {
const response = await apiClient.get<DomainRegistration[]>('/api/domains/registrations/');
return response.data;
};
/**
* Get a single domain registration
*/
export const getDomainRegistration = async (id: number): Promise<DomainRegistration> => {
const response = await apiClient.get<DomainRegistration>(`/api/domains/registrations/${id}/`);
return response.data;
};
/**
* Update nameservers for a domain
*/
export const updateNameservers = async (
id: number,
nameservers: string[]
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/update_nameservers/`,
{ nameservers }
);
return response.data;
};
/**
* Toggle auto-renewal for a domain
*/
export const toggleAutoRenew = async (
id: number,
autoRenew: boolean
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/toggle_auto_renew/`,
{ auto_renew: autoRenew }
);
return response.data;
};
/**
* Renew a domain
*/
export const renewDomain = async (
id: number,
years: number = 1
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/renew/`,
{ years }
);
return response.data;
};
/**
* Sync domain info from NameSilo
*/
export const syncDomain = async (id: number): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/sync/`
);
return response.data;
};
/**
* Get domain search history
*/
export const getSearchHistory = async (): Promise<DomainSearchHistory[]> => {
const response = await apiClient.get<DomainSearchHistory[]>('/api/domains/history/');
return response.data;
};

View File

@@ -0,0 +1,93 @@
/**
* OAuth API
* Handles OAuth authentication flows with various providers
*/
import apiClient from './client';
export interface OAuthProvider {
name: string;
display_name: string;
icon: string;
}
export interface OAuthAuthorizationResponse {
authorization_url: string;
}
export interface OAuthTokenResponse {
access: string;
refresh: string;
user: {
id: number;
username: string;
email: string;
name: string;
role: string;
avatar_url?: string;
is_staff: boolean;
is_superuser: boolean;
business?: number;
business_name?: string;
business_subdomain?: string;
};
}
export interface OAuthConnection {
id: string;
provider: string;
provider_user_id: string;
email?: string;
connected_at: string;
}
/**
* Get list of enabled OAuth providers
*/
export const getOAuthProviders = async (): Promise<OAuthProvider[]> => {
const response = await apiClient.get<{ providers: OAuthProvider[] }>('/api/auth/oauth/providers/');
return response.data.providers;
};
/**
* Initiate OAuth flow - get authorization URL
*/
export const initiateOAuth = async (provider: string): Promise<OAuthAuthorizationResponse> => {
const response = await apiClient.get<OAuthAuthorizationResponse>(
`/api/auth/oauth/${provider}/authorize/`
);
return response.data;
};
/**
* Handle OAuth callback - exchange code for tokens
*/
export const handleOAuthCallback = async (
provider: string,
code: string,
state: string
): Promise<OAuthTokenResponse> => {
const response = await apiClient.post<OAuthTokenResponse>(
`/api/auth/oauth/${provider}/callback/`,
{
code,
state,
}
);
return response.data;
};
/**
* Get user's connected OAuth accounts
*/
export const getOAuthConnections = async (): Promise<OAuthConnection[]> => {
const response = await apiClient.get<{ connections: OAuthConnection[] }>('/api/auth/oauth/connections/');
return response.data.connections;
};
/**
* Disconnect an OAuth account
*/
export const disconnectOAuth = async (provider: string): Promise<void> => {
await apiClient.delete(`/api/auth/oauth/connections/${provider}/`);
};

View File

@@ -0,0 +1,433 @@
/**
* Payments API
* Functions for managing payment configuration (API keys and Connect)
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
export type PaymentMode = 'direct_api' | 'connect' | 'none';
export type KeyStatus = 'active' | 'invalid' | 'deprecated';
export type AccountStatus = 'pending' | 'onboarding' | 'active' | 'restricted' | 'rejected';
export interface ApiKeysInfo {
id: number;
status: KeyStatus;
secret_key_masked: string;
publishable_key_masked: string;
last_validated_at: string | null;
stripe_account_id: string;
stripe_account_name: string;
validation_error: string;
created_at: string;
updated_at: string;
}
export interface ConnectAccountInfo {
id: number;
business: number;
business_name: string;
business_subdomain: string;
stripe_account_id: string;
account_type: 'standard' | 'express' | 'custom';
status: AccountStatus;
charges_enabled: boolean;
payouts_enabled: boolean;
details_submitted: boolean;
onboarding_complete: boolean;
onboarding_link: string | null;
onboarding_link_expires_at: string | null;
is_onboarding_link_valid: boolean;
created_at: string;
updated_at: string;
}
export interface PaymentConfig {
payment_mode: PaymentMode;
tier: string;
can_accept_payments: boolean;
api_keys: ApiKeysInfo | null;
connect_account: ConnectAccountInfo | null;
}
export interface ApiKeysValidationResult {
valid: boolean;
account_id?: string;
account_name?: string;
environment?: string;
error?: string;
}
export interface ApiKeysCurrentResponse {
configured: boolean;
id?: number;
status?: KeyStatus;
secret_key_masked?: string;
publishable_key_masked?: string;
last_validated_at?: string | null;
stripe_account_id?: string;
stripe_account_name?: string;
validation_error?: string;
message?: string;
}
export interface ConnectOnboardingResponse {
account_type: 'standard' | 'custom';
url: string;
stripe_account_id?: string;
}
export interface AccountSessionResponse {
client_secret: string;
stripe_account_id: string;
publishable_key: string;
}
// ============================================================================
// Unified Configuration
// ============================================================================
/**
* Get unified payment configuration status.
* Returns the complete payment setup for the business.
*/
export const getPaymentConfig = () =>
apiClient.get<PaymentConfig>('/api/payments/config/status/');
// ============================================================================
// API Keys (Free Tier)
// ============================================================================
/**
* Get current API key configuration (masked keys).
*/
export const getApiKeys = () =>
apiClient.get<ApiKeysCurrentResponse>('/api/payments/api-keys/');
/**
* Save API keys.
* Validates and stores the provided Stripe API keys.
*/
export const saveApiKeys = (secretKey: string, publishableKey: string) =>
apiClient.post<ApiKeysInfo>('/api/payments/api-keys/', {
secret_key: secretKey,
publishable_key: publishableKey,
});
/**
* Validate API keys without saving.
* Tests the keys against Stripe API.
*/
export const validateApiKeys = (secretKey: string, publishableKey: string) =>
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/validate/', {
secret_key: secretKey,
publishable_key: publishableKey,
});
/**
* Re-validate stored API keys.
* Tests stored keys and updates their status.
*/
export const revalidateApiKeys = () =>
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/revalidate/');
/**
* Delete stored API keys.
*/
export const deleteApiKeys = () =>
apiClient.delete<{ success: boolean; message: string }>('/api/payments/api-keys/delete/');
// ============================================================================
// Stripe Connect (Paid Tiers)
// ============================================================================
/**
* Get current Connect account status.
*/
export const getConnectStatus = () =>
apiClient.get<ConnectAccountInfo>('/api/payments/connect/status/');
/**
* Initiate Connect account onboarding.
* Returns a URL to redirect the user for Stripe onboarding.
*/
export const initiateConnectOnboarding = (refreshUrl: string, returnUrl: string) =>
apiClient.post<ConnectOnboardingResponse>('/api/payments/connect/onboard/', {
refresh_url: refreshUrl,
return_url: returnUrl,
});
/**
* Refresh Connect onboarding link.
* For custom Connect accounts that need a new onboarding link.
*/
export const refreshConnectOnboardingLink = (refreshUrl: string, returnUrl: string) =>
apiClient.post<{ url: string }>('/api/payments/connect/refresh-link/', {
refresh_url: refreshUrl,
return_url: returnUrl,
});
/**
* Create an Account Session for embedded Connect onboarding.
* Returns a client_secret for initializing Stripe's embedded Connect components.
*/
export const createAccountSession = () =>
apiClient.post<AccountSessionResponse>('/api/payments/connect/account-session/');
/**
* Refresh Connect account status from Stripe.
* Syncs the local account record with the current state in Stripe.
*/
export const refreshConnectStatus = () =>
apiClient.post<ConnectAccountInfo>('/api/payments/connect/refresh-status/');
// ============================================================================
// Transaction Analytics
// ============================================================================
export interface Transaction {
id: number;
business: number;
business_name: string;
stripe_payment_intent_id: string;
stripe_charge_id: string;
transaction_type: 'payment' | 'refund' | 'application_fee';
status: 'pending' | 'succeeded' | 'failed' | 'refunded' | 'partially_refunded';
amount: number;
amount_display: string;
application_fee_amount: number;
fee_display: string;
net_amount: number;
currency: string;
customer_email: string;
customer_name: string;
created_at: string;
updated_at: string;
stripe_data?: Record<string, unknown>;
}
export interface TransactionListResponse {
results: Transaction[];
count: number;
page: number;
page_size: number;
total_pages: number;
}
export interface TransactionSummary {
total_transactions: number;
total_volume: number;
total_volume_display: string;
total_fees: number;
total_fees_display: string;
net_revenue: number;
net_revenue_display: string;
successful_transactions: number;
failed_transactions: number;
refunded_transactions: number;
average_transaction: number;
average_transaction_display: string;
}
export interface TransactionFilters {
start_date?: string;
end_date?: string;
status?: 'all' | 'succeeded' | 'pending' | 'failed' | 'refunded';
transaction_type?: 'all' | 'payment' | 'refund' | 'application_fee';
page?: number;
page_size?: number;
}
export interface StripeCharge {
id: string;
amount: number;
amount_display: string;
amount_refunded: number;
currency: string;
status: string;
paid: boolean;
refunded: boolean;
description: string | null;
receipt_email: string | null;
receipt_url: string | null;
created: number;
payment_method_details: Record<string, unknown> | null;
billing_details: Record<string, unknown> | null;
}
export interface ChargesResponse {
charges: StripeCharge[];
has_more: boolean;
}
export interface StripePayout {
id: string;
amount: number;
amount_display: string;
currency: string;
status: string;
arrival_date: number | null;
created: number;
description: string | null;
destination: string | null;
failure_message: string | null;
method: string;
type: string;
}
export interface PayoutsResponse {
payouts: StripePayout[];
has_more: boolean;
}
export interface BalanceItem {
amount: number;
currency: string;
amount_display: string;
}
export interface BalanceResponse {
available: BalanceItem[];
pending: BalanceItem[];
available_total: number;
pending_total: number;
}
export interface ExportRequest {
format: 'csv' | 'xlsx' | 'pdf' | 'quickbooks';
start_date?: string;
end_date?: string;
include_details?: boolean;
}
/**
* Get list of transactions with optional filtering.
*/
export const getTransactions = (filters?: TransactionFilters) => {
const params = new URLSearchParams();
if (filters?.start_date) params.append('start_date', filters.start_date);
if (filters?.end_date) params.append('end_date', filters.end_date);
if (filters?.status && filters.status !== 'all') params.append('status', filters.status);
if (filters?.transaction_type && filters.transaction_type !== 'all') {
params.append('transaction_type', filters.transaction_type);
}
if (filters?.page) params.append('page', String(filters.page));
if (filters?.page_size) params.append('page_size', String(filters.page_size));
const queryString = params.toString();
return apiClient.get<TransactionListResponse>(
`/api/payments/transactions/${queryString ? `?${queryString}` : ''}`
);
};
/**
* Get a single transaction by ID.
*/
export const getTransaction = (id: number) =>
apiClient.get<Transaction>(`/api/payments/transactions/${id}/`);
/**
* Get transaction summary/analytics.
*/
export const getTransactionSummary = (filters?: Pick<TransactionFilters, 'start_date' | 'end_date'>) => {
const params = new URLSearchParams();
if (filters?.start_date) params.append('start_date', filters.start_date);
if (filters?.end_date) params.append('end_date', filters.end_date);
const queryString = params.toString();
return apiClient.get<TransactionSummary>(
`/api/payments/transactions/summary/${queryString ? `?${queryString}` : ''}`
);
};
/**
* Get charges from Stripe API.
*/
export const getStripeCharges = (limit: number = 20) =>
apiClient.get<ChargesResponse>(`/api/payments/transactions/charges/?limit=${limit}`);
/**
* Get payouts from Stripe API.
*/
export const getStripePayouts = (limit: number = 20) =>
apiClient.get<PayoutsResponse>(`/api/payments/transactions/payouts/?limit=${limit}`);
/**
* Get current balance from Stripe API.
*/
export const getStripeBalance = () =>
apiClient.get<BalanceResponse>('/api/payments/transactions/balance/');
/**
* Export transaction data.
* Returns the file data directly for download.
*/
export const exportTransactions = (request: ExportRequest) =>
apiClient.post('/api/payments/transactions/export/', request, {
responseType: 'blob',
});
// ============================================================================
// Transaction Details & Refunds
// ============================================================================
export interface RefundInfo {
id: string;
amount: number;
amount_display: string;
status: string;
reason: string | null;
created: number;
}
export interface PaymentMethodInfo {
type: string;
brand?: string;
last4?: string;
exp_month?: number;
exp_year?: number;
funding?: string;
bank_name?: string;
}
export interface TransactionDetail extends Transaction {
refunds: RefundInfo[];
refundable_amount: number;
total_refunded: number;
can_refund: boolean;
payment_method_info: PaymentMethodInfo | null;
description: string;
}
export interface RefundRequest {
amount?: number;
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
metadata?: Record<string, string>;
}
export interface RefundResponse {
success: boolean;
refund_id: string;
amount: number;
amount_display: string;
status: string;
reason: string | null;
transaction_status: string;
}
/**
* Get detailed transaction information including refund data.
*/
export const getTransactionDetail = (id: number) =>
apiClient.get<TransactionDetail>(`/api/payments/transactions/${id}/`);
/**
* Issue a refund for a transaction.
* @param transactionId - The ID of the transaction to refund
* @param request - Optional refund request with amount and reason
*/
export const refundTransaction = (transactionId: number, request?: RefundRequest) =>
apiClient.post<RefundResponse>(`/api/payments/transactions/${transactionId}/refund/`, request || {});

View File

@@ -0,0 +1,56 @@
/**
* Platform API
* API functions for platform-level operations (businesses, users, etc.)
*/
import apiClient from './client';
export interface PlatformBusiness {
id: number;
name: string;
subdomain: string;
tier: string;
is_active: boolean;
created_at: string;
user_count: number;
}
export interface PlatformUser {
id: number;
email: string;
username: string;
name?: string;
role?: string;
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
business: number | null;
business_name?: string;
business_subdomain?: string;
date_joined: string;
last_login?: string;
}
/**
* Get all businesses (platform admin only)
*/
export const getBusinesses = async (): Promise<PlatformBusiness[]> => {
const response = await apiClient.get<PlatformBusiness[]>('/api/platform/businesses/');
return response.data;
};
/**
* Get all users (platform admin only)
*/
export const getUsers = async (): Promise<PlatformUser[]> => {
const response = await apiClient.get<PlatformUser[]>('/api/platform/users/');
return response.data;
};
/**
* Get users for a specific business
*/
export const getBusinessUsers = async (businessId: number): Promise<PlatformUser[]> => {
const response = await apiClient.get<PlatformUser[]>(`/api/platform/users/?business=${businessId}`);
return response.data;
};

View File

@@ -0,0 +1,90 @@
/**
* Platform OAuth Settings API
*/
import apiClient from './client';
export interface OAuthProviderConfig {
enabled: boolean;
client_id: string;
client_secret: string;
// Apple-specific fields
team_id?: string;
key_id?: string;
// Microsoft-specific field
tenant_id?: string;
}
export interface PlatformOAuthSettings {
// Global setting
oauth_allow_registration: boolean;
// Provider configurations
google: OAuthProviderConfig;
apple: OAuthProviderConfig;
facebook: OAuthProviderConfig;
linkedin: OAuthProviderConfig;
microsoft: OAuthProviderConfig;
twitter: OAuthProviderConfig;
twitch: OAuthProviderConfig;
}
export interface PlatformOAuthSettingsUpdate {
oauth_allow_registration?: boolean;
// Google
oauth_google_enabled?: boolean;
oauth_google_client_id?: string;
oauth_google_client_secret?: string;
// Apple
oauth_apple_enabled?: boolean;
oauth_apple_client_id?: string;
oauth_apple_client_secret?: string;
oauth_apple_team_id?: string;
oauth_apple_key_id?: string;
// Facebook
oauth_facebook_enabled?: boolean;
oauth_facebook_client_id?: string;
oauth_facebook_client_secret?: string;
// LinkedIn
oauth_linkedin_enabled?: boolean;
oauth_linkedin_client_id?: string;
oauth_linkedin_client_secret?: string;
// Microsoft
oauth_microsoft_enabled?: boolean;
oauth_microsoft_client_id?: string;
oauth_microsoft_client_secret?: string;
oauth_microsoft_tenant_id?: string;
// Twitter (X)
oauth_twitter_enabled?: boolean;
oauth_twitter_client_id?: string;
oauth_twitter_client_secret?: string;
// Twitch
oauth_twitch_enabled?: boolean;
oauth_twitch_client_id?: string;
oauth_twitch_client_secret?: string;
}
/**
* Get platform OAuth settings
*/
export const getPlatformOAuthSettings = async (): Promise<PlatformOAuthSettings> => {
const { data } = await apiClient.get('/api/platform/settings/oauth/');
return data;
};
/**
* Update platform OAuth settings
*/
export const updatePlatformOAuthSettings = async (
settings: PlatformOAuthSettingsUpdate
): Promise<PlatformOAuthSettings> => {
const { data } = await apiClient.post('/api/platform/settings/oauth/', settings);
return data;
};

View File

@@ -0,0 +1,210 @@
import apiClient from './client';
// Types
export interface UserProfile {
id: number;
username: string;
email: string;
name: string;
phone?: string;
phone_verified: boolean;
avatar_url?: string;
email_verified: boolean;
two_factor_enabled: boolean;
totp_confirmed: boolean;
sms_2fa_enabled: boolean;
timezone: string;
locale: string;
notification_preferences: NotificationPreferences;
role: string;
business?: number;
business_name?: string;
business_subdomain?: string;
// Address fields
address_line1?: string;
address_line2?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
}
export interface NotificationPreferences {
email: boolean;
sms: boolean;
in_app: boolean;
appointment_reminders: boolean;
marketing: boolean;
}
export interface TOTPSetupResponse {
secret: string;
qr_code: string; // Base64 encoded PNG
provisioning_uri: string;
}
export interface TOTPVerifyResponse {
success: boolean;
recovery_codes: string[];
}
export interface Session {
id: string;
device_info: string;
ip_address: string;
location: string;
created_at: string;
last_activity: string;
is_current: boolean;
}
export interface LoginHistoryEntry {
id: string;
timestamp: string;
ip_address: string;
device_info: string;
location: string;
success: boolean;
failure_reason?: string;
two_factor_method?: string;
}
// Profile API
export const getProfile = async (): Promise<UserProfile> => {
const response = await apiClient.get('/api/auth/profile/');
return response.data;
};
export const updateProfile = async (data: Partial<UserProfile>): Promise<UserProfile> => {
const response = await apiClient.patch('/api/auth/profile/', data);
return response.data;
};
export const uploadAvatar = async (file: File): Promise<{ avatar_url: string }> => {
const formData = new FormData();
formData.append('avatar', file);
const response = await apiClient.post('/api/auth/profile/avatar/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};
export const deleteAvatar = async (): Promise<void> => {
await apiClient.delete('/api/auth/profile/avatar/');
};
// Email API
export const sendVerificationEmail = async (): Promise<void> => {
await apiClient.post('/api/auth/email/verify/send/');
};
export const verifyEmail = async (token: string): Promise<void> => {
await apiClient.post('/api/auth/email/verify/confirm/', { token });
};
export const requestEmailChange = async (newEmail: string): Promise<void> => {
await apiClient.post('/api/auth/email/change/', { new_email: newEmail });
};
export const confirmEmailChange = async (token: string): Promise<void> => {
await apiClient.post('/api/auth/email/change/confirm/', { token });
};
// Password API
export const changePassword = async (
currentPassword: string,
newPassword: string
): Promise<void> => {
await apiClient.post('/api/auth/password/change/', {
current_password: currentPassword,
new_password: newPassword,
});
};
// 2FA API
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
const response = await apiClient.post('/api/auth/2fa/totp/setup/');
return response.data;
};
export const verifyTOTP = async (code: string): Promise<TOTPVerifyResponse> => {
const response = await apiClient.post('/api/auth/2fa/totp/verify/', { code });
return response.data;
};
export const disableTOTP = async (code: string): Promise<void> => {
await apiClient.post('/api/auth/2fa/totp/disable/', { code });
};
export const getRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.get('/api/auth/2fa/recovery-codes/');
return response.data.codes;
};
export const regenerateRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.post('/api/auth/2fa/recovery-codes/regenerate/');
return response.data.codes;
};
// Sessions API
export const getSessions = async (): Promise<Session[]> => {
const response = await apiClient.get('/api/auth/sessions/');
return response.data;
};
export const revokeSession = async (sessionId: string): Promise<void> => {
await apiClient.delete(`/api/auth/sessions/${sessionId}/`);
};
export const revokeOtherSessions = async (): Promise<void> => {
await apiClient.post('/api/auth/sessions/revoke-others/');
};
export const getLoginHistory = async (): Promise<LoginHistoryEntry[]> => {
const response = await apiClient.get('/api/auth/login-history/');
return response.data;
};
// Phone Verification API
export const sendPhoneVerification = async (phone: string): Promise<void> => {
await apiClient.post('/api/auth/phone/verify/send/', { phone });
};
export const verifyPhoneCode = async (code: string): Promise<void> => {
await apiClient.post('/api/auth/phone/verify/confirm/', { code });
};
// Multiple Email Management API
export interface UserEmail {
id: number;
email: string;
is_primary: boolean;
verified: boolean;
created_at: string;
}
export const getUserEmails = async (): Promise<UserEmail[]> => {
const response = await apiClient.get('/api/auth/emails/');
return response.data;
};
export const addUserEmail = async (email: string): Promise<UserEmail> => {
const response = await apiClient.post('/api/auth/emails/', { email });
return response.data;
};
export const deleteUserEmail = async (emailId: number): Promise<void> => {
await apiClient.delete(`/api/auth/emails/${emailId}/`);
};
export const sendUserEmailVerification = async (emailId: number): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/send-verification/`);
};
export const verifyUserEmail = async (emailId: number, token: string): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/verify/`, { token });
};
export const setPrimaryEmail = async (emailId: number): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/set-primary/`);
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,247 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100%" viewBox="0 0 1730 1100" enable-background="new 0 0 1730 1100" xml:space="preserve">
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M969.645508,771.667175
C983.734009,760.932678 998.024170,750.981995 1011.135925,739.665955
C1020.239868,731.809021 1027.811401,722.153137 1035.890625,713.142212
C1039.635864,708.964966 1042.988037,704.431946 1046.455933,700.011230
C1047.427979,698.771973 1048.177979,697.358459 1048.318481,695.266968
C1043.233154,698.355286 1038.068848,701.321533 1033.076172,704.552979
C1011.285095,718.656555 987.633118,729.002747 963.865662,739.154541
C926.816467,754.979309 888.330383,766.524841 849.335266,776.096252
C816.661194,784.116150 783.571899,790.540527 750.510559,796.854858
C725.879822,801.559082 701.040466,805.235657 676.219849,808.862976
C650.730042,812.588013 625.020874,814.936829 599.640015,819.253784
C561.088013,825.810913 522.543823,832.670288 484.360413,841.058594
C453.594025,847.817566 423.045654,856.010376 393.000458,865.471924
C368.607147,873.153687 344.985138,883.370483 321.251038,893.028137
C306.543671,899.012756 291.840790,905.266846 277.895966,912.805786
C257.393433,923.890198 237.239243,935.709290 217.557373,948.193909
C200.561569,958.974670 183.671112,970.190491 168.118774,982.907349
C150.190521,997.567078 133.454575,1013.749817 116.860298,1029.946777
C109.343819,1037.283325 103.407921,1046.241699 96.785301,1054.488647
C95.963615,1055.511963 95.329086,1056.685547 94.607811,1057.789551
C94.087418,1057.428833 93.567024,1057.068237 93.046631,1056.707520
C95.143036,1051.902710 97.038155,1046.997681 99.369362,1042.309692
C112.070229,1016.768616 126.605263,992.265686 143.560577,969.362183
C154.371017,954.759155 166.268524,940.905212 178.350021,927.312134
C195.337433,908.199402 214.588501,891.460449 234.166809,874.999146
C257.180664,855.649292 281.649719,838.427429 307.573792,823.472717
C336.247131,806.932129 365.478760,791.285645 396.623840,779.614136
C412.184509,773.782776 427.375671,766.898743 443.113495,761.622986
C464.384369,754.492371 485.846008,747.786011 507.540192,742.096313
C540.973694,733.327393 574.554077,725.033386 608.300049,717.568359
C634.070862,711.867554 660.204224,707.813232 686.158875,702.934082
C711.461548,698.177490 736.731262,693.245178 762.037231,688.506470
C773.996765,686.266907 786.117065,684.782654 797.967041,682.087830
C813.228760,678.617249 828.417358,674.693970 843.413147,670.214722
C868.431335,662.742126 893.816467,656.055481 918.046326,646.500244
C947.249329,634.983948 975.782898,621.687195 1001.597900,603.148926
C1019.638672,590.193542 1038.112427,577.639526 1057.397705,566.673218
C1078.458008,554.697449 1100.290771,544.266785 1123.296509,535.835693
C1153.968750,524.595032 1185.606567,517.348511 1216.895020,508.623566
C1228.170898,505.479218 1239.672241,503.140717 1251.080444,500.476807
C1252.144653,500.228271 1253.280396,500.285614 1255.739990,500.096741
C1253.853149,502.287323 1252.808350,503.648651 1251.613892,504.862793
C1244.387573,512.208191 1237.151611,519.453979 1230.673462,527.585815
C1218.089600,543.381836 1207.873535,560.547852 1199.297607,578.655396
C1191.381104,595.370850 1184.464722,612.563843 1177.247925,629.605774
C1168.326660,650.672302 1158.144165,671.075928 1146.017334,690.496033
C1135.214478,707.795898 1123.201904,724.184570 1109.329102,739.153809
C1098.717407,750.604187 1088.405151,762.391296 1077.100098,773.122986
C1066.321655,783.354675 1054.340088,792.309082 1043.048340,802.012085
C1022.812439,819.400757 999.674561,832.426270 976.316162,844.731873
C956.019775,855.424316 934.888245,864.927551 913.308838,872.683411
C889.113220,881.379639 864.222961,888.452698 839.208069,894.452332
C822.112122,898.552673 804.305725,899.769409 786.777283,901.957336
C776.820679,903.200073 766.784119,903.802368 755.687805,904.790649
C757.714539,906.218933 759.003662,907.369080 760.489807,908.138672
C783.668091,920.140076 807.284790,931.020691 832.462219,938.359375
C855.906860,945.193054 879.583191,951.030334 903.823364,953.736511
C919.614380,955.499451 935.650452,956.242859 951.530090,955.794312
C985.213318,954.842834 1018.249756,949.116272 1050.425049,938.980957
C1090.859131,926.244141 1128.350220,907.577209 1162.281494,882.076538
C1172.054565,874.731628 1181.528320,866.922607 1190.604370,858.733459
C1201.177246,849.194031 1211.503418,839.328491 1221.327759,829.023743
C1238.017578,811.517944 1253.516968,792.980530 1265.936401,772.111816
C1274.501709,757.719238 1283.092041,743.320740 1291.001709,728.565918
C1296.228638,718.815796 1300.504639,708.528442 1304.793457,698.308411
C1315.707275,672.301758 1322.893799,645.154175 1327.839600,617.478088
C1330.420410,603.036621 1331.911011,588.336731 1332.869995,573.686584
C1333.878906,558.275757 1334.407471,542.754089 1333.765503,527.338318
C1333.190186,513.526611 1330.652344,499.801727 1329.257446,486.010529
C1329.129883,484.748444 1330.735107,482.487030 1332.013306,482.032196
C1347.430786,476.546417 1363.083862,471.698395 1378.384033,465.913452
C1395.856812,459.307068 1413.124390,452.132050 1430.313843,444.811279
C1442.720703,439.527374 1455.204834,434.247284 1467.033081,427.821472
C1488.682861,416.060059 1510.133179,403.880432 1531.183105,391.080017
C1553.192505,377.696136 1573.413086,361.740723 1592.717285,344.700775
C1602.850830,335.755951 1612.603027,326.373413 1622.384766,317.039001
C1625.733643,313.843140 1628.616577,310.162659 1631.782593,306.769073
C1632.601929,305.891022 1633.686157,305.260193 1634.648682,304.515747
C1634.988770,304.771484 1635.328979,305.027191 1635.669067,305.282928
C1633.291504,309.465271 1631.207642,313.850372 1628.485229,317.794739
C1616.850464,334.652039 1605.817017,352.011719 1593.041870,367.969421
C1581.144165,382.831451 1568.030884,396.904633 1554.180420,409.977081
C1532.040161,430.873718 1508.570923,450.362701 1482.932861,466.917206
C1461.684692,480.637024 1440.099609,493.861084 1418.288452,506.665344
C1412.599854,510.004883 1412.874390,514.199585 1413.025269,519.129028
C1413.335327,529.252197 1413.837646,539.372375 1413.970093,549.497498
C1414.420044,583.932129 1409.491089,617.730225 1402.481934,651.371643
C1396.489746,680.130859 1388.232544,708.150024 1376.739746,735.113281
C1365.900146,760.543701 1354.243652,785.647095 1338.789673,808.736450
C1329.595947,822.472168 1320.811768,836.521423 1310.929565,849.746643
C1299.263916,865.358582 1287.147461,880.676086 1272.680908,893.951477
C1267.312744,898.877502 1262.994141,904.960815 1257.552490,909.790405
C1244.686401,921.209106 1231.741821,932.582581 1218.245483,943.234863
C1206.325317,952.643250 1194.096924,961.842163 1181.146973,969.724365
C1158.948486,983.235718 1135.947021,995.433838 1111.749023,1005.062805
C1097.940796,1010.557495 1084.021851,1016.069458 1069.709839,1019.932617
C1049.345581,1025.429443 1028.718628,1030.276367 1007.929993,1033.773071
C991.841858,1036.479248 975.354248,1037.157715 959.006348,1037.869873
C944.148682,1038.517090 929.177429,1038.851318 914.369873,1037.769165
C895.950500,1036.422974 877.505005,1034.281860 859.326843,1031.045898
C829.348206,1025.709351 800.144714,1017.064575 771.925598,1005.670044
C756.608765,999.485352 741.639099,992.105408 727.324646,983.855835
C708.068115,972.758301 689.407349,960.583984 670.880676,948.284729
C663.679993,943.504333 657.602966,937.049927 650.901428,931.493958
C644.098328,925.853760 636.800903,920.762085 630.356140,914.748413
C619.933044,905.022461 609.957886,894.810120 599.945679,884.653320
C596.250183,880.904480 592.985229,876.731201 588.732971,871.842102
C593.234985,871.136841 596.812500,870.434021 600.423706,870.036133
C613.563843,868.588135 626.724976,867.327454 639.859131,865.829712
C649.863892,864.688843 659.866699,863.473877 669.822815,861.976013
C687.452637,859.323730 705.044434,856.420166 722.655457,853.642822
C738.960144,851.071533 755.332520,848.871826 771.558411,845.876282
C788.103882,842.821716 804.617798,839.442139 820.942810,835.388611
C838.621033,830.999084 856.189697,826.061890 873.562744,820.590759
C883.364563,817.504089 892.799072,813.130615 902.178589,808.853394
C914.899170,803.052734 927.670898,797.269348 939.927246,790.575073
C950.102966,785.017090 959.565613,778.153442 969.645508,771.667175
M1050.581421,694.050720
C1050.730957,693.806946 1050.880493,693.563171 1051.029907,693.319275
C1050.812500,693.437988 1050.595093,693.556763 1050.581421,694.050720
z"/>
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M556.660950,457.603760
C541.109375,531.266846 547.394165,603.414612 568.399292,675.217285
C432.503021,704.469421 306.741730,754.212341 199.911194,846.845520
C200.479172,845.300049 200.602173,844.107422 201.242157,843.353882
C209.385620,833.765381 217.337875,823.994263 225.877579,814.768311
C234.207504,805.768921 242.989990,797.166687 251.896179,788.730408
C257.379120,783.536743 263.590637,779.120728 269.241333,774.092896
C273.459808,770.339478 276.960907,765.728882 281.376740,762.257324
C297.837646,749.316223 314.230652,736.249023 331.255981,724.078125
C345.231110,714.087769 359.912170,705.048035 374.584686,696.085144
C382.450134,691.280396 391.044617,687.685791 399.150726,683.253601
C407.072968,678.921997 414.597321,673.833801 422.642975,669.762817
C438.151398,661.916077 453.798492,654.321594 469.600006,647.085205
C477.539642,643.449280 478.113831,642.479065 476.519012,633.766968
C474.589203,623.224731 473.630249,612.508850 471.947601,601.916382
C467.749847,575.490784 468.654633,548.856323 469.237122,522.316833
C469.602295,505.676849 471.616699,488.988892 474.083252,472.500793
C477.059357,452.606354 480.060059,432.564514 485.320496,413.203339
C491.148651,391.752808 499.099060,370.831879 506.971741,350.000183
C512.325867,335.832855 518.620361,321.957916 525.397888,308.404053
C541.421509,276.359467 560.144958,245.828873 582.862244,218.156967
C598.004089,199.712769 614.822388,182.523621 631.949951,165.861969
C652.972046,145.411667 676.340942,127.695229 701.137573,111.953148
C726.902954,95.596024 753.783325,81.411240 782.541138,71.040688
C797.603638,65.608902 812.617126,59.820137 828.057861,55.716591
C845.892639,50.976776 864.121277,47.634624 882.284790,44.254494
C890.218506,42.778072 898.403564,42.346916 906.495300,42.086170
C924.443237,41.507816 942.445129,40.435017 960.349243,41.242741
C979.963135,42.127602 999.561890,44.377670 1019.039673,46.986061
C1043.176270,50.218334 1066.758545,56.365486 1089.506470,64.964005
C1106.661865,71.448593 1123.305542,79.342972 1139.969482,87.057976
C1162.813843,97.634354 1183.941406,111.123840 1204.113037,126.138229
C1207.003540,128.289703 1209.946899,130.370087 1213.763916,133.133530
C1216.783447,129.931229 1220.327026,126.716408 1223.223755,122.997650
C1231.400269,112.500671 1239.273560,101.768028 1247.343994,91.187546
C1251.051270,86.327263 1254.881470,81.556633 1258.788696,76.855797
C1259.760620,75.686508 1261.248413,74.945900 1262.499023,74.008209
C1263.292480,75.345688 1264.457031,76.590347 1264.818726,78.035873
C1267.046143,86.937248 1268.891724,95.937119 1271.242432,104.803978
C1275.496948,120.851143 1280.156372,136.791153 1284.390381,152.843521
C1289.730957,173.090820 1294.707275,193.434189 1300.038086,213.684174
C1305.998291,236.325089 1312.179443,258.907806 1318.265015,281.515717
C1318.472290,282.285461 1318.685059,283.053680 1319.249390,285.117157
C1249.010864,270.419495 1179.575439,255.889877 1109.182129,241.159790
C1125.300659,224.247345 1141.057739,207.714233 1157.271729,190.701782
C1151.530518,186.784927 1146.192871,182.681778 1140.429565,179.305191
C1127.437134,171.693329 1114.523315,163.859375 1101.056763,157.163300
C1072.803589,143.114868 1043.187866,132.633057 1012.025146,127.306679
C996.903809,124.722130 981.545776,123.292236 966.235352,122.115311
C953.661621,121.148743 940.985535,120.787796 928.380005,121.118324
C905.687134,121.713341 883.266846,125.033134 861.164490,130.156235
C827.750183,137.901321 796.099426,150.481354 765.943542,166.659683
C744.045410,178.407761 723.100586,191.717087 704.741150,208.715820
C692.812561,219.760330 680.168945,230.111618 668.980225,241.854492
C657.360962,254.049179 646.193909,266.898956 636.478516,280.629303
C622.844910,299.897369 609.775757,319.708069 598.278931,340.299286
C589.203308,356.553925 582.410522,374.153534 575.426636,391.487335
C567.199646,411.906586 561.340576,433.110779 557.061401,454.725311
C556.900940,455.536224 556.898621,456.378479 556.660950,457.603760
z"/>
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M1706.087402,107.067314
C1703.089111,115.619484 1700.499512,124.342247 1697.015747,132.691925
C1686.536865,157.806900 1674.552490,182.225861 1658.662109,204.387024
C1646.541138,221.290833 1633.860840,237.859802 1620.447754,253.749130
C1610.171387,265.922516 1598.887085,277.376678 1587.116699,288.127747
C1567.458008,306.083740 1546.417847,322.320587 1524.483398,337.552246
C1495.366455,357.771515 1464.521729,374.787689 1432.470215,389.522156
C1408.761597,400.421356 1384.338989,409.873322 1359.856445,418.950348
C1338.651123,426.812286 1317.005859,433.538574 1295.377563,440.189545
C1282.541626,444.136749 1269.303589,446.756866 1256.353271,450.357635
C1243.725464,453.868683 1231.256226,457.945312 1218.677490,461.637756
C1192.216675,469.405334 1165.581299,476.616241 1139.306396,484.964813
C1122.046509,490.448944 1105.143555,497.158905 1088.355957,503.995453
C1073.956177,509.859589 1059.653931,516.113403 1045.836670,523.221436
C1027.095337,532.862488 1009.846802,544.765564 994.656799,559.637390
C986.521912,567.601807 977.590271,574.817322 968.586731,581.815613
C950.906799,595.557678 919.261353,591.257507 902.949524,575.751221
C890.393311,563.815002 883.972961,548.799927 878.270020,533.265137
C872.118042,516.506958 862.364990,502.109009 851.068176,488.567474
C837.824646,472.692474 824.675781,456.737396 811.315308,440.961517
C803.714661,431.986664 795.703918,423.360413 788.002930,414.468903
C778.470581,403.462769 769.106140,392.311340 759.596680,381.285126
C752.240295,372.755371 744.606201,364.460114 737.400879,355.806427
C730.120544,347.062592 727.078613,337.212921 730.571777,325.824554
C736.598145,306.177765 760.405457,299.432281 775.159790,313.874237
C789.284302,327.699738 802.566406,342.385803 816.219543,356.692902
C816.564697,357.054535 816.916931,357.409424 817.268250,357.765015
C845.125427,385.956726 872.707642,414.429291 901.026123,442.149719
C908.467834,449.434296 918.068054,454.575775 926.906189,460.350739
C933.051758,464.366333 939.634460,467.707123 945.928894,471.503540
C948.467102,473.034454 950.404358,472.612885 952.644043,470.884766
C972.771118,455.355255 994.347229,442.156677 1017.344299,431.336121
C1045.954834,417.874298 1075.032959,405.539398 1105.177612,395.868073
C1127.357422,388.752136 1149.351074,380.949371 1171.792480,374.778931
C1209.532104,364.402008 1247.646118,355.393494 1285.443359,345.217163
C1317.973999,336.458740 1350.391968,327.239716 1382.656372,317.547119
C1412.278198,308.648407 1441.114014,297.585785 1469.434570,285.016663
C1511.778687,266.223450 1552.020386,243.841415 1590.082031,217.493744
C1608.183228,204.963440 1625.881104,191.914856 1641.874268,176.685043
C1649.680786,169.250977 1658.483398,162.733627 1665.537964,154.666931
C1679.129517,139.125244 1691.799438,122.777374 1705.282837,106.722069
C1705.838623,106.809341 1705.963135,106.938339 1706.087402,107.067314
z"/>
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M1705.527710,106.486694
C1705.630859,105.936501 1705.907837,105.568420 1706.218628,105.231407
C1706.297485,105.145935 1706.483765,105.159477 1706.930664,105.055130
C1706.771118,105.730949 1706.643921,106.269272 1706.302246,106.937462
C1705.963135,106.938339 1705.838623,106.809341 1705.527710,106.486694
z"/>
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M196.850372,849.515076
C196.898865,849.852539 196.767776,850.076172 196.636688,850.299805
C196.648056,850.000305 196.659409,849.700745 196.850372,849.515076
z"/>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,89 @@
.confirmation-container {
background: white;
border-radius: 8px;
padding: 3rem 2rem;
max-width: 500px;
margin: 2rem auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
text-align: center;
}
.confirmation-icon {
width: 80px;
height: 80px;
background: #48bb78;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin: 0 auto 1.5rem;
}
.confirmation-container h2 {
font-size: 2rem;
color: #1a202c;
margin-bottom: 2rem;
}
.confirmation-details {
background: #f7fafc;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.5rem;
text-align: left;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #e2e8f0;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-weight: 600;
color: #4a5568;
}
.detail-value {
color: #2d3748;
}
.status-badge {
background: #48bb78;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
}
.confirmation-message {
color: #718096;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-done {
width: 100%;
padding: 0.75rem;
background: #3182ce;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-done:hover {
background: #2c5282;
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { format } from 'date-fns';
import './AppointmentConfirmation.css';
const AppointmentConfirmation = ({ appointment, onClose }) => {
const startTime = new Date(appointment.start_time);
return (
<div className="confirmation-container">
<div className="confirmation-icon"></div>
<h2>Booking Confirmed!</h2>
<div className="confirmation-details">
<div className="detail-row">
<span className="detail-label">Date:</span>
<span className="detail-value">{format(startTime, 'MMMM d, yyyy')}</span>
</div>
<div className="detail-row">
<span className="detail-label">Time:</span>
<span className="detail-value">{format(startTime, 'h:mm a')}</span>
</div>
<div className="detail-row">
<span className="detail-label">Status:</span>
<span className="detail-value status-badge">{appointment.status}</span>
</div>
</div>
<p className="confirmation-message">
You will receive a confirmation email shortly with all the details.
</p>
<button onClick={onClose} className="btn-done">
Done
</button>
</div>
);
};
export default AppointmentConfirmation;

View File

@@ -0,0 +1,137 @@
.booking-form-container {
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 500px;
margin: 2rem auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.booking-form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.booking-form-header h2 {
font-size: 1.75rem;
color: #1a202c;
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
color: #718096;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
}
.close-btn:hover {
background: #edf2f7;
}
.service-summary {
background: #f7fafc;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
}
.service-summary p {
margin: 0.5rem 0;
color: #4a5568;
}
.booking-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-weight: 600;
margin-bottom: 0.5rem;
color: #2d3748;
}
.form-group input,
.form-group select {
padding: 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3182ce;
}
.form-group input.error,
.form-group select.error {
border-color: #e53e3e;
}
.error-message {
color: #e53e3e;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.btn-cancel,
.btn-submit {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-cancel {
background: #edf2f7;
color: #4a5568;
}
.btn-cancel:hover {
background: #e2e8f0;
}
.btn-submit {
background: #3182ce;
color: white;
}
.btn-submit:hover:not(:disabled) {
background: #2c5282;
}
.btn-submit:disabled {
background: #a0aec0;
cursor: not-allowed;
}

View File

@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { format } from 'date-fns';
import './BookingForm.css';
const BookingForm = ({ service, resources, onSubmit, onCancel, loading }) => {
const [formData, setFormData] = useState({
resource: resources?.[0]?.id || '',
date: '',
time: '',
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error for this field
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.resource) {
newErrors.resource = 'Please select a resource';
}
if (!formData.date) {
newErrors.date = 'Please select a date';
}
if (!formData.time) {
newErrors.time = 'Please select a time';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) {
return;
}
// Combine date and time into ISO format
const startDateTime = new Date(`${formData.date}T${formData.time}`);
const endDateTime = new Date(startDateTime.getTime() + service.duration * 60000);
const appointmentData = {
service: service.id,
resource: parseInt(formData.resource),
start_time: startDateTime.toISOString(),
end_time: endDateTime.toISOString(),
};
onSubmit(appointmentData);
};
return (
<div className="booking-form-container">
<div className="booking-form-header">
<h2>Book: {service.name}</h2>
<button onClick={onCancel} className="close-btn">×</button>
</div>
<div className="service-summary">
<p><strong>Duration:</strong> {service.duration} minutes</p>
<p><strong>Price:</strong> ${service.price}</p>
</div>
<form onSubmit={handleSubmit} className="booking-form">
<div className="form-group">
<label htmlFor="resource">Select Provider</label>
<select
id="resource"
name="resource"
value={formData.resource}
onChange={handleChange}
className={errors.resource ? 'error' : ''}
>
<option value="">Choose a provider...</option>
{resources?.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
{errors.resource && <span className="error-message">{errors.resource}</span>}
</div>
<div className="form-group">
<label htmlFor="date">Date</label>
<input
type="date"
id="date"
name="date"
value={formData.date}
onChange={handleChange}
min={format(new Date(), 'yyyy-MM-dd')}
className={errors.date ? 'error' : ''}
/>
{errors.date && <span className="error-message">{errors.date}</span>}
</div>
<div className="form-group">
<label htmlFor="time">Time</label>
<input
type="time"
id="time"
name="time"
value={formData.time}
onChange={handleChange}
className={errors.time ? 'error' : ''}
/>
{errors.time && <span className="error-message">{errors.time}</span>}
</div>
<div className="form-actions">
<button type="button" onClick={onCancel} className="btn-cancel">
Cancel
</button>
<button type="submit" className="btn-submit" disabled={loading}>
{loading ? 'Booking...' : 'Confirm Booking'}
</button>
</div>
</form>
</div>
);
};
export default BookingForm;

View File

@@ -0,0 +1,269 @@
/**
* Stripe Connect Onboarding Component
* For paid-tier businesses to connect their Stripe account via Connect
*/
import React, { useState } from 'react';
import {
ExternalLink,
CheckCircle,
AlertCircle,
Loader2,
RefreshCw,
CreditCard,
Wallet,
} from 'lucide-react';
import { ConnectAccountInfo } from '../api/payments';
import { useConnectOnboarding, useRefreshConnectLink } from '../hooks/usePayments';
interface ConnectOnboardingProps {
connectAccount: ConnectAccountInfo | null;
tier: string;
onSuccess?: () => void;
}
const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
connectAccount,
tier,
onSuccess,
}) => {
const [error, setError] = useState<string | null>(null);
const onboardingMutation = useConnectOnboarding();
const refreshLinkMutation = useRefreshConnectLink();
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
const isOnboarding = connectAccount?.status === 'onboarding' ||
(connectAccount && !connectAccount.onboarding_complete);
const needsOnboarding = !connectAccount;
const getReturnUrls = () => {
const baseUrl = window.location.origin;
return {
refreshUrl: `${baseUrl}/payments?connect=refresh`,
returnUrl: `${baseUrl}/payments?connect=complete`,
};
};
const handleStartOnboarding = async () => {
setError(null);
try {
const { refreshUrl, returnUrl } = getReturnUrls();
const result = await onboardingMutation.mutateAsync({ refreshUrl, returnUrl });
// Redirect to Stripe onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to start onboarding');
}
};
const handleRefreshLink = async () => {
setError(null);
try {
const { refreshUrl, returnUrl } = getReturnUrls();
const result = await refreshLinkMutation.mutateAsync({ refreshUrl, returnUrl });
// Redirect to continue onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to refresh onboarding link');
}
};
// Account type display
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
case 'express':
return 'Express Connect';
case 'custom':
return 'Custom Connect';
default:
return 'Connect';
}
};
return (
<div className="space-y-6">
{/* Active Account Status */}
{isActive && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<p className="text-sm text-green-700 mt-1">
Your Stripe account is connected and ready to accept payments.
</p>
</div>
</div>
</div>
)}
{/* Account Details */}
{connectAccount && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
connectAccount.status === 'active'
? 'bg-green-100 text-green-800'
: connectAccount.status === 'onboarding'
? 'bg-yellow-100 text-yellow-800'
: connectAccount.status === 'restricted'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="flex items-center gap-1">
{connectAccount.charges_enabled ? (
<>
<CreditCard size={14} className="text-green-600" />
<span className="text-green-600">Enabled</span>
</>
) : (
<>
<CreditCard size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
</>
)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="flex items-center gap-1">
{connectAccount.payouts_enabled ? (
<>
<Wallet size={14} className="text-green-600" />
<span className="text-green-600">Enabled</span>
</>
) : (
<>
<Wallet size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
</>
)}
</span>
</div>
{connectAccount.stripe_account_id && (
<div className="flex justify-between">
<span className="text-gray-600">Account ID:</span>
<code className="font-mono text-gray-900 text-xs">
{connectAccount.stripe_account_id}
</code>
</div>
)}
</div>
</div>
)}
{/* Onboarding in Progress */}
{isOnboarding && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-yellow-800">Complete Onboarding</h4>
<p className="text-sm text-yellow-700 mt-1">
Your Stripe Connect account setup is incomplete.
Click below to continue the onboarding process.
</p>
<button
onClick={handleRefreshLink}
disabled={refreshLinkMutation.isPending}
className="mt-3 flex items-center gap-2 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-lg hover:bg-yellow-200 disabled:opacity-50"
>
{refreshLinkMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<RefreshCw size={16} />
)}
Continue Onboarding
</button>
</div>
</div>
</div>
)}
{/* Start Onboarding */}
{needsOnboarding && (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-800 mb-2">Connect with Stripe</h4>
<p className="text-sm text-blue-700">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
This provides a seamless payment experience for your customers while
the platform handles payment processing.
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
</li>
</ul>
</div>
<button
onClick={handleStartOnboarding}
disabled={onboardingMutation.isPending}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] disabled:opacity-50"
>
{onboardingMutation.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<ExternalLink size={18} />
Connect with Stripe
</>
)}
</button>
</div>
)}
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-2 text-red-800">
<AlertCircle size={18} className="shrink-0 mt-0.5" />
<span className="text-sm">{error}</span>
</div>
</div>
)}
{/* External Stripe Dashboard Link */}
{isActive && (
<a
href="https://dashboard.stripe.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
>
<ExternalLink size={14} />
Open Stripe Dashboard
</a>
)}
</div>
);
};
export default ConnectOnboarding;

View File

@@ -0,0 +1,290 @@
/**
* Embedded Stripe Connect Onboarding Component
*
* Uses Stripe's Connect embedded components to provide a seamless
* onboarding experience without redirecting users away from the app.
*/
import React, { useState, useCallback } from 'react';
import {
ConnectComponentsProvider,
ConnectAccountOnboarding,
} from '@stripe/react-connect-js';
import { loadConnectAndInitialize } from '@stripe/connect-js';
import type { StripeConnectInstance } from '@stripe/connect-js';
import {
CheckCircle,
AlertCircle,
Loader2,
CreditCard,
Wallet,
Building2,
} from 'lucide-react';
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
interface ConnectOnboardingEmbedProps {
connectAccount: ConnectAccountInfo | null;
tier: string;
onComplete?: () => void;
onError?: (error: string) => void;
}
type LoadingState = 'idle' | 'loading' | 'ready' | 'error' | 'complete';
const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
connectAccount,
tier,
onComplete,
onError,
}) => {
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
// Initialize Stripe Connect
const initializeStripeConnect = useCallback(async () => {
if (loadingState === 'loading' || loadingState === 'ready') return;
setLoadingState('loading');
setErrorMessage(null);
try {
// Fetch account session from our backend
const response = await createAccountSession();
const { client_secret, publishable_key } = response.data;
// Initialize the Connect instance
const instance = await loadConnectAndInitialize({
publishableKey: publishable_key,
fetchClientSecret: async () => client_secret,
appearance: {
overlays: 'drawer',
variables: {
colorPrimary: '#635BFF',
colorBackground: '#ffffff',
colorText: '#1a1a1a',
colorDanger: '#df1b41',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSizeBase: '14px',
spacingUnit: '4px',
borderRadius: '8px',
},
},
});
setStripeConnectInstance(instance);
setLoadingState('ready');
} catch (err: any) {
console.error('Failed to initialize Stripe Connect:', err);
const message = err.response?.data?.error || err.message || 'Failed to initialize payment setup';
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}
}, [loadingState, onError]);
// Handle onboarding completion
const handleOnboardingExit = useCallback(async () => {
// Refresh status from Stripe to sync the local database
try {
await refreshConnectStatus();
} catch (err) {
console.error('Failed to refresh Connect status:', err);
}
setLoadingState('complete');
onComplete?.();
}, [onComplete]);
// Handle errors from the Connect component
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
console.error('Connect component load error:', loadError);
const message = loadError.error.message || 'Failed to load payment component';
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}, [onError]);
// Account type display
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
case 'express':
return 'Express Connect';
case 'custom':
return 'Custom Connect';
default:
return 'Connect';
}
};
// If account is already active, show status
if (isActive) {
return (
<div className="space-y-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<p className="text-sm text-green-700 mt-1">
Your Stripe account is connected and ready to accept payments.
</p>
</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="flex items-center gap-1 text-green-600">
<CreditCard size={14} />
Enabled
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="flex items-center gap-1 text-green-600">
<Wallet size={14} />
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
</span>
</div>
</div>
</div>
</div>
);
}
// Completion state
if (loadingState === 'complete') {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 mb-3" size={48} />
<h4 className="font-medium text-green-800 text-lg">Onboarding Complete!</h4>
<p className="text-sm text-green-700 mt-2">
Your Stripe account has been set up. You can now accept payments.
</p>
</div>
);
}
// Error state
if (loadingState === 'error') {
return (
<div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-red-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-red-800">Setup Failed</h4>
<p className="text-sm text-red-700 mt-1">{errorMessage}</p>
</div>
</div>
</div>
<button
onClick={() => {
setLoadingState('idle');
setErrorMessage(null);
}}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Try Again
</button>
</div>
);
}
// Idle state - show start button
if (loadingState === 'idle') {
return (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<Building2 className="text-blue-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-blue-800">Set Up Payments</h4>
<p className="text-sm text-blue-700 mt-1">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
Complete the onboarding process to start accepting payments from your customers.
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
</li>
</ul>
</div>
</div>
</div>
<button
onClick={initializeStripeConnect}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
>
<CreditCard size={18} />
Start Payment Setup
</button>
</div>
);
}
// Loading state
if (loadingState === 'loading') {
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
<p className="text-gray-600">Initializing payment setup...</p>
</div>
);
}
// Ready state - show embedded onboarding
if (loadingState === 'ready' && stripeConnectInstance) {
return (
<div className="space-y-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Complete Your Account Setup</h4>
<p className="text-sm text-gray-600">
Fill out the information below to finish setting up your payment account.
Your information is securely handled by Stripe.
</p>
</div>
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white p-4">
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
<ConnectAccountOnboarding
onExit={handleOnboardingExit}
onLoadError={handleLoadError}
/>
</ConnectComponentsProvider>
</div>
</div>
);
}
return null;
};
export default ConnectOnboardingEmbed;

View File

@@ -0,0 +1,636 @@
import React, { useState } from 'react';
import {
Search,
Globe,
Check,
X,
ShoppingCart,
Loader2,
ChevronRight,
Shield,
RefreshCw,
AlertCircle,
} from 'lucide-react';
import {
useDomainSearch,
useRegisterDomain,
useRegisteredDomains,
type DomainAvailability,
type RegistrantContact,
} from '../hooks/useDomains';
interface DomainPurchaseProps {
onSuccess?: () => void;
}
type Step = 'search' | 'details' | 'confirm';
const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
const [step, setStep] = useState<Step>('search');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DomainAvailability[]>([]);
const [selectedDomain, setSelectedDomain] = useState<DomainAvailability | null>(null);
const [years, setYears] = useState(1);
const [whoisPrivacy, setWhoisPrivacy] = useState(true);
const [autoRenew, setAutoRenew] = useState(true);
const [autoConfigureDomain, setAutoConfigureDomain] = useState(true);
// Contact info form state
const [contact, setContact] = useState<RegistrantContact>({
first_name: '',
last_name: '',
email: '',
phone: '',
address: '',
city: '',
state: '',
zip_code: '',
country: 'US',
});
const searchMutation = useDomainSearch();
const registerMutation = useRegisterDomain();
const { data: registeredDomains } = useRegisteredDomains();
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
try {
const results = await searchMutation.mutateAsync({
query: searchQuery,
tlds: ['.com', '.net', '.org', '.io', '.co'],
});
setSearchResults(results);
} catch {
// Error is handled by React Query
}
};
const handleSelectDomain = (domain: DomainAvailability) => {
setSelectedDomain(domain);
setStep('details');
};
const handlePurchase = async () => {
if (!selectedDomain) return;
try {
await registerMutation.mutateAsync({
domain: selectedDomain.domain,
years,
whois_privacy: whoisPrivacy,
auto_renew: autoRenew,
contact,
auto_configure: autoConfigureDomain,
});
// Reset and go back to search
setStep('search');
setSearchQuery('');
setSearchResults([]);
setSelectedDomain(null);
onSuccess?.();
} catch {
// Error is handled by React Query
}
};
const updateContact = (field: keyof RegistrantContact, value: string) => {
setContact((prev) => ({ ...prev, [field]: value }));
};
const isContactValid = () => {
return (
contact.first_name &&
contact.last_name &&
contact.email &&
contact.phone &&
contact.address &&
contact.city &&
contact.state &&
contact.zip_code &&
contact.country
);
};
const getPrice = () => {
if (!selectedDomain) return 0;
const basePrice = selectedDomain.premium_price || selectedDomain.price || 0;
return basePrice * years;
};
return (
<div className="space-y-6">
{/* Steps indicator */}
<div className="flex items-center gap-4">
<div
className={`flex items-center gap-2 ${
step === 'search' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step === 'search'
? 'bg-brand-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
1
</div>
<span className="text-sm font-medium">Search</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div
className={`flex items-center gap-2 ${
step === 'details' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step === 'details'
? 'bg-brand-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
2
</div>
<span className="text-sm font-medium">Details</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div
className={`flex items-center gap-2 ${
step === 'confirm' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step === 'confirm'
? 'bg-brand-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
3
</div>
<span className="text-sm font-medium">Confirm</span>
</div>
</div>
{/* Step 1: Search */}
{step === 'search' && (
<div className="space-y-6">
<form onSubmit={handleSearch} className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Enter domain name or keyword..."
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<button
type="submit"
disabled={searchMutation.isPending || !searchQuery.trim()}
className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{searchMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Search className="h-5 w-5" />
)}
Search
</button>
</form>
{/* Search Results */}
{searchResults.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium text-gray-900 dark:text-white">Search Results</h4>
<div className="space-y-2">
{searchResults.map((result) => (
<div
key={result.domain}
className={`flex items-center justify-between p-4 rounded-lg border ${
result.available
? 'border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50'
}`}
>
<div className="flex items-center gap-3">
{result.available ? (
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<X className="h-5 w-5 text-gray-400" />
)}
<div>
<span className="font-medium text-gray-900 dark:text-white">
{result.domain}
</span>
{result.premium && (
<span className="ml-2 px-2 py-0.5 text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded">
Premium
</span>
)}
</div>
</div>
<div className="flex items-center gap-4">
{result.available && (
<>
<span className="font-semibold text-gray-900 dark:text-white">
${(result.premium_price || result.price || 0).toFixed(2)}/yr
</span>
<button
onClick={() => handleSelectDomain(result)}
className="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700 flex items-center gap-2"
>
<ShoppingCart className="h-4 w-4" />
Select
</button>
</>
)}
{!result.available && (
<span className="text-sm text-gray-500 dark:text-gray-400">Unavailable</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Registered Domains */}
{registeredDomains && registeredDomains.length > 0 && (
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
Your Registered Domains
</h4>
<div className="space-y-2">
{registeredDomains.map((domain) => (
<div
key={domain.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"
>
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-gray-400" />
<span className="font-medium text-gray-900 dark:text-white">
{domain.domain}
</span>
<span
className={`px-2 py-0.5 text-xs rounded ${
domain.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{domain.status}
</span>
</div>
{domain.expires_at && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Expires: {new Date(domain.expires_at).toLocaleDateString()}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Step 2: Details */}
{step === 'details' && selectedDomain && (
<div className="space-y-6">
{/* Selected Domain */}
<div className="p-4 bg-brand-50 dark:bg-brand-900/20 rounded-lg border border-brand-200 dark:border-brand-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Globe className="h-6 w-6 text-brand-600 dark:text-brand-400" />
<span className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedDomain.domain}
</span>
</div>
<button
onClick={() => setStep('search')}
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
Change
</button>
</div>
</div>
{/* Registration Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Registration Period
</label>
<select
value={years}
onChange={(e) => setYears(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
{[1, 2, 3, 5, 10].map((y) => (
<option key={y} value={y}>
{y} {y === 1 ? 'year' : 'years'} - $
{((selectedDomain.premium_price || selectedDomain.price || 0) * y).toFixed(2)}
</option>
))}
</select>
</div>
</div>
{/* Privacy & Auto-renew */}
<div className="space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={whoisPrivacy}
onChange={(e) => setWhoisPrivacy(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">
WHOIS Privacy Protection
</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Hide your personal information from public WHOIS lookups
</p>
</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoRenew}
onChange={(e) => setAutoRenew(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div className="flex items-center gap-2">
<RefreshCw className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">Auto-Renewal</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically renew this domain before it expires
</p>
</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoConfigureDomain}
onChange={(e) => setAutoConfigureDomain(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">
Auto-configure as Custom Domain
</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically set up this domain for your business
</p>
</div>
</div>
</label>
</div>
{/* Contact Information */}
<div className="pt-6 border-t border-gray-100 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
Registrant Information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name *
</label>
<input
type="text"
value={contact.first_name}
onChange={(e) => updateContact('first_name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name *
</label>
<input
type="text"
value={contact.last_name}
onChange={(e) => updateContact('last_name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
</label>
<input
type="email"
value={contact.email}
onChange={(e) => updateContact('email', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone *
</label>
<input
type="tel"
value={contact.phone}
onChange={(e) => updateContact('phone', e.target.value)}
placeholder="+1.5551234567"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Address *
</label>
<input
type="text"
value={contact.address}
onChange={(e) => updateContact('address', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City *
</label>
<input
type="text"
value={contact.city}
onChange={(e) => updateContact('city', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
State/Province *
</label>
<input
type="text"
value={contact.state}
onChange={(e) => updateContact('state', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
ZIP/Postal Code *
</label>
<input
type="text"
value={contact.zip_code}
onChange={(e) => updateContact('zip_code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country *
</label>
<select
value={contact.country}
onChange={(e) => updateContact('country', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
</select>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-between pt-4">
<button
onClick={() => setStep('search')}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Back
</button>
<button
onClick={() => setStep('confirm')}
disabled={!isContactValid()}
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
</button>
</div>
</div>
)}
{/* Step 3: Confirm */}
{step === 'confirm' && selectedDomain && (
<div className="space-y-6">
<h4 className="font-medium text-gray-900 dark:text-white">Order Summary</h4>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Domain</span>
<span className="font-medium text-gray-900 dark:text-white">
{selectedDomain.domain}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Registration Period</span>
<span className="font-medium text-gray-900 dark:text-white">
{years} {years === 1 ? 'year' : 'years'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">WHOIS Privacy</span>
<span className="font-medium text-gray-900 dark:text-white">
{whoisPrivacy ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Auto-Renewal</span>
<span className="font-medium text-gray-900 dark:text-white">
{autoRenew ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between">
<span className="font-semibold text-gray-900 dark:text-white">Total</span>
<span className="font-bold text-xl text-brand-600 dark:text-brand-400">
${getPrice().toFixed(2)}
</span>
</div>
</div>
</div>
{/* Registrant Summary */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Registrant</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{contact.first_name} {contact.last_name}
<br />
{contact.email}
<br />
{contact.address}
<br />
{contact.city}, {contact.state} {contact.zip_code}
</p>
</div>
{registerMutation.isError && (
<div className="flex items-center gap-2 p-4 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
<AlertCircle className="h-5 w-5" />
<span>Registration failed. Please try again.</span>
</div>
)}
{/* Actions */}
<div className="flex justify-between pt-4">
<button
onClick={() => setStep('details')}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Back
</button>
<button
onClick={handlePurchase}
disabled={registerMutation.isPending}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{registerMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ShoppingCart className="h-5 w-5" />
)}
Complete Purchase
</button>
</div>
</div>
)}
</div>
);
};
export default DomainPurchase;

View File

@@ -0,0 +1,111 @@
/**
* Language Selector Component
* Dropdown for selecting the application language
*/
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe, Check, ChevronDown } from 'lucide-react';
import { supportedLanguages, SupportedLanguage } from '../i18n';
interface LanguageSelectorProps {
variant?: 'dropdown' | 'inline';
showFlag?: boolean;
className?: string;
}
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
variant = 'dropdown',
showFlag = true,
className = '',
}) => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const currentLanguage = supportedLanguages.find(
(lang) => lang.code === i18n.language
) || supportedLanguages[0];
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleLanguageChange = (code: SupportedLanguage) => {
i18n.changeLanguage(code);
setIsOpen(false);
};
if (variant === 'inline') {
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{supportedLanguages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
i18n.language === lang.code
? 'bg-brand-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
>
{showFlag && <span className="mr-1.5">{lang.flag}</span>}
{lang.name}
</button>
))}
</div>
);
}
return (
<div ref={dropdownRef} className={`relative ${className}`}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<Globe className="w-4 h-4" />
{showFlag && <span>{currentLanguage.flag}</span>}
<span className="hidden sm:inline">{currentLanguage.name}</span>
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1 animate-in fade-in slide-in-from-top-2">
<ul role="listbox" aria-label="Select language">
{supportedLanguages.map((lang) => (
<li key={lang.code}>
<button
onClick={() => handleLanguageChange(lang.code)}
className={`w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors ${
i18n.language === lang.code
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
role="option"
aria-selected={i18n.language === lang.code}
>
<span className="text-lg">{lang.flag}</span>
<span className="flex-1">{lang.name}</span>
{i18n.language === lang.code && (
<Check className="w-4 h-4 text-brand-600 dark:text-brand-400" />
)}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default LanguageSelector;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Eye, XCircle } from 'lucide-react';
import { User } from '../types';
interface MasqueradeBannerProps {
effectiveUser: User;
originalUser: User;
previousUser: User | null;
onStop: () => void;
}
const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, originalUser, previousUser, onStop }) => {
const buttonText = previousUser ? `Return to ${previousUser.name}` : 'Stop Masquerading';
return (
<div className="bg-orange-600 text-white px-4 py-2 shadow-md flex items-center justify-between z-50 relative">
<div className="flex items-center gap-3">
<div className="p-1.5 bg-white/20 rounded-full animate-pulse">
<Eye size={18} />
</div>
<span className="text-sm font-medium">
Masquerading as <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
<span className="opacity-75 mx-2 text-xs">|</span>
Logged in as {originalUser.name}
</span>
</div>
<button
onClick={onStop}
className="flex items-center gap-2 px-3 py-1 text-xs font-bold uppercase bg-white text-orange-600 rounded hover:bg-orange-50 transition-colors"
>
<XCircle size={14} />
{buttonText}
</button>
</div>
);
};
export default MasqueradeBanner;

View File

@@ -0,0 +1,156 @@
/**
* OAuth Buttons Component
* Displays OAuth provider buttons with icons and brand colors
*/
import React from 'react';
import { Loader2 } from 'lucide-react';
import { useInitiateOAuth, useOAuthProviders } from '../hooks/useOAuth';
interface OAuthButtonsProps {
onSuccess?: () => void;
disabled?: boolean;
}
// Provider configurations with colors and icons
const providerConfig: Record<
string,
{
name: string;
bgColor: string;
hoverColor: string;
textColor: string;
icon: string;
}
> = {
google: {
name: 'Google',
bgColor: 'bg-white',
hoverColor: 'hover:bg-gray-50',
textColor: 'text-gray-900',
icon: 'G',
},
apple: {
name: 'Apple',
bgColor: 'bg-black',
hoverColor: 'hover:bg-gray-900',
textColor: 'text-white',
icon: '',
},
facebook: {
name: 'Facebook',
bgColor: 'bg-[#1877F2]',
hoverColor: 'hover:bg-[#166FE5]',
textColor: 'text-white',
icon: 'f',
},
linkedin: {
name: 'LinkedIn',
bgColor: 'bg-[#0A66C2]',
hoverColor: 'hover:bg-[#095196]',
textColor: 'text-white',
icon: 'in',
},
microsoft: {
name: 'Microsoft',
bgColor: 'bg-[#00A4EF]',
hoverColor: 'hover:bg-[#0078D4]',
textColor: 'text-white',
icon: 'M',
},
x: {
name: 'X',
bgColor: 'bg-black',
hoverColor: 'hover:bg-gray-900',
textColor: 'text-white',
icon: 'X',
},
twitch: {
name: 'Twitch',
bgColor: 'bg-[#9146FF]',
hoverColor: 'hover:bg-[#7D3ACE]',
textColor: 'text-white',
icon: 'T',
},
};
const OAuthButtons: React.FC<OAuthButtonsProps> = ({ onSuccess, disabled = false }) => {
const { data: providers, isLoading } = useOAuthProviders();
const initiateMutation = useInitiateOAuth();
const handleOAuthClick = (providerId: string) => {
if (disabled || initiateMutation.isPending) return;
initiateMutation.mutate(providerId, {
onSuccess: () => {
onSuccess?.();
},
onError: (error) => {
console.error('OAuth initiation error:', error);
},
});
};
if (isLoading) {
return (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
);
}
if (!providers || providers.length === 0) {
return null;
}
return (
<div className="space-y-3">
{providers.map((provider) => {
const config = providerConfig[provider.name] || {
name: provider.display_name,
bgColor: 'bg-gray-600',
hoverColor: 'hover:bg-gray-700',
textColor: 'text-white',
icon: provider.display_name.charAt(0).toUpperCase(),
};
const isCurrentlyLoading =
initiateMutation.isPending && initiateMutation.variables === provider.name;
return (
<button
key={provider.name}
type="button"
onClick={() => handleOAuthClick(provider.name)}
disabled={disabled || initiateMutation.isPending}
className={`
w-full flex items-center justify-center gap-3 py-3 px-4
border rounded-lg shadow-sm text-sm font-medium
transition-all duration-200 ease-in-out transform active:scale-[0.98]
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400
disabled:opacity-50 disabled:cursor-not-allowed
${config.bgColor} ${config.hoverColor} ${config.textColor}
${provider.name === 'google' ? 'border-gray-300 dark:border-gray-700' : 'border-transparent'}
`}
>
{isCurrentlyLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Connecting...</span>
</>
) : (
<>
<span className="flex items-center justify-center w-5 h-5 font-bold text-sm">
{config.icon}
</span>
<span>Continue with {config.name}</span>
</>
)}
</button>
);
})}
</div>
);
};
export default OAuthButtons;

View File

@@ -0,0 +1,329 @@
/**
* Onboarding Wizard Component
* Multi-step wizard for paid-tier businesses to complete post-signup setup
* Step 1: Welcome/Overview
* Step 2: Stripe Connect setup (embedded)
* Step 3: Completion
*/
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
CheckCircle,
CreditCard,
Rocket,
ArrowRight,
Sparkles,
Loader2,
X,
AlertCircle,
} from 'lucide-react';
import { Business } from '../types';
import { usePaymentConfig } from '../hooks/usePayments';
import { useUpdateBusiness } from '../hooks/useBusiness';
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
interface OnboardingWizardProps {
business: Business;
onComplete: () => void;
onSkip?: () => void;
}
type OnboardingStep = 'welcome' | 'stripe' | 'complete';
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
business,
onComplete,
onSkip,
}) => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [currentStep, setCurrentStep] = useState<OnboardingStep>('welcome');
const { data: paymentConfig, isLoading: configLoading, refetch: refetchConfig } = usePaymentConfig();
const updateBusinessMutation = useUpdateBusiness();
// Check if Stripe Connect is complete
const isStripeConnected = paymentConfig?.connect_account?.status === 'active' &&
paymentConfig?.connect_account?.charges_enabled;
// Handle return from Stripe Connect (for fallback redirect flow)
useEffect(() => {
const connectStatus = searchParams.get('connect');
if (connectStatus === 'complete' || connectStatus === 'refresh') {
// User returned from Stripe, refresh the config
refetchConfig();
// Clear the search params
setSearchParams({});
// Show stripe step to verify completion
setCurrentStep('stripe');
}
}, [searchParams, refetchConfig, setSearchParams]);
// Auto-advance to complete step when Stripe is connected
useEffect(() => {
if (isStripeConnected && currentStep === 'stripe') {
setCurrentStep('complete');
}
}, [isStripeConnected, currentStep]);
// Handle embedded onboarding completion
const handleEmbeddedOnboardingComplete = () => {
refetchConfig();
setCurrentStep('complete');
};
// Handle embedded onboarding error
const handleEmbeddedOnboardingError = (error: string) => {
console.error('Embedded onboarding error:', error);
};
const handleCompleteOnboarding = async () => {
try {
await updateBusinessMutation.mutateAsync({ initialSetupComplete: true });
onComplete();
} catch (err) {
console.error('Failed to complete onboarding:', err);
onComplete(); // Still call onComplete even if the update fails
}
};
const handleSkip = async () => {
try {
await updateBusinessMutation.mutateAsync({ initialSetupComplete: true });
} catch (err) {
console.error('Failed to skip onboarding:', err);
}
if (onSkip) {
onSkip();
} else {
onComplete();
}
};
const steps = [
{ key: 'welcome', label: t('onboarding.steps.welcome') },
{ key: 'stripe', label: t('onboarding.steps.payments') },
{ key: 'complete', label: t('onboarding.steps.complete') },
];
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
// Step indicator component
const StepIndicator = () => (
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step, index) => (
<React.Fragment key={step.key}>
<div
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors ${
index < currentStepIndex
? 'bg-green-500 text-white'
: index === currentStepIndex
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{index < currentStepIndex ? (
<CheckCircle size={16} />
) : (
index + 1
)}
</div>
{index < steps.length - 1 && (
<div
className={`w-12 h-0.5 ${
index < currentStepIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
);
// Welcome step
const WelcomeStep = () => (
<div className="text-center">
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mb-6">
<Sparkles className="text-white" size={32} />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{t('onboarding.welcome.title', { businessName: business.name })}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 max-w-md mx-auto">
{t('onboarding.welcome.subtitle')}
</p>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 mb-6 max-w-md mx-auto">
<h3 className="font-medium text-gray-900 dark:text-white mb-3 text-left">
{t('onboarding.welcome.whatsIncluded')}
</h3>
<ul className="space-y-2 text-left">
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<CreditCard size={18} className="text-blue-500 shrink-0" />
<span>{t('onboarding.welcome.connectStripe')}</span>
</li>
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<CheckCircle size={18} className="text-green-500 shrink-0" />
<span>{t('onboarding.welcome.automaticPayouts')}</span>
</li>
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<CheckCircle size={18} className="text-green-500 shrink-0" />
<span>{t('onboarding.welcome.pciCompliance')}</span>
</li>
</ul>
</div>
<div className="flex flex-col gap-3 max-w-xs mx-auto">
<button
onClick={() => setCurrentStep('stripe')}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
{t('onboarding.welcome.getStarted')}
<ArrowRight size={18} />
</button>
<button
onClick={handleSkip}
className="w-full px-6 py-2 text-gray-500 dark:text-gray-400 text-sm hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
>
{t('onboarding.welcome.skip')}
</button>
</div>
</div>
);
// Stripe Connect step - uses embedded onboarding
const StripeStep = () => (
<div>
<div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-[#635BFF] rounded-full flex items-center justify-center mb-6">
<CreditCard className="text-white" size={32} />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{t('onboarding.stripe.title')}
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
{t('onboarding.stripe.subtitle', { plan: business.plan })}
</p>
</div>
{configLoading ? (
<div className="flex items-center justify-center gap-2 py-8">
<Loader2 className="animate-spin text-gray-400" size={24} />
<span className="text-gray-500">{t('onboarding.stripe.checkingStatus')}</span>
</div>
) : isStripeConnected ? (
<div className="space-y-4 max-w-md mx-auto">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center gap-3">
<CheckCircle className="text-green-600 dark:text-green-400" size={24} />
<div className="text-left">
<h4 className="font-medium text-green-800 dark:text-green-300">
{t('onboarding.stripe.connected.title')}
</h4>
<p className="text-sm text-green-700 dark:text-green-400">
{t('onboarding.stripe.connected.subtitle')}
</p>
</div>
</div>
</div>
<button
onClick={() => setCurrentStep('complete')}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
{t('onboarding.stripe.continue')}
<ArrowRight size={18} />
</button>
</div>
) : (
<div className="max-w-md mx-auto">
<ConnectOnboardingEmbed
connectAccount={paymentConfig?.connect_account || null}
tier={business.plan}
onComplete={handleEmbeddedOnboardingComplete}
onError={handleEmbeddedOnboardingError}
/>
<button
onClick={handleSkip}
className="w-full mt-4 px-6 py-2 text-gray-500 dark:text-gray-400 text-sm hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
>
{t('onboarding.stripe.doLater')}
</button>
</div>
)}
</div>
);
// Complete step
const CompleteStep = () => (
<div className="text-center">
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-green-400 to-green-600 rounded-full flex items-center justify-center mb-6">
<Rocket className="text-white" size={32} />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{t('onboarding.complete.title')}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 max-w-md mx-auto">
{t('onboarding.complete.subtitle')}
</p>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6 max-w-md mx-auto">
<ul className="space-y-2 text-left">
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
<CheckCircle size={16} className="shrink-0" />
<span>{t('onboarding.complete.checklist.accountCreated')}</span>
</li>
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
<CheckCircle size={16} className="shrink-0" />
<span>{t('onboarding.complete.checklist.stripeConfigured')}</span>
</li>
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
<CheckCircle size={16} className="shrink-0" />
<span>{t('onboarding.complete.checklist.readyForPayments')}</span>
</li>
</ul>
</div>
<button
onClick={handleCompleteOnboarding}
disabled={updateBusinessMutation.isPending}
className="px-8 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{updateBusinessMutation.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
t('onboarding.complete.goToDashboard')
)}
</button>
</div>
);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-auto">
{/* Header with close button */}
<div className="flex justify-end p-4 pb-0">
<button
onClick={handleSkip}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={t('onboarding.skipForNow')}
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="px-8 pb-8">
<StepIndicator />
{currentStep === 'welcome' && <WelcomeStep />}
{currentStep === 'stripe' && <StripeStep />}
{currentStep === 'complete' && <CompleteStep />}
</div>
</div>
</div>
);
};
export default OnboardingWizard;

View File

@@ -0,0 +1,220 @@
/**
* Payment Settings Section Component
* Unified payment configuration UI that shows the appropriate setup
* based on the business tier (API keys for Free, Connect for Paid)
*/
import React from 'react';
import {
CreditCard,
CheckCircle,
AlertCircle,
Loader2,
FlaskConical,
Zap,
} from 'lucide-react';
import { Business } from '../types';
import { usePaymentConfig } from '../hooks/usePayments';
import StripeApiKeysForm from './StripeApiKeysForm';
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
interface PaymentSettingsSectionProps {
business: Business;
}
type PaymentModeType = 'direct_api' | 'connect' | 'none';
const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ business }) => {
const { data: config, isLoading, error, refetch } = usePaymentConfig();
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3">
<Loader2 className="animate-spin text-gray-400" size={24} />
<span className="text-gray-600">Loading payment configuration...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 text-red-600">
<AlertCircle size={24} />
<span>Failed to load payment configuration</span>
</div>
<button
onClick={() => refetch()}
className="mt-3 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Retry
</button>
</div>
);
}
const paymentMode = (config?.payment_mode || 'none') as PaymentModeType;
const canAcceptPayments = config?.can_accept_payments || false;
const tier = config?.tier || business.plan || 'Free';
const isFreeTier = tier === 'Free';
// Determine Stripe environment (test vs live) from API keys
const getStripeEnvironment = (): 'test' | 'live' | null => {
const maskedKey = config?.api_keys?.publishable_key_masked;
if (!maskedKey) return null;
if (maskedKey.startsWith('pk_test_')) return 'test';
if (maskedKey.startsWith('pk_live_')) return 'live';
return null;
};
const stripeEnvironment = getStripeEnvironment();
// Status badge component
const StatusBadge = () => {
if (canAcceptPayments) {
return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
<CheckCircle size={12} />
Ready
</span>
);
}
return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">
<AlertCircle size={12} />
Setup Required
</span>
);
};
// Mode description
const getModeDescription = () => {
if (isFreeTier) {
return 'Free tier businesses use their own Stripe API keys for payment processing. No platform fees apply.';
}
return `${tier} tier businesses use Stripe Connect for payment processing with platform-managed payments.`;
};
return (
<div className="bg-white rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<CreditCard className="text-purple-600" size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Payment Configuration</h2>
<p className="text-sm text-gray-500">{getModeDescription()}</p>
</div>
</div>
<StatusBadge />
</div>
</div>
{/* Test/Live Mode Banner */}
{stripeEnvironment && config?.api_keys?.status === 'active' && (
<div
className={`px-6 py-3 flex items-center gap-3 ${
stripeEnvironment === 'test'
? 'bg-amber-50 border-b border-amber-200'
: 'bg-green-50 border-b border-green-200'
}`}
>
{stripeEnvironment === 'test' ? (
<>
<div className="p-2 bg-amber-100 rounded-full">
<FlaskConical className="text-amber-600" size={20} />
</div>
<div className="flex-1">
<p className="font-semibold text-amber-800">Test Mode</p>
<p className="text-sm text-amber-700">
Payments are simulated. No real money will be charged.
</p>
</div>
<a
href="https://dashboard.stripe.com/test/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-amber-700 hover:text-amber-800 underline"
>
Get Live Keys
</a>
</>
) : (
<>
<div className="p-2 bg-green-100 rounded-full">
<Zap className="text-green-600" size={20} />
</div>
<div className="flex-1">
<p className="font-semibold text-green-800">Live Mode</p>
<p className="text-sm text-green-700">
Payments are real. Customers will be charged.
</p>
</div>
</>
)}
</div>
)}
{/* Content */}
<div className="p-6">
{/* Tier info banner */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-600">Current Plan:</span>
<span className={`ml-2 px-2 py-0.5 text-xs font-semibold rounded-full ${
tier === 'Enterprise' ? 'bg-purple-100 text-purple-800' :
tier === 'Business' ? 'bg-blue-100 text-blue-800' :
tier === 'Professional' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`}>
{tier}
</span>
</div>
<div className="text-sm text-gray-600">
Payment Mode:{' '}
<span className="font-medium text-gray-900">
{paymentMode === 'direct_api' ? 'Direct API Keys' :
paymentMode === 'connect' ? 'Stripe Connect' :
'Not Configured'}
</span>
</div>
</div>
</div>
{/* Tier-specific content */}
{isFreeTier ? (
<StripeApiKeysForm
apiKeys={config?.api_keys || null}
onSuccess={() => refetch()}
/>
) : (
<ConnectOnboardingEmbed
connectAccount={config?.connect_account || null}
tier={tier}
onComplete={() => refetch()}
/>
)}
{/* Upgrade notice for free tier with deprecated keys */}
{isFreeTier && config?.api_keys?.status === 'deprecated' && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-800 mb-1">
Upgraded to a Paid Plan?
</h4>
<p className="text-sm text-blue-700">
If you've recently upgraded, your API keys have been deprecated.
Please contact support to complete your Stripe Connect setup.
</p>
</div>
)}
</div>
</div>
);
};
export default PaymentSettingsSection;

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield } from 'lucide-react';
import { User } from '../types';
import SmoothScheduleLogo from './SmoothScheduleLogo';
interface PlatformSidebarProps {
user: User;
isCollapsed: boolean;
toggleCollapse: () => void;
}
const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, toggleCollapse }) => {
const { t } = useTranslation();
const location = useLocation();
const getNavClass = (path: string) => {
const isActive = location.pathname === path || (path !== '/' && location.pathname.startsWith(path));
const baseClasses = `flex items-center gap-3 py-2 text-sm font-medium rounded-md transition-colors`;
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-3';
const activeClasses = 'bg-gray-700 text-white';
const inactiveClasses = 'text-gray-400 hover:text-white hover:bg-gray-800';
return `${baseClasses} ${collapsedClasses} ${isActive ? activeClasses : inactiveClasses}`;
};
const isSuperuser = user.role === 'superuser';
const isManager = user.role === 'platform_manager';
return (
<div className={`flex flex-col h-full bg-gray-900 text-white shrink-0 border-r border-gray-800 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}>
<button
onClick={toggleCollapse}
className={`flex items-center gap-3 w-full text-left px-6 py-6 border-b border-gray-800 ${isCollapsed ? 'justify-center' : ''} hover:bg-gray-800 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<SmoothScheduleLogo className="w-10 h-10 shrink-0" />
{!isCollapsed && (
<div className="overflow-hidden">
<h1 className="font-bold text-sm tracking-wide uppercase text-gray-100 truncate">Smooth Schedule</h1>
<p className="text-xs text-gray-500 capitalize truncate">{user.role.replace('_', ' ')}</p>
</div>
)}
</button>
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
<p className={`text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 ${isCollapsed ? 'text-center' : 'px-3'}`}>{isCollapsed ? 'Ops' : 'Operations'}</p>
{(isSuperuser || isManager) && (
<Link to="/platform/dashboard" className={getNavClass('/platform/dashboard')} title={t('nav.platformDashboard')}>
<LayoutDashboard size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.dashboard')}</span>}
</Link>
)}
<Link to="/platform/businesses" className={getNavClass("/platform/businesses")} title={t('nav.businesses')}>
<Building2 size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.businesses')}</span>}
</Link>
<Link to="/platform/users" className={getNavClass('/platform/users')} title={t('nav.users')}>
<Users size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.users')}</span>}
</Link>
<Link to="/platform/support" className={getNavClass('/platform/support')} title={t('nav.support')}>
<MessageSquare size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.support')}</span>}
</Link>
{isSuperuser && (
<>
<p className={`text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 mt-8 ${isCollapsed ? 'text-center' : 'px-3'}`}>{isCollapsed ? 'Sys' : 'System'}</p>
<Link to="/platform/staff" className={getNavClass('/platform/staff')} title={t('nav.staff')}>
<Shield size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
<Link to="/platform/settings" className={getNavClass('/platform/settings')} title={t('nav.platformSettings')}>
<Settings size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.platformSettings')}</span>}
</Link>
</>
)}
</nav>
</div>
);
};
export default PlatformSidebar;

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface PortalProps {
children: React.ReactNode;
}
/**
* Portal component that renders children directly into document.body.
* This bypasses any parent stacking contexts created by CSS transforms,
* ensuring modals with fixed positioning cover the entire viewport.
*/
const Portal: React.FC<PortalProps> = ({ children }) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) return null;
return createPortal(children, document.body);
};
export default Portal;

View File

@@ -0,0 +1,252 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CalendarPlus, Clock, User, Briefcase, MapPin, FileText, Loader2, Check } from 'lucide-react';
import { useServices } from '../hooks/useServices';
import { useResources } from '../hooks/useResources';
import { useCustomers } from '../hooks/useCustomers';
import { useCreateAppointment } from '../hooks/useAppointments';
import { format } from 'date-fns';
interface QuickAddAppointmentProps {
onSuccess?: () => void;
}
const QuickAddAppointment: React.FC<QuickAddAppointmentProps> = ({ onSuccess }) => {
const { t } = useTranslation();
const { data: services } = useServices();
const { data: resources } = useResources();
const { data: customers } = useCustomers();
const createAppointment = useCreateAppointment();
const [customerId, setCustomerId] = useState('');
const [serviceId, setServiceId] = useState('');
const [resourceId, setResourceId] = useState('');
const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [time, setTime] = useState('09:00');
const [notes, setNotes] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
// Get selected service to auto-fill duration
const selectedService = useMemo(() => {
return services?.find(s => s.id === serviceId);
}, [services, serviceId]);
// Generate time slots (every 15 minutes from 6am to 10pm)
const timeSlots = useMemo(() => {
const slots = [];
for (let hour = 6; hour <= 22; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const h = hour.toString().padStart(2, '0');
const m = minute.toString().padStart(2, '0');
slots.push(`${h}:${m}`);
}
}
return slots;
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!serviceId || !date || !time) {
return;
}
const [hours, minutes] = time.split(':').map(Number);
const startTime = new Date(date);
startTime.setHours(hours, minutes, 0, 0);
try {
await createAppointment.mutateAsync({
customerId: customerId || undefined,
customerName: customerId ? (customers?.find(c => c.id === customerId)?.name || '') : 'Walk-in',
serviceId,
resourceId: resourceId || null,
startTime,
durationMinutes: selectedService?.durationMinutes || 60,
status: 'Scheduled',
notes,
});
// Show success state
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 2000);
// Reset form
setCustomerId('');
setServiceId('');
setResourceId('');
setNotes('');
setTime('09:00');
onSuccess?.();
} catch (error) {
console.error('Failed to create appointment:', error);
}
};
const activeCustomers = customers?.filter(c => c.status === 'Active') || [];
return (
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
<CalendarPlus className="h-5 w-5 text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('dashboard.quickAddAppointment', 'Quick Add Appointment')}
</h3>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Customer Select */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<User className="inline h-4 w-4 mr-1" />
{t('appointments.customer', 'Customer')}
</label>
<select
value={customerId}
onChange={(e) => setCustomerId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="">{t('appointments.walkIn', 'Walk-in / No customer')}</option>
{activeCustomers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name} {customer.email && `(${customer.email})`}
</option>
))}
</select>
</div>
{/* Service Select */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<Briefcase className="inline h-4 w-4 mr-1" />
{t('appointments.service', 'Service')} *
</label>
<select
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="">{t('appointments.selectService', 'Select service...')}</option>
{services?.map((service) => (
<option key={service.id} value={service.id}>
{service.name} ({service.durationMinutes} min - ${service.price})
</option>
))}
</select>
</div>
{/* Resource Select (Optional) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<MapPin className="inline h-4 w-4 mr-1" />
{t('appointments.resource', 'Resource')}
</label>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="">{t('appointments.unassigned', 'Unassigned')}</option>
{resources?.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
</div>
{/* Date and Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('appointments.date', 'Date')} *
</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
min={format(new Date(), 'yyyy-MM-dd')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<Clock className="inline h-4 w-4 mr-1" />
{t('appointments.time', 'Time')} *
</label>
<select
value={time}
onChange={(e) => setTime(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
{timeSlots.map((slot) => (
<option key={slot} value={slot}>
{slot}
</option>
))}
</select>
</div>
</div>
{/* Duration Display */}
{selectedService && (
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<Clock className="h-4 w-4" />
{t('appointments.duration', 'Duration')}: {selectedService.durationMinutes} {t('common.minutes', 'minutes')}
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<FileText className="inline h-4 w-4 mr-1" />
{t('appointments.notes', 'Notes')}
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder={t('appointments.notesPlaceholder', 'Optional notes...')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
/>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={createAppointment.isPending || !serviceId}
className={`w-full py-2.5 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${
showSuccess
? 'bg-green-600 text-white'
: 'bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
}`}
>
{createAppointment.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('common.creating', 'Creating...')}
</>
) : showSuccess ? (
<>
<Check className="h-4 w-4" />
{t('common.created', 'Created!')}
</>
) : (
<>
<CalendarPlus className="h-4 w-4" />
{t('appointments.addAppointment', 'Add Appointment')}
</>
)}
</button>
</form>
</div>
);
};
export default QuickAddAppointment;

View File

@@ -0,0 +1,729 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns';
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
import { Appointment } from '../types';
import Portal from './Portal';
type ViewMode = 'day' | 'week' | 'month';
// Format duration as hours and minutes when >= 60 min
const formatDuration = (minutes: number): string => {
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
return `${minutes} min`;
};
// Constants for timeline rendering
const PIXELS_PER_HOUR = 64;
const PIXELS_PER_MINUTE = PIXELS_PER_HOUR / 60;
interface ResourceCalendarProps {
resourceId: string;
resourceName: string;
onClose: () => void;
}
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [currentDate, setCurrentDate] = useState(new Date());
const timelineRef = useRef<HTMLDivElement>(null);
const timeLabelsRef = useRef<HTMLDivElement>(null);
// Drag state
const [dragState, setDragState] = useState<{
appointmentId: string;
startY: number;
originalStartTime: Date;
originalDuration: number;
} | null>(null);
const [dragPreview, setDragPreview] = useState<Date | null>(null);
// Resize state
const [resizeState, setResizeState] = useState<{
appointmentId: string;
direction: 'top' | 'bottom';
startY: number;
originalStartTime: Date;
originalDuration: number;
} | null>(null);
const [resizePreview, setResizePreview] = useState<{ startTime: Date; duration: number } | null>(null);
const updateMutation = useUpdateAppointment();
// Auto-scroll to current time or 8 AM when switching to day/week view
useEffect(() => {
if ((viewMode === 'day' || viewMode === 'week') && timelineRef.current) {
const now = new Date();
const scrollToHour = isToday(currentDate)
? Math.max(now.getHours() - 1, 0) // Scroll to an hour before current time
: 8; // Default to 8 AM for other days
timelineRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR;
// Sync time labels scroll
if (timeLabelsRef.current) {
timeLabelsRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR;
}
}
}, [viewMode, currentDate]);
// Sync scroll between timeline and time labels (for week view)
useEffect(() => {
const timeline = timelineRef.current;
const timeLabels = timeLabelsRef.current;
if (!timeline || !timeLabels) return;
const handleTimelineScroll = () => {
if (timeLabels) {
timeLabels.scrollTop = timeline.scrollTop;
}
};
timeline.addEventListener('scroll', handleTimelineScroll);
return () => timeline.removeEventListener('scroll', handleTimelineScroll);
}, [viewMode]);
// Helper to get Monday of the week containing the given date
const getMonday = (date: Date) => {
return startOfWeek(date, { weekStartsOn: 1 }); // 1 = Monday
};
// Helper to get Friday of the week (4 days after Monday)
const getFriday = (date: Date) => {
return addDays(getMonday(date), 4);
};
// Calculate date range based on view mode
const dateRange = useMemo(() => {
switch (viewMode) {
case 'day':
return { startDate: startOfDay(currentDate), endDate: addDays(startOfDay(currentDate), 1) };
case 'week':
// Full week (Monday to Sunday)
return { startDate: getMonday(currentDate), endDate: addDays(getMonday(currentDate), 7) };
case 'month':
return { startDate: startOfMonth(currentDate), endDate: addDays(endOfMonth(currentDate), 1) };
}
}, [viewMode, currentDate]);
// Fetch appointments for this resource within the date range
const { data: allAppointments = [], isLoading } = useAppointments({
resource: resourceId,
...dateRange
});
// Filter appointments for this specific resource
const appointments = useMemo(() => {
const resourceIdStr = String(resourceId);
return allAppointments.filter(apt => apt.resourceId === resourceIdStr);
}, [allAppointments, resourceId]);
const navigatePrevious = () => {
switch (viewMode) {
case 'day':
setCurrentDate(addDays(currentDate, -1));
break;
case 'week':
setCurrentDate(addWeeks(currentDate, -1));
break;
case 'month':
setCurrentDate(addMonths(currentDate, -1));
break;
}
};
const navigateNext = () => {
switch (viewMode) {
case 'day':
setCurrentDate(addDays(currentDate, 1));
break;
case 'week':
setCurrentDate(addWeeks(currentDate, 1));
break;
case 'month':
setCurrentDate(addMonths(currentDate, 1));
break;
}
};
const goToToday = () => {
setCurrentDate(new Date());
};
const getTitle = () => {
switch (viewMode) {
case 'day':
return format(currentDate, 'EEEE, MMMM d, yyyy');
case 'week':
const weekStart = getMonday(currentDate);
const weekEnd = addDays(weekStart, 6); // Sunday
return `${format(weekStart, 'MMM d')} - ${format(weekEnd, 'MMM d, yyyy')}`;
case 'month':
return format(currentDate, 'MMMM yyyy');
}
};
// Get appointments for a specific day
const getAppointmentsForDay = (day: Date) => {
return appointments.filter(apt => isSameDay(new Date(apt.startTime), day));
};
// Convert Y position to time
const yToTime = (y: number, baseDate: Date): Date => {
const minutes = Math.round((y / PIXELS_PER_MINUTE) / 15) * 15; // Snap to 15 min
const result = new Date(baseDate);
result.setHours(0, 0, 0, 0);
result.setMinutes(minutes);
return result;
};
// Handle drag start
const handleDragStart = (e: React.MouseEvent, apt: Appointment) => {
e.preventDefault();
const rect = timelineRef.current?.getBoundingClientRect();
if (!rect) return;
setDragState({
appointmentId: apt.id,
startY: e.clientY,
originalStartTime: new Date(apt.startTime),
originalDuration: apt.durationMinutes,
});
};
// Handle resize start
const handleResizeStart = (e: React.MouseEvent, apt: Appointment, direction: 'top' | 'bottom') => {
e.preventDefault();
e.stopPropagation();
setResizeState({
appointmentId: apt.id,
direction,
startY: e.clientY,
originalStartTime: new Date(apt.startTime),
originalDuration: apt.durationMinutes,
});
};
// Mouse move handler for drag and resize
useEffect(() => {
if (!dragState && !resizeState) return;
const handleMouseMove = (e: MouseEvent) => {
if (dragState) {
const deltaY = e.clientY - dragState.startY;
const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15;
const newStartTime = new Date(dragState.originalStartTime.getTime() + deltaMinutes * 60000);
// Keep within same day
const dayStart = startOfDay(dragState.originalStartTime);
const dayEnd = endOfDay(dragState.originalStartTime);
if (newStartTime >= dayStart && newStartTime <= dayEnd) {
setDragPreview(newStartTime);
}
}
if (resizeState) {
const deltaY = e.clientY - resizeState.startY;
const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15;
if (resizeState.direction === 'bottom') {
// Resize from bottom - change duration
const newDuration = Math.max(15, resizeState.originalDuration + deltaMinutes);
setResizePreview({
startTime: resizeState.originalStartTime,
duration: newDuration,
});
} else {
// Resize from top - change start time and duration
const newStartTime = new Date(resizeState.originalStartTime.getTime() + deltaMinutes * 60000);
const newDuration = Math.max(15, resizeState.originalDuration - deltaMinutes);
// Keep within same day
const dayStart = startOfDay(resizeState.originalStartTime);
if (newStartTime >= dayStart) {
setResizePreview({
startTime: newStartTime,
duration: newDuration,
});
}
}
}
};
const handleMouseUp = () => {
if (dragState && dragPreview) {
updateMutation.mutate({
id: dragState.appointmentId,
updates: {
startTime: dragPreview,
durationMinutes: dragState.originalDuration, // Preserve duration when dragging
}
});
}
if (resizeState && resizePreview) {
updateMutation.mutate({
id: resizeState.appointmentId,
updates: {
startTime: resizePreview.startTime,
durationMinutes: resizePreview.duration,
}
});
}
setDragState(null);
setDragPreview(null);
setResizeState(null);
setResizePreview(null);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragState, dragPreview, resizeState, resizePreview, updateMutation]);
// Calculate lanes for overlapping appointments
const calculateLanes = (appts: Appointment[]): Map<string, { lane: number; totalLanes: number }> => {
const laneMap = new Map<string, { lane: number; totalLanes: number }>();
if (appts.length === 0) return laneMap;
// Sort by start time
const sorted = [...appts].sort((a, b) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
// Get end time for an appointment
const getEndTime = (apt: Appointment) => {
return new Date(apt.startTime).getTime() + apt.durationMinutes * 60000;
};
// Find overlapping groups
const groups: Appointment[][] = [];
let currentGroup: Appointment[] = [];
let groupEndTime = 0;
for (const apt of sorted) {
const aptStart = new Date(apt.startTime).getTime();
const aptEnd = getEndTime(apt);
if (currentGroup.length === 0 || aptStart < groupEndTime) {
// Overlaps with current group
currentGroup.push(apt);
groupEndTime = Math.max(groupEndTime, aptEnd);
} else {
// Start new group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [apt];
groupEndTime = aptEnd;
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
// Assign lanes within each group
for (const group of groups) {
const totalLanes = group.length;
// Sort by start time within group
group.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
group.forEach((apt, index) => {
laneMap.set(apt.id, { lane: index, totalLanes });
});
}
return laneMap;
};
const renderDayView = () => {
const dayStart = startOfDay(currentDate);
const hours = eachHourOfInterval({
start: dayStart,
end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000)
});
const dayAppointments = getAppointmentsForDay(currentDate);
const laneAssignments = calculateLanes(dayAppointments);
return (
<div className="flex-1 overflow-y-auto min-h-0" ref={timelineRef}>
<div className="relative ml-16" style={{ height: hours.length * PIXELS_PER_HOUR }}>
{/* Hour grid lines */}
{hours.map((hour) => (
<div key={hour.toISOString()} className="border-b border-gray-200 dark:border-gray-700 relative" style={{ height: PIXELS_PER_HOUR }}>
<div className="absolute -left-16 top-0 w-14 text-xs text-gray-500 dark:text-gray-400 pr-2 text-right">
{format(hour, 'h a')}
</div>
{/* Half-hour line */}
<div className="absolute left-0 right-0 top-1/2 border-t border-dashed border-gray-100 dark:border-gray-800" />
</div>
))}
{/* Render appointments */}
{dayAppointments.map((apt) => {
const isDragging = dragState?.appointmentId === apt.id;
const isResizing = resizeState?.appointmentId === apt.id;
// Use preview values if dragging/resizing this appointment
let displayStartTime = new Date(apt.startTime);
let displayDuration = apt.durationMinutes;
if (isDragging && dragPreview) {
displayStartTime = dragPreview;
}
if (isResizing && resizePreview) {
displayStartTime = resizePreview.startTime;
displayDuration = resizePreview.duration;
}
const startHour = displayStartTime.getHours() + displayStartTime.getMinutes() / 60;
const durationHours = displayDuration / 60;
const top = startHour * PIXELS_PER_HOUR;
const height = Math.max(durationHours * PIXELS_PER_HOUR, 30);
// Get lane info for overlapping appointments
const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 };
const widthPercent = 100 / laneInfo.totalLanes;
const leftPercent = laneInfo.lane * widthPercent;
return (
<div
key={apt.id}
className={`absolute bg-brand-100 dark:bg-brand-900/50 border-t-4 border-brand-500 rounded-b px-2 py-1 overflow-hidden cursor-move select-none group transition-shadow ${
isDragging || isResizing ? 'shadow-lg ring-2 ring-brand-500 z-20' : 'hover:shadow-md z-10'
}`}
style={{
top: `${top}px`,
height: `${height}px`,
left: `${leftPercent}%`,
width: `calc(${widthPercent}% - 8px)`,
}}
onMouseDown={(e) => handleDragStart(e, apt)}
>
{/* Top resize handle */}
<div
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
onMouseDown={(e) => handleResizeStart(e, apt, 'top')}
/>
<div className="text-sm font-medium text-gray-900 dark:text-white truncate pointer-events-none mt-2">
{apt.customerName}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 pointer-events-none">
<Clock size={10} />
{format(displayStartTime, 'h:mm a')} {formatDuration(displayDuration)}
</div>
{/* Bottom resize handle */}
<div
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
onMouseDown={(e) => handleResizeStart(e, apt, 'bottom')}
/>
</div>
);
})}
{/* Current time indicator */}
{isToday(currentDate) && (
<div
className="absolute left-0 right-0 border-t-2 border-red-500 z-30 pointer-events-none"
style={{
top: `${(new Date().getHours() + new Date().getMinutes() / 60) * PIXELS_PER_HOUR}px`
}}
>
<div className="absolute -left-1.5 -top-1.5 w-3 h-3 bg-red-500 rounded-full" />
</div>
)}
</div>
</div>
);
};
const renderWeekView = () => {
// Full week Monday to Sunday
const days = eachDayOfInterval({
start: getMonday(currentDate),
end: addDays(getMonday(currentDate), 6)
});
const dayStart = startOfDay(days[0]);
const hours = eachHourOfInterval({
start: dayStart,
end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000)
});
const DAY_COLUMN_WIDTH = 200; // pixels per day column
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Day headers - fixed at top */}
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
<div className="w-16 flex-shrink-0" /> {/* Spacer for time column */}
<div className="flex overflow-hidden">
{days.map((day) => (
<div
key={day.toISOString()}
className={`flex-shrink-0 text-center py-2 font-medium text-sm border-l border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 ${
isToday(day) ? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20' : 'text-gray-900 dark:text-white'
}`}
style={{ width: DAY_COLUMN_WIDTH }}
onClick={() => {
setCurrentDate(day);
setViewMode('day');
}}
>
{format(day, 'EEE, MMM d')}
</div>
))}
</div>
</div>
{/* Scrollable timeline grid */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Time labels - fixed left column */}
<div ref={timeLabelsRef} className="w-16 flex-shrink-0 overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
<div style={{ height: hours.length * PIXELS_PER_HOUR }}>
{hours.map((hour) => (
<div key={hour.toISOString()} className="relative" style={{ height: PIXELS_PER_HOUR }}>
<div className="absolute top-0 right-2 text-xs text-gray-500 dark:text-gray-400">
{format(hour, 'h a')}
</div>
</div>
))}
</div>
</div>
{/* Day columns with appointments - scrollable both ways */}
<div className="flex-1 overflow-auto" ref={timelineRef}>
<div className="flex" style={{ height: hours.length * PIXELS_PER_HOUR, width: days.length * DAY_COLUMN_WIDTH }}>
{days.map((day) => {
const dayAppointments = getAppointmentsForDay(day);
const laneAssignments = calculateLanes(dayAppointments);
return (
<div
key={day.toISOString()}
className="relative flex-shrink-0 border-l border-gray-200 dark:border-gray-700"
style={{ width: DAY_COLUMN_WIDTH }}
onClick={() => {
setCurrentDate(day);
setViewMode('day');
}}
>
{/* Hour grid lines */}
{hours.map((hour) => (
<div
key={hour.toISOString()}
className="border-b border-gray-100 dark:border-gray-800"
style={{ height: PIXELS_PER_HOUR }}
>
<div className="absolute left-0 right-0 border-t border-dashed border-gray-100 dark:border-gray-800" style={{ top: PIXELS_PER_HOUR / 2 }} />
</div>
))}
{/* Appointments for this day */}
{dayAppointments.map((apt) => {
const aptStartTime = new Date(apt.startTime);
const startHour = aptStartTime.getHours() + aptStartTime.getMinutes() / 60;
const durationHours = apt.durationMinutes / 60;
const top = startHour * PIXELS_PER_HOUR;
const height = Math.max(durationHours * PIXELS_PER_HOUR, 24);
const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 };
const widthPercent = 100 / laneInfo.totalLanes;
const leftPercent = laneInfo.lane * widthPercent;
return (
<div
key={apt.id}
className="absolute bg-brand-100 dark:bg-brand-900/50 border-t-2 border-brand-500 rounded-b px-1 py-0.5 overflow-hidden cursor-pointer hover:shadow-md hover:z-10 text-xs"
style={{
top: `${top}px`,
height: `${height}px`,
left: `${leftPercent}%`,
width: `calc(${widthPercent}% - 4px)`,
}}
onClick={(e) => {
e.stopPropagation();
setCurrentDate(day);
setViewMode('day');
}}
>
<div className="font-medium text-gray-900 dark:text-white truncate">
{apt.customerName}
</div>
<div className="text-gray-500 dark:text-gray-400 truncate">
{format(aptStartTime, 'h:mm a')}
</div>
</div>
);
})}
{/* Current time indicator for today */}
{isToday(day) && (
<div
className="absolute left-0 right-0 border-t-2 border-red-500 z-20 pointer-events-none"
style={{
top: `${(new Date().getHours() + new Date().getMinutes() / 60) * PIXELS_PER_HOUR}px`
}}
/>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};
const renderMonthView = () => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
// Start padding from Monday (weekStartsOn: 1)
const startDayOfWeek = getDay(monthStart);
// Adjust for Monday start: if Sunday (0), it's 6 days from Monday; otherwise subtract 1
const paddingDays = Array(startDayOfWeek === 0 ? 6 : startDayOfWeek - 1).fill(null);
return (
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => (
<div key={day} className="text-center text-xs font-medium text-gray-500 dark:text-gray-400 py-2">
{day}
</div>
))}
{paddingDays.map((_, index) => (
<div key={`padding-${index}`} className="min-h-20" />
))}
{days.map((day) => {
const dayAppointments = getAppointmentsForDay(day);
const dayOfWeek = getDay(day);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
return (
<div
key={day.toISOString()}
className={`min-h-20 p-2 border border-gray-200 dark:border-gray-700 rounded cursor-pointer hover:border-brand-300 dark:hover:border-brand-700 transition-colors ${
isToday(day) ? 'bg-brand-50 dark:bg-brand-900/20' : isWeekend ? 'bg-gray-50 dark:bg-gray-900/30' : 'bg-white dark:bg-gray-800'
}`}
onClick={() => {
// Drill down to week view showing the week containing this day
setCurrentDate(day);
setViewMode('week');
}}
>
<div className={`text-sm font-medium mb-1 ${isToday(day) ? 'text-brand-600 dark:text-brand-400' : isWeekend ? 'text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white'}`}>
{format(day, 'd')}
</div>
{dayAppointments.length > 0 && (
<div className="text-xs">
<div className="text-brand-600 dark:text-brand-400 font-medium">
{dayAppointments.length} appt{dayAppointments.length > 1 ? 's' : ''}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
return (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-6xl h-[80vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{resourceName} Calendar</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{viewMode === 'day' ? 'Drag to move, drag edges to resize' : 'Click a day to view details'}
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<X size={24} />
</button>
</div>
{/* Toolbar */}
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={navigatePrevious}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
<ChevronLeft size={20} />
</button>
<button
onClick={goToToday}
className="px-3 py-1 text-sm font-medium bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Today
</button>
<button
onClick={navigateNext}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
<ChevronRight size={20} />
</button>
<div className="ml-4 text-lg font-semibold text-gray-900 dark:text-white">
{getTitle()}
</div>
</div>
{/* View Mode Selector */}
<div className="flex gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
{(['day', 'week', 'month'] as ViewMode[]).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`px-4 py-1.5 text-sm font-medium rounded transition-colors capitalize ${viewMode === mode
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{mode}
</button>
))}
</div>
</div>
{/* Calendar Content */}
<div className="flex-1 min-h-0 flex flex-col relative">
{viewMode === 'day' && renderDayView()}
{viewMode === 'week' && renderWeekView()}
{viewMode === 'month' && renderMonthView()}
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 dark:text-gray-500">Loading appointments...</p>
</div>
)}
{!isLoading && appointments.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 dark:text-gray-500">No appointments scheduled for this period</p>
</div>
)}
</div>
</div>
</Portal>
);
};
export default ResourceCalendar;

View File

@@ -0,0 +1,89 @@
.service-list {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.service-list h2 {
font-size: 2rem;
margin-bottom: 2rem;
color: #1a202c;
}
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.service-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.service-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.service-card h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: #2d3748;
}
.service-details {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #4a5568;
}
.service-duration {
background: #edf2f7;
padding: 0.25rem 0.75rem;
border-radius: 4px;
}
.service-price {
font-weight: bold;
color: #2b6cb0;
font-size: 1.1rem;
}
.service-description {
color: #718096;
font-size: 0.9rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.service-book-btn {
width: 100%;
padding: 0.75rem;
background: #3182ce;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.service-book-btn:hover {
background: #2c5282;
}
.service-list-loading,
.service-list-empty {
text-align: center;
padding: 3rem;
color: #718096;
font-size: 1.1rem;
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import './ServiceList.css';
const ServiceList = ({ services, onSelectService, loading }) => {
if (loading) {
return <div className="service-list-loading">Loading services...</div>;
}
if (!services || services.length === 0) {
return <div className="service-list-empty">No services available</div>;
}
return (
<div className="service-list">
<h2>Available Services</h2>
<div className="service-grid">
{services.map((service) => (
<div
key={service.id}
className="service-card"
onClick={() => onSelectService(service)}
>
<h3>{service.name}</h3>
<div className="service-details">
<span className="service-duration">{service.duration} min</span>
<span className="service-price">${service.price}</span>
</div>
{service.description && (
<p className="service-description">{service.description}</p>
)}
<button className="service-book-btn">Book Now</button>
</div>
))}
</div>
</div>
);
};
export default ServiceList;

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
CalendarDays,
Settings,
Users,
CreditCard,
MessageSquare,
LogOut,
ClipboardList,
Briefcase
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import SmoothScheduleLogo from './SmoothScheduleLogo';
interface SidebarProps {
business: Business;
user: User;
isCollapsed: boolean;
toggleCollapse: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCollapse }) => {
const { t } = useTranslation();
const location = useLocation();
const { role } = user;
const logoutMutation = useLogout();
const getNavClass = (path: string, exact: boolean = false, disabled: boolean = false) => {
const isActive = exact
? location.pathname === path
: location.pathname.startsWith(path);
const baseClasses = `flex items-center gap-3 py-3 text-sm font-medium rounded-lg transition-colors`;
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
const activeClasses = 'bg-opacity-10 text-white bg-white';
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
const disabledClasses = 'text-white/30 cursor-not-allowed';
if (disabled) {
return `${baseClasses} ${collapsedClasses} ${disabledClasses}`;
}
return `${baseClasses} ${collapsedClasses} ${isActive ? activeClasses : inactiveClasses}`;
};
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
const canViewSettings = role === 'owner';
const getDashboardLink = () => {
if (role === 'resource') return '/';
return '/';
};
const handleSignOut = () => {
logoutMutation.mutate();
};
return (
<div
className={`flex flex-col h-full text-white shrink-0 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}
style={{ backgroundColor: business.primaryColor }}
>
<button
onClick={toggleCollapse}
className={`flex items-center gap-3 w-full text-left px-6 py-8 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
{business.name.substring(0, 2).toUpperCase()}
</div>
{!isCollapsed && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
</div>
)}
</button>
<nav className="flex-1 px-4 space-y-1 overflow-y-auto">
<Link to={getDashboardLink()} className={getNavClass('/', true)} title={t('nav.dashboard')}>
<LayoutDashboard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.dashboard')}</span>}
</Link>
<Link to="/scheduler" className={getNavClass('/scheduler')} title={t('nav.scheduler')}>
<CalendarDays size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.scheduler')}</span>}
</Link>
{canViewManagementPages && (
<>
<Link to="/customers" className={getNavClass('/customers')} title={t('nav.customers')}>
<Users size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.customers')}</span>}
</Link>
<Link to="/services" className={getNavClass('/services')} title={t('nav.services', 'Services')}>
<Briefcase size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.services', 'Services')}</span>}
</Link>
<Link to="/resources" className={getNavClass('/resources')} title={t('nav.resources')}>
<ClipboardList size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.resources')}</span>}
</Link>
</>
)}
{canViewAdminPages && (
<>
{business.paymentsEnabled ? (
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</Link>
) : (
<div
className={getNavClass('/payments', false, true)}
title={t('nav.paymentsDisabledTooltip')}
>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</div>
)}
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
<MessageSquare size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.messages')}</span>}
</Link>
<Link to="/staff" className={getNavClass('/staff')} title={t('nav.staff')}>
<Users size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
</>
)}
{canViewSettings && (
<div className="pt-8 mt-8 border-t border-white/10">
{canViewSettings && (
<Link to="/settings" className={getNavClass('/settings', true)} title={t('nav.businessSettings')}>
<Settings size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.businessSettings')}</span>}
</Link>
)}
</div>
)}
</nav>
<div className="p-4 border-t border-white/10">
<div className={`flex items-center gap-2 text-xs text-white/60 mb-4 ${isCollapsed ? 'justify-center' : ''}`}>
<SmoothScheduleLogo className="w-6 h-6 text-white" />
{!isCollapsed && (
<div>
<span className="block">{t('common.poweredBy')}</span>
<span className="font-semibold text-white/80">Smooth Schedule</span>
</div>
)}
</div>
<button
onClick={handleSignOut}
disabled={logoutMutation.isPending}
className={`flex items-center gap-3 px-4 py-2 text-sm font-medium text-white/70 hover:text-white w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
>
<LogOut size={20} className="shrink-0" />
{!isCollapsed && <span>{t('auth.signOut')}</span>}
</button>
</div>
</div>
);
};
export default Sidebar;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,441 @@
/**
* Stripe API Keys Form Component
* For free-tier businesses to enter and manage their Stripe API keys
*/
import React, { useState } from 'react';
import {
Key,
Eye,
EyeOff,
CheckCircle,
AlertCircle,
Loader2,
Trash2,
RefreshCw,
FlaskConical,
Zap,
} from 'lucide-react';
import { ApiKeysInfo } from '../api/payments';
import {
useValidateApiKeys,
useSaveApiKeys,
useDeleteApiKeys,
useRevalidateApiKeys,
} from '../hooks/usePayments';
interface StripeApiKeysFormProps {
apiKeys: ApiKeysInfo | null;
onSuccess?: () => void;
}
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
const [secretKey, setSecretKey] = useState('');
const [publishableKey, setPublishableKey] = useState('');
const [showSecretKey, setShowSecretKey] = useState(false);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
accountName?: string;
environment?: string;
error?: string;
} | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const validateMutation = useValidateApiKeys();
const saveMutation = useSaveApiKeys();
const deleteMutation = useDeleteApiKeys();
const revalidateMutation = useRevalidateApiKeys();
const isConfigured = apiKeys && apiKeys.status !== 'deprecated';
const isDeprecated = apiKeys?.status === 'deprecated';
const isInvalid = apiKeys?.status === 'invalid';
// Determine if using test or live keys from the masked key prefix
const getKeyEnvironment = (maskedKey: string | undefined): 'test' | 'live' | null => {
if (!maskedKey) return null;
if (maskedKey.startsWith('pk_test_') || maskedKey.startsWith('sk_test_')) return 'test';
if (maskedKey.startsWith('pk_live_') || maskedKey.startsWith('sk_live_')) return 'live';
return null;
};
const keyEnvironment = getKeyEnvironment(apiKeys?.publishable_key_masked);
const handleValidate = async () => {
setValidationResult(null);
try {
const result = await validateMutation.mutateAsync({ secretKey, publishableKey });
setValidationResult({
valid: result.valid,
accountName: result.account_name,
environment: result.environment,
error: result.error,
});
} catch (error: any) {
setValidationResult({
valid: false,
error: error.response?.data?.error || 'Validation failed',
});
}
};
const handleSave = async () => {
try {
await saveMutation.mutateAsync({ secretKey, publishableKey });
setSecretKey('');
setPublishableKey('');
setValidationResult(null);
onSuccess?.();
} catch (error: any) {
setValidationResult({
valid: false,
error: error.response?.data?.error || 'Failed to save keys',
});
}
};
const handleDelete = async () => {
try {
await deleteMutation.mutateAsync();
setShowDeleteConfirm(false);
onSuccess?.();
} catch (error) {
console.error('Failed to delete keys:', error);
}
};
const handleRevalidate = async () => {
try {
await revalidateMutation.mutateAsync();
onSuccess?.();
} catch (error) {
console.error('Failed to revalidate keys:', error);
}
};
const canSave = validationResult?.valid && secretKey && publishableKey;
return (
<div className="space-y-6">
{/* Current Configuration */}
{isConfigured && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<CheckCircle size={18} className="text-green-500" />
Stripe Keys Configured
</h4>
<div className="flex items-center gap-2">
{/* Environment Badge */}
{keyEnvironment && (
<span
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full ${
keyEnvironment === 'test'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{keyEnvironment === 'test' ? (
<>
<FlaskConical size={12} />
Test Mode
</>
) : (
<>
<Zap size={12} />
Live Mode
</>
)}
</span>
)}
{/* Status Badge */}
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
apiKeys.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: apiKeys.status === 'invalid'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
}`}
>
{apiKeys.status}
</span>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Publishable Key:</span>
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.publishable_key_masked}</code>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Secret Key:</span>
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.secret_key_masked}</code>
</div>
{apiKeys.stripe_account_name && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Account:</span>
<span className="text-gray-900 dark:text-white">{apiKeys.stripe_account_name}</span>
</div>
)}
{apiKeys.last_validated_at && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Last Validated:</span>
<span className="text-gray-900 dark:text-white">
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
</span>
</div>
)}
</div>
{/* Test Mode Warning */}
{keyEnvironment === 'test' && apiKeys.status === 'active' && (
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<FlaskConical size={16} className="shrink-0 mt-0.5" />
<span>
You are using <strong>test keys</strong>. Payments will not be processed for real.
Switch to live keys when ready to accept real payments.
</span>
</div>
)}
{isInvalid && apiKeys.validation_error && (
<div className="mt-3 p-2 bg-red-50 rounded text-sm text-red-700">
{apiKeys.validation_error}
</div>
)}
<div className="flex gap-2 mt-4">
<button
onClick={handleRevalidate}
disabled={revalidateMutation.isPending}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
{revalidateMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<RefreshCw size={16} />
)}
Re-validate
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-lg hover:bg-red-50"
>
<Trash2 size={16} />
Remove
</button>
</div>
</div>
)}
{/* Deprecated Notice */}
{isDeprecated && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
<div>
<h4 className="font-medium text-yellow-800">API Keys Deprecated</h4>
<p className="text-sm text-yellow-700 mt-1">
Your API keys have been deprecated because you upgraded to a paid tier.
Please complete Stripe Connect onboarding to accept payments.
</p>
</div>
</div>
</div>
)}
{/* Add/Update Keys Form */}
{(!isConfigured || isDeprecated) && (
<div className="space-y-4">
<h4 className="font-medium text-gray-900">
{isConfigured ? 'Update API Keys' : 'Add Stripe API Keys'}
</h4>
<p className="text-sm text-gray-600">
Enter your Stripe API keys to enable payment collection.
You can find these in your{' '}
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Stripe Dashboard
</a>
.
</p>
{/* Publishable Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Publishable Key
</label>
<div className="relative">
<Key
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
value={publishableKey}
onChange={(e) => {
setPublishableKey(e.target.value);
setValidationResult(null);
}}
placeholder="pk_test_..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
{/* Secret Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Secret Key
</label>
<div className="relative">
<Key
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type={showSecretKey ? 'text' : 'password'}
value={secretKey}
onChange={(e) => {
setSecretKey(e.target.value);
setValidationResult(null);
}}
placeholder="sk_test_..."
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
<button
type="button"
onClick={() => setShowSecretKey(!showSecretKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showSecretKey ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{/* Validation Result */}
{validationResult && (
<div
className={`flex items-start gap-2 p-3 rounded-lg ${
validationResult.valid
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{validationResult.valid ? (
<CheckCircle size={18} className="shrink-0 mt-0.5" />
) : (
<AlertCircle size={18} className="shrink-0 mt-0.5" />
)}
<div className="text-sm flex-1">
{validationResult.valid ? (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium">Keys are valid!</span>
{validationResult.environment && (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
validationResult.environment === 'test'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{validationResult.environment === 'test' ? (
<>
<FlaskConical size={10} />
Test Mode
</>
) : (
<>
<Zap size={10} />
Live Mode
</>
)}
</span>
)}
</div>
{validationResult.accountName && (
<div>Connected to: {validationResult.accountName}</div>
)}
{validationResult.environment === 'test' && (
<div className="text-amber-700 dark:text-amber-400 text-xs mt-1">
These are test keys. No real payments will be processed.
</div>
)}
</div>
) : (
<span>{validationResult.error}</span>
)}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleValidate}
disabled={!secretKey || !publishableKey || validateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{validateMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<CheckCircle size={16} />
)}
Validate
</button>
<button
onClick={handleSave}
disabled={!canSave || saveMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saveMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Key size={16} />
)}
Save Keys
</button>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Remove API Keys?
</h3>
<p className="text-gray-600 mb-4">
Are you sure you want to remove your Stripe API keys?
You will not be able to accept payments until you add them again.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{deleteMutation.isPending && <Loader2 size={16} className="animate-spin" />}
Remove
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default StripeApiKeysForm;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Bell, Search, Moon, Sun, Menu } from 'lucide-react';
import { User } from '../types';
import UserProfileDropdown from './UserProfileDropdown';
import LanguageSelector from './LanguageSelector';
interface TopBarProps {
user: User;
isDarkMode: boolean;
toggleTheme: () => void;
onMenuClick: () => void;
}
const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuClick }) => {
const { t } = useTranslation();
return (
<header className="flex items-center justify-between h-16 px-4 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 transition-colors duration-200 shrink-0">
<div className="flex items-center gap-4">
<button
onClick={onMenuClick}
className="p-2 -ml-2 text-gray-500 rounded-md md:hidden hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-brand-500"
aria-label="Open sidebar"
>
<Menu size={24} />
</button>
<div className="relative hidden md:block w-96">
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
<Search size={18} />
</span>
<input
type="text"
placeholder={t('common.search')}
className="w-full py-2 pl-10 pr-4 text-sm text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 placeholder-gray-400 dark:placeholder-gray-500 transition-colors duration-200"
/>
</div>
</div>
<div className="flex items-center gap-4">
<LanguageSelector />
<button
onClick={toggleTheme}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
</button>
<button className="relative p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
<Bell size={20} />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<UserProfileDropdown user={user} />
</div>
</header>
);
};
export default TopBar;

View File

@@ -0,0 +1,549 @@
/**
* Transaction Detail Modal
*
* Displays comprehensive transaction information and provides refund functionality.
* Supports both partial and full refunds with reason selection.
*/
import React, { useState } from 'react';
import {
X,
CreditCard,
User,
Mail,
Calendar,
DollarSign,
RefreshCcw,
CheckCircle,
Clock,
XCircle,
AlertCircle,
Receipt,
ExternalLink,
Loader2,
ArrowLeftRight,
Percent,
} from 'lucide-react';
import { TransactionDetail, RefundInfo, RefundRequest } from '../api/payments';
import { useTransactionDetail, useRefundTransaction } from '../hooks/useTransactionAnalytics';
import Portal from './Portal';
interface TransactionDetailModalProps {
transactionId: number | null;
onClose: () => void;
}
const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
transactionId,
onClose,
}) => {
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
const refundMutation = useRefundTransaction();
// Refund form state
const [showRefundForm, setShowRefundForm] = useState(false);
const [refundType, setRefundType] = useState<'full' | 'partial'>('full');
const [refundAmount, setRefundAmount] = useState('');
const [refundReason, setRefundReason] = useState<RefundRequest['reason']>('requested_by_customer');
const [refundError, setRefundError] = useState<string | null>(null);
if (!transactionId) return null;
const handleRefund = async () => {
if (!transaction) return;
setRefundError(null);
const request: RefundRequest = {
reason: refundReason,
};
// For partial refunds, include the amount
if (refundType === 'partial') {
const amountCents = Math.round(parseFloat(refundAmount) * 100);
if (isNaN(amountCents) || amountCents <= 0) {
setRefundError('Please enter a valid refund amount');
return;
}
if (amountCents > transaction.refundable_amount) {
setRefundError(`Amount exceeds refundable amount ($${(transaction.refundable_amount / 100).toFixed(2)})`);
return;
}
request.amount = amountCents;
}
try {
await refundMutation.mutateAsync({
transactionId: transaction.id,
request,
});
setShowRefundForm(false);
setRefundAmount('');
} catch (err: any) {
setRefundError(err.response?.data?.error || 'Failed to process refund');
}
};
// Status badge helper
const getStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
succeeded: { bg: 'bg-green-100', text: 'text-green-800', icon: <CheckCircle size={14} /> },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: <Clock size={14} /> },
failed: { bg: 'bg-red-100', text: 'text-red-800', icon: <XCircle size={14} /> },
refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: <RefreshCcw size={14} /> },
partially_refunded: { bg: 'bg-orange-100', text: 'text-orange-800', icon: <RefreshCcw size={14} /> },
};
const style = styles[status] || styles.pending;
return (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full ${style.bg} ${style.text}`}>
{style.icon}
{status.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
);
};
// Format date helper
const formatDate = (dateStr: string | number) => {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Format timestamp for refunds
const formatRefundDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Get payment method display
const getPaymentMethodDisplay = () => {
if (!transaction?.payment_method_info) return null;
const pm = transaction.payment_method_info;
if (pm.type === 'card') {
return (
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<CreditCard className="text-gray-600" size={20} />
</div>
<div>
<p className="font-medium text-gray-900">
{pm.brand} **** {pm.last4}
</p>
{pm.exp_month && pm.exp_year && (
<p className="text-sm text-gray-500">
Expires {pm.exp_month}/{pm.exp_year}
{pm.funding && ` (${pm.funding})`}
</p>
)}
</div>
</div>
);
}
return (
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<DollarSign className="text-gray-600" size={20} />
</div>
<div>
<p className="font-medium text-gray-900 capitalize">{pm.type.replace('_', ' ')}</p>
{pm.bank_name && <p className="text-sm text-gray-500">{pm.bank_name}</p>}
</div>
</div>
);
};
return (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div
className="w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Transaction Details
</h3>
{transaction && (
<p className="text-sm text-gray-500 font-mono">
{transaction.stripe_payment_intent_id}
</p>
)}
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-gray-400" size={32} />
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-700">
<AlertCircle size={18} />
<p className="font-medium">Failed to load transaction details</p>
</div>
</div>
)}
{transaction && (
<>
{/* Status & Amount */}
<div className="flex items-start justify-between">
<div>
{getStatusBadge(transaction.status)}
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">
{transaction.amount_display}
</p>
<p className="text-sm text-gray-500">
{transaction.transaction_type.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
</p>
</div>
{transaction.can_refund && !showRefundForm && (
<button
onClick={() => setShowRefundForm(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
<RefreshCcw size={16} />
Issue Refund
</button>
)}
</div>
{/* Refund Form */}
{showRefundForm && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-4">
<div className="flex items-center gap-2 text-red-800">
<RefreshCcw size={18} />
<h4 className="font-semibold">Issue Refund</h4>
</div>
{/* Refund Type */}
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="refundType"
checked={refundType === 'full'}
onChange={() => setRefundType('full')}
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">
Full refund (${(transaction.refundable_amount / 100).toFixed(2)})
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="refundType"
checked={refundType === 'partial'}
onChange={() => setRefundType('partial')}
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Partial refund</span>
</label>
</div>
{/* Partial Amount */}
{refundType === 'partial' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refund Amount (max ${(transaction.refundable_amount / 100).toFixed(2)})
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
min="0.01"
max={(transaction.refundable_amount / 100).toFixed(2)}
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
placeholder="0.00"
className="w-full pl-7 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
</div>
)}
{/* Reason */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refund Reason
</label>
<select
value={refundReason}
onChange={(e) => setRefundReason(e.target.value as RefundRequest['reason'])}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
<option value="requested_by_customer">Requested by customer</option>
<option value="duplicate">Duplicate charge</option>
<option value="fraudulent">Fraudulent</option>
</select>
</div>
{refundError && (
<div className="flex items-center gap-2 text-red-600 text-sm">
<AlertCircle size={16} />
{refundError}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3">
<button
onClick={handleRefund}
disabled={refundMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{refundMutation.isPending ? (
<>
<Loader2 className="animate-spin" size={16} />
Processing...
</>
) : (
<>
<RefreshCcw size={16} />
Confirm Refund
</>
)}
</button>
<button
onClick={() => {
setShowRefundForm(false);
setRefundError(null);
setRefundAmount('');
}}
disabled={refundMutation.isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
>
Cancel
</button>
</div>
</div>
)}
{/* Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Customer Info */}
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<User size={16} />
Customer
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
{transaction.customer_name && (
<div className="flex items-center gap-2 text-sm">
<User size={14} className="text-gray-400" />
<span className="text-gray-900 dark:text-white font-medium">
{transaction.customer_name}
</span>
</div>
)}
{transaction.customer_email && (
<div className="flex items-center gap-2 text-sm">
<Mail size={14} className="text-gray-400" />
<span className="text-gray-600 dark:text-gray-300">
{transaction.customer_email}
</span>
</div>
)}
</div>
</div>
{/* Amount Breakdown */}
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<DollarSign size={16} />
Amount Breakdown
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Gross Amount</span>
<span className="font-medium">{transaction.amount_display}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Platform Fee</span>
<span className="text-red-600">-{transaction.fee_display}</span>
</div>
{transaction.total_refunded > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Refunded</span>
<span className="text-orange-600">
-${(transaction.total_refunded / 100).toFixed(2)}
</span>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
<span className="font-medium text-gray-900 dark:text-white">Net Amount</span>
<span className="font-bold text-green-600">
${(transaction.net_amount / 100).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
{/* Payment Method */}
{transaction.payment_method_info && (
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<CreditCard size={16} />
Payment Method
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
{getPaymentMethodDisplay()}
</div>
</div>
)}
{/* Description */}
{transaction.description && (
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Receipt size={16} />
Description
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-gray-700 dark:text-gray-300">{transaction.description}</p>
</div>
</div>
)}
{/* Refund History */}
{transaction.refunds && transaction.refunds.length > 0 && (
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<RefreshCcw size={16} />
Refund History
</h4>
<div className="space-y-3">
{transaction.refunds.map((refund: RefundInfo) => (
<div
key={refund.id}
className="bg-orange-50 border border-orange-200 rounded-lg p-4 flex items-center justify-between"
>
<div>
<p className="font-medium text-orange-800">{refund.amount_display}</p>
<p className="text-sm text-orange-600">
{refund.reason
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
: 'No reason provided'}
</p>
<p className="text-xs text-orange-500 mt-1">
{formatRefundDate(refund.created)}
</p>
</div>
<div className="text-right">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
refund.status === 'succeeded'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{refund.status === 'succeeded' ? (
<CheckCircle size={12} />
) : (
<Clock size={12} />
)}
{refund.status}
</span>
<p className="text-xs text-gray-500 mt-1 font-mono">{refund.id}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Timeline */}
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Calendar size={16} />
Timeline
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 text-sm">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-gray-600">Created</span>
<span className="ml-auto text-gray-900 dark:text-white">
{formatDate(transaction.created_at)}
</span>
</div>
{transaction.updated_at !== transaction.created_at && (
<div className="flex items-center gap-3 text-sm">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="text-gray-600">Last Updated</span>
<span className="ml-auto text-gray-900 dark:text-white">
{formatDate(transaction.updated_at)}
</span>
</div>
)}
</div>
</div>
{/* Technical Details */}
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowLeftRight size={16} />
Technical Details
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2 font-mono text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Payment Intent</span>
<span className="text-gray-700 dark:text-gray-300">
{transaction.stripe_payment_intent_id}
</span>
</div>
{transaction.stripe_charge_id && (
<div className="flex justify-between">
<span className="text-gray-500">Charge ID</span>
<span className="text-gray-700 dark:text-gray-300">
{transaction.stripe_charge_id}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500">Transaction ID</span>
<span className="text-gray-700 dark:text-gray-300">{transaction.id}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Currency</span>
<span className="text-gray-700 dark:text-gray-300 uppercase">
{transaction.currency}
</span>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
</Portal>
);
};
export default TransactionDetailModal;

View File

@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Clock, X, ArrowRight, Sparkles } from 'lucide-react';
import { Business } from '../types';
interface TrialBannerProps {
business: Business;
}
/**
* TrialBanner Component
* Shows at the top of the business layout when trial is active
* Displays days remaining and upgrade CTA
* Dismissible but reappears on page reload
*/
const TrialBanner: React.FC<TrialBannerProps> = ({ business }) => {
const { t } = useTranslation();
const [isDismissed, setIsDismissed] = useState(false);
const navigate = useNavigate();
if (isDismissed || !business.isTrialActive || !business.daysLeftInTrial) {
return null;
}
const daysLeft = business.daysLeftInTrial;
const isUrgent = daysLeft <= 3;
const trialEndDate = business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : '';
const handleUpgrade = () => {
navigate('/upgrade');
};
const handleDismiss = () => {
setIsDismissed(true);
};
return (
<div
className={`relative ${
isUrgent
? 'bg-gradient-to-r from-red-500 to-orange-500'
: 'bg-gradient-to-r from-blue-600 to-blue-500'
} text-white shadow-md`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between gap-4">
{/* Left: Trial Info */}
<div className="flex items-center gap-3 flex-1">
<div className={`p-2 rounded-full ${isUrgent ? 'bg-white/20' : 'bg-white/20'} backdrop-blur-sm`}>
{isUrgent ? (
<Clock size={20} className="animate-pulse" />
) : (
<Sparkles size={20} />
)}
</div>
<div className="flex-1">
<p className="font-semibold text-sm sm:text-base">
{t('trial.banner.title')} - {t('trial.banner.daysLeft', { days: daysLeft })}
</p>
<p className="text-xs sm:text-sm text-white/90 hidden sm:block">
{t('trial.banner.expiresOn', { date: trialEndDate })}
</p>
</div>
</div>
{/* Right: CTA Button */}
<div className="flex items-center gap-2">
<button
onClick={handleUpgrade}
className="group px-4 py-2 bg-white text-blue-600 hover:bg-blue-50 rounded-lg font-semibold text-sm transition-all shadow-lg hover:shadow-xl flex items-center gap-2"
>
{t('trial.banner.upgradeNow')}
<ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" />
</button>
{/* Dismiss Button */}
<button
onClick={handleDismiss}
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
aria-label={t('trial.banner.dismiss')}
>
<X size={20} />
</button>
</div>
</div>
</div>
</div>
);
};
export default TrialBanner;

View File

@@ -0,0 +1,150 @@
import React, { useState, useRef, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { User, Settings, LogOut, ChevronDown } from 'lucide-react';
import { User as UserType } from '../types';
import { useLogout } from '../hooks/useAuth';
interface UserProfileDropdownProps {
user: UserType;
variant?: 'default' | 'light'; // 'light' for colored headers
}
const UserProfileDropdown: React.FC<UserProfileDropdownProps> = ({ user, variant = 'default' }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const location = useLocation();
// Determine the profile route based on current path
const isPlatform = location.pathname.startsWith('/platform');
const profilePath = isPlatform ? '/platform/profile' : '/profile';
const isLight = variant === 'light';
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Close dropdown on escape key
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, []);
const handleSignOut = () => {
logout();
};
// Get user initials for fallback avatar
const getInitials = (name: string) => {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
// Format role for display
const formatRole = (role: string) => {
return role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-3 pl-6 border-l hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg ${
isLight
? 'border-white/20 focus:ring-white/50'
: 'border-gray-200 dark:border-gray-700 focus:ring-brand-500'
}`}
aria-expanded={isOpen}
aria-haspopup="true"
>
<div className="text-right hidden sm:block">
<p className={`text-sm font-medium ${isLight ? 'text-white' : 'text-gray-900 dark:text-white'}`}>
{user.name}
</p>
<p className={`text-xs ${isLight ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'}`}>
{formatRole(user.role)}
</p>
</div>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name}
className={`w-10 h-10 rounded-full object-cover ${
isLight ? 'border-2 border-white/30' : 'border border-gray-200 dark:border-gray-600'
}`}
/>
) : (
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium ${
isLight
? 'border-2 border-white/30 bg-white/20 text-white'
: 'border border-gray-200 dark:border-gray-600 bg-brand-500 text-white'
}`}>
{getInitials(user.name)}
</div>
)}
<ChevronDown
size={16}
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''} ${
isLight ? 'text-white/70' : 'text-gray-400'
}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
{/* User Info Header */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{user.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<Link
to={profilePath}
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<Settings size={16} className="text-gray-400" />
Profile Settings
</Link>
</div>
{/* Sign Out */}
<div className="border-t border-gray-200 dark:border-gray-700 py-1">
<button
onClick={handleSignOut}
disabled={isLoggingOut}
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<LogOut size={16} />
{isLoggingOut ? 'Signing out...' : 'Sign Out'}
</button>
</div>
</div>
)}
</div>
);
};
export default UserProfileDropdown;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight } from 'lucide-react';
interface CTASectionProps {
variant?: 'default' | 'minimal';
}
const CTASection: React.FC<CTASectionProps> = ({ variant = 'default' }) => {
const { t } = useTranslation();
if (variant === 'minimal') {
return (
<section className="py-16 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.cta.ready')}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
{t('marketing.cta.readySubtitle')}
</p>
<Link
to="/signup"
className="inline-flex items-center gap-2 px-6 py-3 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-colors"
>
{t('marketing.cta.startFree')}
<ArrowRight className="h-5 w-5" />
</Link>
</div>
</section>
);
}
return (
<section className="py-20 lg:py-28 bg-gradient-to-br from-brand-600 to-brand-700 relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
</div>
<div className="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-6">
{t('marketing.cta.ready')}
</h2>
<p className="text-lg sm:text-xl text-brand-100 mb-10 max-w-2xl mx-auto">
{t('marketing.cta.readySubtitle')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
to="/signup"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4 text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 shadow-lg shadow-black/10 transition-colors"
>
{t('marketing.cta.startFree')}
<ArrowRight className="h-5 w-5" />
</Link>
<Link
to="/contact"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4 text-base font-semibold text-white bg-white/10 rounded-xl hover:bg-white/20 border border-white/20 transition-colors"
>
{t('marketing.cta.talkToSales')}
</Link>
</div>
<p className="mt-6 text-sm text-brand-200">
{t('marketing.cta.noCredit')}
</p>
</div>
</section>
);
};
export default CTASection;

View File

@@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { ChevronDown } from 'lucide-react';
interface FAQItem {
question: string;
answer: string;
}
interface FAQAccordionProps {
items: FAQItem[];
}
const FAQAccordion: React.FC<FAQAccordionProps> = ({ items }) => {
const [openIndex, setOpenIndex] = useState<number | null>(0);
const toggleItem = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="space-y-4">
{items.map((item, index) => (
<div
key={index}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<button
onClick={() => toggleItem(index)}
className="w-full flex items-center justify-between p-6 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
aria-expanded={openIndex === index}
>
<span className="text-base font-medium text-gray-900 dark:text-white dark:hover:text-white pr-4">
{item.question}
</span>
<ChevronDown
className={`h-5 w-5 text-gray-500 dark:text-gray-400 flex-shrink-0 transition-transform duration-200 ${
openIndex === index ? 'rotate-180' : ''
}`}
/>
</button>
<div
className={`overflow-hidden transition-all duration-200 ${
openIndex === index ? 'max-h-96' : 'max-h-0'
}`}
>
<div className="px-6 pt-2 pb-6 text-gray-600 dark:text-gray-400 leading-relaxed">
{item.answer}
</div>
</div>
</div>
))}
</div>
);
};
export default FAQAccordion;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface FeatureCardProps {
icon: LucideIcon;
title: string;
description: string;
iconColor?: string;
}
const FeatureCard: React.FC<FeatureCardProps> = ({
icon: Icon,
title,
description,
iconColor = 'brand',
}) => {
const colorClasses: Record<string, string> = {
brand: 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400',
green: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
purple: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
orange: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400',
pink: 'bg-pink-100 dark:bg-pink-900/30 text-pink-600 dark:text-pink-400',
cyan: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400',
};
return (
<div className="group p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-lg hover:shadow-brand-600/5 transition-all duration-300">
<div className={`inline-flex p-3 rounded-xl ${colorClasses[iconColor]} mb-4`}>
<Icon className="h-6 w-6" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">
{title}
</h3>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
{description}
</p>
</div>
);
};
export default FeatureCard;

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Twitter, Linkedin, Github, Youtube } from 'lucide-react';
import SmoothScheduleLogo from '../SmoothScheduleLogo';
const Footer: React.FC = () => {
const { t } = useTranslation();
const currentYear = new Date().getFullYear();
const footerLinks = {
product: [
{ to: '/features', label: t('marketing.nav.features') },
{ to: '/pricing', label: t('marketing.nav.pricing') },
{ to: '/signup', label: t('marketing.nav.getStarted') },
],
company: [
{ to: '/about', label: t('marketing.nav.about') },
{ to: '/contact', label: t('marketing.nav.contact') },
],
legal: [
{ to: '/privacy', label: t('marketing.footer.legal.privacy') },
{ to: '/terms', label: t('marketing.footer.legal.terms') },
],
};
const socialLinks = [
{ href: 'https://twitter.com/smoothschedule', icon: Twitter, label: 'Twitter' },
{ href: 'https://linkedin.com/company/smoothschedule', icon: Linkedin, label: 'LinkedIn' },
{ href: 'https://github.com/smoothschedule', icon: Github, label: 'GitHub' },
{ href: 'https://youtube.com/@smoothschedule', icon: Youtube, label: 'YouTube' },
];
return (
<footer className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
{/* Main Footer Content */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 lg:gap-12">
{/* Brand Column */}
<div className="col-span-2 md:col-span-1">
<Link to="/" className="flex items-center gap-2 mb-4 group">
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
<span className="text-lg font-bold text-gray-900 dark:text-white">
Smooth Schedule
</span>
</Link>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t('marketing.description')}
</p>
{/* Social Links */}
<div className="flex items-center gap-4">
{socialLinks.map((social) => (
<a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-gray-500 hover:text-brand-600 dark:text-gray-400 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={social.label}
>
<social.icon className="h-5 w-5" />
</a>
))}
</div>
</div>
{/* Product Links */}
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
{t('marketing.footer.product.title')}
</h3>
<ul className="space-y-3">
{footerLinks.product.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Company Links */}
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
{t('marketing.footer.company.title')}
</h3>
<ul className="space-y-3">
{footerLinks.company.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Legal Links */}
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
{t('marketing.footer.legal.title')}
</h3>
<ul className="space-y-3">
{footerLinks.legal.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-800">
<p className="text-sm text-center text-gray-500 dark:text-gray-400">
&copy; {currentYear} {t('marketing.footer.copyright')}
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,166 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Play, ArrowRight, CheckCircle } from 'lucide-react';
const Hero: React.FC = () => {
const { t } = useTranslation();
return (
<section className="relative overflow-hidden bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
{/* Background Pattern */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-brand-100 dark:bg-brand-500/10 rounded-full blur-3xl opacity-50 dark:opacity-30" />
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-brand-100 dark:bg-brand-500/10 rounded-full blur-3xl opacity-50 dark:opacity-30" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 lg:py-32">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
{/* Left Content */}
<div className="text-center lg:text-left">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand-50 dark:bg-brand-900/30 border border-brand-200 dark:border-brand-800 mb-6">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-brand-700 dark:text-brand-300">
{t('marketing.pricing.startToday')}
</span>
</div>
{/* Headline */}
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-tight mb-6">
{t('marketing.hero.headline')}
</h1>
{/* Subheadline */}
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 mb-8 max-w-xl mx-auto lg:mx-0">
{t('marketing.hero.subheadline')}
</p>
{/* CTAs */}
<div className="flex flex-col sm:flex-row items-center gap-4 justify-center lg:justify-start mb-8">
<Link
to="/signup"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 shadow-lg shadow-brand-600/25 hover:shadow-brand-600/40 transition-all duration-200"
>
{t('marketing.hero.cta')}
<ArrowRight className="h-5 w-5" />
</Link>
<button
onClick={() => {/* TODO: Open demo modal/video */}}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 text-base font-semibold text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<Play className="h-5 w-5" />
{t('marketing.hero.secondaryCta')}
</button>
</div>
{/* Trust Indicators */}
<div className="flex flex-col sm:flex-row items-center gap-4 text-sm text-gray-500 dark:text-gray-400 justify-center lg:justify-start">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span>{t('marketing.pricing.noCredit')}</span>
</div>
<div className="hidden sm:block w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span>{t('marketing.pricing.startToday')}</span>
</div>
</div>
</div>
{/* Right Content - Dashboard Preview */}
<div className="relative">
<div className="relative rounded-2xl overflow-hidden shadow-2xl shadow-brand-600/10 border border-gray-200 dark:border-gray-700">
{/* Mock Dashboard */}
<div className="bg-white dark:bg-gray-800 aspect-[4/3]">
{/* Mock Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400" />
<div className="w-3 h-3 rounded-full bg-yellow-400" />
<div className="w-3 h-3 rounded-full bg-green-400" />
</div>
<div className="flex-1 text-center">
<div className="inline-block px-4 py-1 rounded-lg bg-gray-100 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400">
dashboard.smoothschedule.com
</div>
</div>
</div>
{/* Mock Content */}
<div className="p-4 space-y-4">
{/* Stats Row */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'Today', value: '12', color: 'brand' },
{ label: 'This Week', value: '48', color: 'green' },
{ label: 'Revenue', value: '$2.4k', color: 'purple' },
].map((stat) => (
<div key={stat.label} className="p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{stat.label}</div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{stat.value}</div>
</div>
))}
</div>
{/* Calendar Mock */}
<div className="rounded-lg bg-gray-50 dark:bg-gray-700/50 p-3">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-3">Today's Schedule</div>
<div className="space-y-2">
{[
{ time: '9:00 AM', title: 'Sarah J. - Haircut', color: 'brand' },
{ time: '10:30 AM', title: 'Mike T. - Consultation', color: 'green' },
{ time: '2:00 PM', title: 'Emma W. - Color', color: 'purple' },
].map((apt, i) => (
<div key={i} className="flex items-center gap-3 p-2 rounded-lg bg-white dark:bg-gray-800">
<div className={`w-1 h-8 rounded-full ${
apt.color === 'brand' ? 'bg-brand-500' :
apt.color === 'green' ? 'bg-green-500' : 'bg-purple-500'
}`} />
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">{apt.time}</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{apt.title}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Floating Elements */}
<div className="absolute -bottom-4 -left-4 px-4 py-3 rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">New Booking!</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Just now</div>
</div>
</div>
</div>
</div>
</div>
{/* Trust Badge */}
<div className="mt-16 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{t('marketing.hero.trustedBy')}
</p>
<div className="flex flex-wrap items-center justify-center gap-8 opacity-50">
{/* Mock company logos - replace with actual logos */}
{['TechCorp', 'Innovate', 'StartupX', 'GrowthCo', 'ScaleUp'].map((name) => (
<div key={name} className="text-lg font-bold text-gray-400 dark:text-gray-500">
{name}
</div>
))}
</div>
</div>
</div>
</section>
);
};
export default Hero;

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { UserPlus, Settings, Rocket } from 'lucide-react';
const HowItWorks: React.FC = () => {
const { t } = useTranslation();
const steps = [
{
number: '01',
icon: UserPlus,
title: t('marketing.howItWorks.step1.title'),
description: t('marketing.howItWorks.step1.description'),
color: 'brand',
},
{
number: '02',
icon: Settings,
title: t('marketing.howItWorks.step2.title'),
description: t('marketing.howItWorks.step2.description'),
color: 'purple',
},
{
number: '03',
icon: Rocket,
title: t('marketing.howItWorks.step3.title'),
description: t('marketing.howItWorks.step3.description'),
color: 'green',
},
];
const colorClasses: Record<string, { bg: string; text: string; border: string }> = {
brand: {
bg: 'bg-brand-100 dark:bg-brand-900/30',
text: 'text-brand-600 dark:text-brand-400',
border: 'border-brand-200 dark:border-brand-800',
},
purple: {
bg: 'bg-purple-100 dark:bg-purple-900/30',
text: 'text-purple-600 dark:text-purple-400',
border: 'border-purple-200 dark:border-purple-800',
},
green: {
bg: 'bg-green-100 dark:bg-green-900/30',
text: 'text-green-600 dark:text-green-400',
border: 'border-green-200 dark:border-green-800',
},
};
return (
<section className="py-20 lg:py-28 bg-gray-50 dark:bg-gray-800/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.howItWorks.title')}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{t('marketing.howItWorks.subtitle')}
</p>
</div>
{/* Steps */}
<div className="grid md:grid-cols-3 gap-8 lg:gap-12">
{steps.map((step, index) => {
const colors = colorClasses[step.color];
return (
<div key={step.number} className="relative">
{/* Connector Line (hidden on mobile) */}
{index < steps.length - 1 && (
<div className="hidden md:block absolute top-16 left-1/2 w-full h-0.5 bg-gradient-to-r from-gray-200 dark:from-gray-700 to-transparent" />
)}
<div className="relative bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 text-center">
{/* Step Number */}
<div className={`absolute -top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full ${colors.bg} ${colors.text} border ${colors.border} text-sm font-bold`}>
{step.number}
</div>
{/* Icon */}
<div className={`inline-flex p-4 rounded-2xl ${colors.bg} mb-6`}>
<step.icon className={`h-8 w-8 ${colors.text}`} />
</div>
{/* Content */}
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
{step.title}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{step.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</section>
);
};
export default HowItWorks;

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Menu, X, Sun, Moon } from 'lucide-react';
import SmoothScheduleLogo from '../SmoothScheduleLogo';
import LanguageSelector from '../LanguageSelector';
interface NavbarProps {
darkMode: boolean;
toggleTheme: () => void;
}
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
const { t } = useTranslation();
const location = useLocation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Close mobile menu on route change
useEffect(() => {
setIsMenuOpen(false);
}, [location.pathname]);
const navLinks = [
{ to: '/features', label: t('marketing.nav.features') },
{ to: '/pricing', label: t('marketing.nav.pricing') },
{ to: '/about', label: t('marketing.nav.about') },
{ to: '/contact', label: t('marketing.nav.contact') },
];
const isActive = (path: string) => location.pathname === path;
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled
? 'bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg shadow-sm'
: 'bg-transparent'
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 lg:h-20">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 group">
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
<span className="text-xl font-bold text-gray-900 dark:text-white hidden sm:block">
Smooth Schedule
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden lg:flex items-center gap-8">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className={`text-sm font-medium transition-colors ${
isActive(link.to)
? 'text-brand-600 dark:text-brand-400'
: 'text-gray-600 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400'
}`}
>
{link.label}
</Link>
))}
</div>
{/* Right Section */}
<div className="flex items-center gap-3">
{/* Language Selector - Hidden on mobile */}
<div className="hidden md:block">
<LanguageSelector />
</div>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{/* Login Button - Hidden on mobile */}
<Link
to="/login"
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{t('marketing.nav.login')}
</Link>
{/* Get Started CTA */}
<Link
to="/signup"
className="hidden sm:inline-flex px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors shadow-sm"
>
{t('marketing.nav.getStarted')}
</Link>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="lg:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Toggle menu"
>
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
</div>
</div>
{/* Mobile Menu */}
<div
className={`lg:hidden overflow-hidden transition-all duration-300 ${
isMenuOpen ? 'max-h-96' : 'max-h-0'
}`}
>
<div className="px-4 py-4 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-2">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className={`px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive(link.to)
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-600 dark:text-brand-400'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{link.label}
</Link>
))}
<hr className="my-2 border-gray-200 dark:border-gray-800" />
<Link
to="/login"
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{t('marketing.nav.login')}
</Link>
<Link
to="/signup"
className="px-4 py-3 rounded-lg text-sm font-medium text-center text-white bg-brand-600 hover:bg-brand-700 transition-colors"
>
{t('marketing.nav.getStarted')}
</Link>
<div className="px-4 py-2">
<LanguageSelector />
</div>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,185 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check } from 'lucide-react';
interface PricingCardProps {
tier: 'free' | 'professional' | 'business' | 'enterprise';
highlighted?: boolean;
billingPeriod: 'monthly' | 'annual';
}
const PricingCard: React.FC<PricingCardProps> = ({
tier,
highlighted = false,
billingPeriod,
}) => {
const { t } = useTranslation();
const tierData = {
free: {
price: 0,
annualPrice: 0,
},
professional: {
price: 29,
annualPrice: 290,
},
business: {
price: 79,
annualPrice: 790,
},
enterprise: {
price: 'custom',
annualPrice: 'custom',
},
};
const data = tierData[tier];
const price = billingPeriod === 'annual' ? data.annualPrice : data.price;
const isCustom = price === 'custom';
// Get features array from i18n
const features = t(`marketing.pricing.tiers.${tier}.features`, { returnObjects: true }) as string[];
const transactionFee = t(`marketing.pricing.tiers.${tier}.transactionFee`);
const trialInfo = t(`marketing.pricing.tiers.${tier}.trial`);
if (highlighted) {
return (
<div className="relative flex flex-col p-8 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20">
{/* Most Popular Badge */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1.5 bg-brand-500 text-white text-sm font-semibold rounded-full whitespace-nowrap">
{t('marketing.pricing.mostPopular')}
</div>
{/* Header */}
<div className="mb-6">
<h3 className="text-xl font-bold text-white mb-2">
{t(`marketing.pricing.tiers.${tier}.name`)}
</h3>
<p className="text-brand-100">
{t(`marketing.pricing.tiers.${tier}.description`)}
</p>
</div>
{/* Price */}
<div className="mb-6">
{isCustom ? (
<span className="text-4xl font-bold text-white">
{t('marketing.pricing.tiers.enterprise.price')}
</span>
) : (
<>
<span className="text-5xl font-bold text-white">${price}</span>
<span className="text-brand-200 ml-2">
{billingPeriod === 'annual' ? '/year' : t('marketing.pricing.perMonth')}
</span>
</>
)}
{trialInfo && (
<div className="mt-2 text-sm text-brand-100">
{trialInfo}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-3 mb-8">
{features.map((feature, index) => (
<li key={index} className="flex items-start gap-3">
<Check className="h-5 w-5 text-brand-200 flex-shrink-0 mt-0.5" />
<span className="text-white">{feature}</span>
</li>
))}
<li className="flex items-start gap-3 pt-2 border-t border-brand-500">
<span className="text-brand-200 text-sm">{transactionFee}</span>
</li>
</ul>
{/* CTA */}
{isCustom ? (
<Link
to="/contact"
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
>
{t('marketing.pricing.contactSales')}
</Link>
) : (
<Link
to="/signup"
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
>
{t('marketing.pricing.getStarted')}
</Link>
)}
</div>
);
}
return (
<div className="relative flex flex-col p-8 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t(`marketing.pricing.tiers.${tier}.name`)}
</h3>
<p className="text-gray-500 dark:text-gray-400">
{t(`marketing.pricing.tiers.${tier}.description`)}
</p>
</div>
{/* Price */}
<div className="mb-6">
{isCustom ? (
<span className="text-4xl font-bold text-gray-900 dark:text-white">
{t('marketing.pricing.tiers.enterprise.price')}
</span>
) : (
<>
<span className="text-5xl font-bold text-gray-900 dark:text-white">${price}</span>
<span className="text-gray-500 dark:text-gray-400 ml-2">
{billingPeriod === 'annual' ? '/year' : t('marketing.pricing.perMonth')}
</span>
</>
)}
{trialInfo && (
<div className="mt-2 text-sm text-brand-600 dark:text-brand-400">
{trialInfo}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-3 mb-8">
{features.map((feature, index) => (
<li key={index} className="flex items-start gap-3">
<Check className="h-5 w-5 text-brand-600 dark:text-brand-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-700 dark:text-gray-300">{feature}</span>
</li>
))}
<li className="flex items-start gap-3 pt-2 border-t border-gray-100 dark:border-gray-700">
<span className="text-gray-500 dark:text-gray-400 text-sm">{transactionFee}</span>
</li>
</ul>
{/* CTA */}
{isCustom ? (
<Link
to="/contact"
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-xl hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
>
{t('marketing.pricing.contactSales')}
</Link>
) : (
<Link
to="/signup"
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-xl hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
>
{t('marketing.pricing.getStarted')}
</Link>
)}
</div>
);
};
export default PricingCard;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Calendar, Building2, Globe, Clock } from 'lucide-react';
const StatsSection: React.FC = () => {
const { t } = useTranslation();
const stats = [
{
icon: Calendar,
value: '1M+',
label: t('marketing.stats.appointments'),
color: 'brand',
},
{
icon: Building2,
value: '5,000+',
label: t('marketing.stats.businesses'),
color: 'green',
},
{
icon: Globe,
value: '50+',
label: t('marketing.stats.countries'),
color: 'purple',
},
{
icon: Clock,
value: '99.9%',
label: t('marketing.stats.uptime'),
color: 'orange',
},
];
const colorClasses: Record<string, string> = {
brand: 'text-brand-600 dark:text-brand-400',
green: 'text-green-600 dark:text-green-400',
purple: 'text-purple-600 dark:text-purple-400',
orange: 'text-orange-600 dark:text-orange-400',
};
return (
<section className="py-20 bg-white dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<div className="inline-flex p-3 rounded-xl bg-gray-100 dark:bg-gray-800 mb-4">
<stat.icon className={`h-6 w-6 ${colorClasses[stat.color]}`} />
</div>
<div className={`text-4xl lg:text-5xl font-bold mb-2 ${colorClasses[stat.color]}`}>
{stat.value}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{stat.label}
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default StatsSection;

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Star } from 'lucide-react';
interface TestimonialCardProps {
quote: string;
author: string;
role: string;
company: string;
avatarUrl?: string;
rating?: number;
}
const TestimonialCard: React.FC<TestimonialCardProps> = ({
quote,
author,
role,
company,
avatarUrl,
rating = 5,
}) => {
return (
<div className="flex flex-col p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow">
{/* Stars */}
<div className="flex gap-1 mb-4">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`h-5 w-5 ${
i < rating
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-300 dark:text-gray-600'
}`}
/>
))}
</div>
{/* Quote */}
<blockquote className="flex-1 text-gray-700 dark:text-gray-300 mb-6 leading-relaxed">
"{quote}"
</blockquote>
{/* Author */}
<div className="flex items-center gap-3">
{avatarUrl ? (
<img
src={avatarUrl}
alt={author}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<span className="text-lg font-semibold text-brand-600 dark:text-brand-400">
{author.charAt(0)}
</span>
</div>
)}
<div>
<div className="font-semibold text-gray-900 dark:text-white">{author}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{role} at {company}
</div>
</div>
</div>
</div>
);
};
export default TestimonialCard;

View File

@@ -0,0 +1,463 @@
import React, { useState } from 'react';
import { X, Shield, Copy, Check, Download, AlertTriangle, Smartphone } from 'lucide-react';
import { useSetupTOTP, useVerifyTOTP, useDisableTOTP, useRecoveryCodes, useRegenerateRecoveryCodes } from '../../hooks/useProfile';
interface TwoFactorSetupProps {
isEnabled: boolean;
phoneVerified?: boolean;
hasPhone?: boolean;
onClose: () => void;
onSuccess: () => void;
onVerifyPhone?: () => void;
}
type SetupStep = 'intro' | 'qrcode' | 'verify' | 'recovery' | 'complete' | 'disable' | 'view-recovery';
const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({ isEnabled, phoneVerified = false, hasPhone = false, onClose, onSuccess, onVerifyPhone }) => {
const [step, setStep] = useState<SetupStep>(isEnabled ? 'disable' : 'intro');
const [verificationCode, setVerificationCode] = useState('');
const [disableCode, setDisableCode] = useState('');
const [error, setError] = useState('');
const [copiedSecret, setCopiedSecret] = useState(false);
const [copiedCodes, setCopiedCodes] = useState(false);
const setupTOTP = useSetupTOTP();
const verifyTOTP = useVerifyTOTP();
const disableTOTP = useDisableTOTP();
const recoveryCodes = useRecoveryCodes();
const regenerateCodes = useRegenerateRecoveryCodes();
const handleStartSetup = async () => {
setError('');
try {
await setupTOTP.mutateAsync();
setStep('qrcode');
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to start 2FA setup');
}
};
const handleVerify = async () => {
if (verificationCode.length !== 6) {
setError('Please enter a 6-digit code');
return;
}
setError('');
try {
const result = await verifyTOTP.mutateAsync(verificationCode);
// Store recovery codes from response
setStep('recovery');
} catch (err: any) {
setError(err.response?.data?.detail || 'Invalid verification code');
}
};
const handleDisable = async () => {
if (disableCode.length !== 6) {
setError('Please enter a 6-digit code');
return;
}
setError('');
try {
await disableTOTP.mutateAsync(disableCode);
onSuccess();
onClose();
} catch (err: any) {
setError(err.response?.data?.detail || 'Invalid code');
}
};
const handleViewRecoveryCodes = async () => {
setError('');
try {
await recoveryCodes.refetch();
setStep('view-recovery');
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load recovery codes');
}
};
const handleRegenerateCodes = async () => {
setError('');
try {
await regenerateCodes.mutateAsync();
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to regenerate codes');
}
};
const copyToClipboard = (text: string, type: 'secret' | 'codes') => {
navigator.clipboard.writeText(text);
if (type === 'secret') {
setCopiedSecret(true);
setTimeout(() => setCopiedSecret(false), 2000);
} else {
setCopiedCodes(true);
setTimeout(() => setCopiedCodes(false), 2000);
}
};
const downloadRecoveryCodes = (codes: string[]) => {
const content = `SmoothSchedule Recovery Codes\n${'='.repeat(30)}\n\nKeep these codes safe. Each code can only be used once.\n\n${codes.join('\n')}\n\nGenerated: ${new Date().toISOString()}`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'smoothschedule-recovery-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
const handleComplete = () => {
onSuccess();
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
<Shield size={20} className="text-brand-600 dark:text-brand-400" />
</div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEnabled ? 'Manage Two-Factor Authentication' : 'Set Up Two-Factor Authentication'}
</h2>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6">
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-2 text-red-700 dark:text-red-400 text-sm">
<AlertTriangle size={16} />
{error}
</div>
)}
{/* Intro Step */}
{step === 'intro' && (
<div className="space-y-4">
<div className="text-center py-4">
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Smartphone size={32} className="text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Secure Your Account
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Two-factor authentication adds an extra layer of security. You'll need an authenticator app like Google Authenticator or Authy.
</p>
</div>
{/* SMS Backup Info */}
<div className={`p-4 rounded-lg border ${phoneVerified ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' : 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'}`}>
<div className="flex items-start gap-3">
{phoneVerified ? (
<Check size={18} className="text-green-600 dark:text-green-400 mt-0.5" />
) : (
<AlertTriangle size={18} className="text-amber-600 dark:text-amber-400 mt-0.5" />
)}
<div className="flex-1">
<p className={`text-sm font-medium ${phoneVerified ? 'text-green-700 dark:text-green-300' : 'text-amber-700 dark:text-amber-300'}`}>
SMS Backup {phoneVerified ? 'Available' : 'Not Available'}
</p>
<p className={`text-xs mt-1 ${phoneVerified ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
{phoneVerified
? 'Your verified phone can be used as a backup method.'
: hasPhone
? 'Your phone number is not verified. Verify it to enable SMS backup as a fallback when you can\'t access your authenticator app.'
: 'Add and verify a phone number in your profile settings to receive text message codes as a backup when you can\'t access your authenticator app.'}
</p>
{!phoneVerified && hasPhone && onVerifyPhone && (
<button
onClick={() => {
onClose();
onVerifyPhone();
}}
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 underline hover:no-underline"
>
Verify your phone number now
</button>
)}
{!phoneVerified && !hasPhone && (
<button
onClick={onClose}
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 underline hover:no-underline"
>
Go to profile settings to add a phone number
</button>
)}
</div>
</div>
</div>
<button
onClick={handleStartSetup}
disabled={setupTOTP.isPending}
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50 font-medium"
>
{setupTOTP.isPending ? 'Setting up...' : 'Get Started'}
</button>
</div>
)}
{/* QR Code Step */}
{step === 'qrcode' && setupTOTP.data && (
<div className="space-y-4">
<div className="text-center">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Scan this QR code with your authenticator app
</p>
<div className="bg-white p-4 rounded-lg inline-block mb-4">
<img
src={`data:image/png;base64,${setupTOTP.data.qr_code}`}
alt="2FA QR Code"
className="w-48 h-48"
/>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Can't scan? Enter this code manually:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-600 text-sm font-mono text-gray-900 dark:text-white break-all">
{setupTOTP.data.secret}
</code>
<button
onClick={() => copyToClipboard(setupTOTP.data!.secret, 'secret')}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
title="Copy to clipboard"
>
{copiedSecret ? <Check size={18} className="text-green-500" /> : <Copy size={18} />}
</button>
</div>
</div>
<button
onClick={() => setStep('verify')}
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Continue
</button>
</div>
)}
{/* Verify Step */}
{step === 'verify' && (
<div className="space-y-4">
<div className="text-center mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Enter the 6-digit code from your authenticator app
</p>
</div>
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="w-full text-center text-2xl tracking-widest py-4 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent font-mono"
autoFocus
/>
<div className="flex gap-3">
<button
onClick={() => setStep('qrcode')}
className="flex-1 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium"
>
Back
</button>
<button
onClick={handleVerify}
disabled={verifyTOTP.isPending || verificationCode.length !== 6}
className="flex-1 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50 font-medium"
>
{verifyTOTP.isPending ? 'Verifying...' : 'Verify'}
</button>
</div>
</div>
)}
{/* Recovery Codes Step */}
{step === 'recovery' && verifyTOTP.data?.recovery_codes && (
<div className="space-y-4">
<div className="text-center mb-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-3">
<Check size={24} className="text-green-600 dark:text-green-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
2FA Enabled Successfully!
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Save these recovery codes in a safe place
</p>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex items-start gap-2 mb-3">
<AlertTriangle size={16} className="text-amber-600 dark:text-amber-400 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-300">
Each code can only be used once. Store them securely - you won't see them again!
</p>
</div>
<div className="grid grid-cols-2 gap-2 bg-white dark:bg-gray-800 rounded-lg p-3">
{verifyTOTP.data.recovery_codes.map((code: string, index: number) => (
<code key={index} className="text-sm font-mono text-gray-900 dark:text-white">
{code}
</code>
))}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => copyToClipboard(verifyTOTP.data!.recovery_codes.join('\n'), 'codes')}
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
{copiedCodes ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
{copiedCodes ? 'Copied!' : 'Copy'}
</button>
<button
onClick={() => downloadRecoveryCodes(verifyTOTP.data!.recovery_codes)}
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Download size={16} />
Download
</button>
</div>
<button
onClick={handleComplete}
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Done
</button>
</div>
)}
{/* Complete Step (fallback) */}
{step === 'complete' && (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Check size={32} className="text-green-600 dark:text-green-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Two-Factor Authentication Enabled
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6">
Your account is now more secure
</p>
<button
onClick={handleComplete}
className="px-6 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Close
</button>
</div>
)}
{/* Disable Step */}
{step === 'disable' && (
<div className="space-y-4">
<div className="space-y-3">
<button
onClick={handleViewRecoveryCodes}
disabled={recoveryCodes.isFetching}
className="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<span className="font-medium text-gray-900 dark:text-white">View Recovery Codes</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{recoveryCodes.isFetching ? 'Loading...' : '→'}
</span>
</button>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
To disable 2FA, enter a code from your authenticator app:
</p>
<input
type="text"
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="w-full text-center text-xl tracking-widest py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent font-mono mb-3"
/>
<button
onClick={handleDisable}
disabled={disableTOTP.isPending || disableCode.length !== 6}
className="w-full py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 font-medium"
>
{disableTOTP.isPending ? 'Disabling...' : 'Disable Two-Factor Authentication'}
</button>
</div>
</div>
)}
{/* View Recovery Codes Step */}
{step === 'view-recovery' && recoveryCodes.data && (
<div className="space-y-4">
<button
onClick={() => setStep('disable')}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
Back
</button>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Your recovery codes (each can only be used once):
</p>
<div className="grid grid-cols-2 gap-2 bg-white dark:bg-gray-800 rounded-lg p-3">
{recoveryCodes.data.map((code: string, index: number) => (
<code key={index} className="text-sm font-mono text-gray-900 dark:text-white">
{code}
</code>
))}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => copyToClipboard(recoveryCodes.data!.join('\n'), 'codes')}
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
{copiedCodes ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
{copiedCodes ? 'Copied!' : 'Copy'}
</button>
<button
onClick={() => downloadRecoveryCodes(recoveryCodes.data!)}
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Download size={16} />
Download
</button>
</div>
<button
onClick={handleRegenerateCodes}
disabled={regenerateCodes.isPending}
className="w-full py-2 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors text-sm"
>
{regenerateCodes.isPending ? 'Regenerating...' : 'Regenerate Recovery Codes'}
</button>
</div>
)}
</div>
</div>
</div>
);
};
export default TwoFactorSetup;

View File

@@ -0,0 +1,332 @@
/**
* WebSocket hook for real-time appointment updates.
* Connects to the backend WebSocket and updates React Query cache.
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCookie } from '../utils/cookies';
import { getSubdomain } from '../api/config';
import { Appointment } from '../types';
interface WebSocketMessage {
type: 'connection_established' | 'appointment_created' | 'appointment_updated' | 'appointment_deleted' | 'pong';
appointment?: {
id: string;
business_id: string;
service_id: string;
resource_id: string | null;
customer_id: string;
customer_name: string;
start_time: string;
end_time: string;
duration_minutes: number;
status: string;
notes: string;
};
appointment_id?: string;
message?: string;
}
interface UseAppointmentWebSocketOptions {
enabled?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Event) => void;
}
/**
* Transform backend appointment format to frontend format
*/
function transformAppointment(data: WebSocketMessage['appointment']): Appointment | null {
if (!data) return null;
return {
id: data.id,
resourceId: data.resource_id,
customerId: data.customer_id,
customerName: data.customer_name,
serviceId: data.service_id,
startTime: new Date(data.start_time),
durationMinutes: data.duration_minutes,
status: data.status as Appointment['status'],
notes: data.notes,
};
}
/**
* Hook for real-time appointment updates via WebSocket.
* Handles React StrictMode's double-effect invocation gracefully.
*/
export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions = {}) {
const { enabled = true, onConnected, onDisconnected, onError } = options;
const queryClient = useQueryClient();
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isCleaningUpRef = useRef(false);
const maxReconnectAttempts = 5;
const [isConnected, setIsConnected] = useState(false);
// Store callbacks in refs to avoid effect re-runs
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
useEffect(() => {
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
}, [onConnected, onDisconnected, onError]);
// Get WebSocket URL - not a callback to avoid recreating
const getWebSocketUrl = () => {
const token = getCookie('access_token');
const subdomain = getSubdomain();
if (!token || !subdomain) {
return null;
}
// Determine WebSocket host - use api subdomain for WebSocket
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
return `${wsProtocol}//${wsHost}/ws/appointments/?token=${token}&subdomain=${subdomain}`;
};
const updateQueryCache = useCallback((message: WebSocketMessage) => {
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
appointmentQueries.forEach((query) => {
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
switch (message.type) {
case 'appointment_created': {
const newAppointment = transformAppointment(message.appointment);
if (!newAppointment) return old;
// Check if appointment already exists (avoid duplicates)
if (old.some(apt => apt.id === newAppointment.id)) {
return old;
}
return [...old, newAppointment];
}
case 'appointment_updated': {
const updatedAppointment = transformAppointment(message.appointment);
if (!updatedAppointment) return old;
return old.map(apt =>
apt.id === updatedAppointment.id ? updatedAppointment : apt
);
}
case 'appointment_deleted': {
if (!message.appointment_id) return old;
return old.filter(apt => apt.id !== message.appointment_id);
}
default:
return old;
}
});
});
}, [queryClient]);
// Main effect to manage WebSocket connection
// Only depends on `enabled` - other values are read from refs or called as functions
useEffect(() => {
if (!enabled) {
return;
}
// Reset cleanup flag at start of effect
isCleaningUpRef.current = false;
// Track the current effect's abort controller to handle StrictMode
let effectAborted = false;
const connect = () => {
// Don't connect if effect was aborted or we're cleaning up
if (effectAborted || isCleaningUpRef.current) {
return;
}
const url = getWebSocketUrl();
if (!url) {
console.log('WebSocket: Missing token or subdomain, skipping connection');
return;
}
// Close existing connection if any
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
wsRef.current.close();
}
console.log('WebSocket: Connecting to', url.replace(/token=[^&]+/, 'token=***'));
const ws = new WebSocket(url);
ws.onopen = () => {
// Don't process if effect was aborted or cleaning up
if (effectAborted || isCleaningUpRef.current) {
ws.close();
return;
}
console.log('WebSocket: Connected');
reconnectAttemptsRef.current = 0;
setIsConnected(true);
onConnectedRef.current?.();
// Start ping interval to keep connection alive
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN && !effectAborted) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Ping every 30 seconds
};
ws.onmessage = (event) => {
// Ignore messages if effect was aborted
if (effectAborted) return;
try {
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'connection_established':
console.log('WebSocket: Connection confirmed -', message.message);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'appointment_created':
case 'appointment_updated':
case 'appointment_deleted':
console.log('WebSocket: Received', message.type);
updateQueryCache(message);
break;
default:
console.log('WebSocket: Unknown message type', message);
}
} catch (err) {
console.error('WebSocket: Failed to parse message', err);
}
};
ws.onerror = (error) => {
// Only log error if not aborted (StrictMode cleanup causes expected errors)
if (!effectAborted) {
console.error('WebSocket: Error', error);
onErrorRef.current?.(error);
}
};
ws.onclose = (event) => {
// Don't log or handle if effect was aborted (expected during StrictMode)
if (effectAborted) {
return;
}
console.log('WebSocket: Disconnected', event.code, event.reason);
setIsConnected(false);
onDisconnectedRef.current?.();
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Only attempt reconnection if not cleaning up
if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
wsRef.current = ws;
};
connect();
// Cleanup function
return () => {
effectAborted = true;
isCleaningUpRef.current = true;
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
};
}, [enabled]); // Only re-run when enabled changes
const reconnect = useCallback(() => {
isCleaningUpRef.current = false;
reconnectAttemptsRef.current = 0;
// Close existing connection
if (wsRef.current) {
wsRef.current.close();
}
// Connection will be re-established by the effect when we force re-render
// For now, we'll rely on the onclose handler to trigger reconnection
}, []);
const disconnect = useCallback(() => {
isCleaningUpRef.current = true;
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
}, []);
return {
isConnected,
reconnect,
disconnect,
};
}

View File

@@ -0,0 +1,279 @@
/**
* Appointment Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Appointment, AppointmentStatus } from '../types';
import { format } from 'date-fns';
interface AppointmentFilters {
resource?: string;
status?: AppointmentStatus;
startDate?: Date;
endDate?: Date;
}
/**
* Hook to fetch appointments with optional filters
*/
export const useAppointments = (filters?: AppointmentFilters) => {
return useQuery<Appointment[]>({
queryKey: ['appointments', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.resource) params.append('resource', filters.resource);
if (filters?.status) params.append('status', filters.status);
// Send full ISO datetime strings to avoid timezone issues
// The backend will compare datetime fields properly
if (filters?.startDate) {
// Start of day in local timezone, converted to ISO
const startOfDay = new Date(filters.startDate);
startOfDay.setHours(0, 0, 0, 0);
params.append('start_date', startOfDay.toISOString());
}
if (filters?.endDate) {
// End of day (or start of next day) in local timezone, converted to ISO
const endOfDay = new Date(filters.endDate);
endOfDay.setHours(0, 0, 0, 0);
params.append('end_date', endOfDay.toISOString());
}
const { data } = await apiClient.get(`/api/appointments/?${params}`);
// Transform backend format to frontend format
return data.map((a: any) => ({
id: String(a.id),
resourceId: a.resource_id ? String(a.resource_id) : null,
customerId: String(a.customer_id || a.customer),
customerName: a.customer_name || '',
serviceId: String(a.service_id || a.service),
startTime: new Date(a.start_time),
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
status: a.status as AppointmentStatus,
notes: a.notes || '',
}));
},
});
};
/**
* Calculate duration in minutes from start and end times
*/
function calculateDuration(startTime: string, endTime: string): number {
const start = new Date(startTime);
const end = new Date(endTime);
return Math.round((end.getTime() - start.getTime()) / (1000 * 60));
}
/**
* Hook to get a single appointment
*/
export const useAppointment = (id: string) => {
return useQuery<Appointment>({
queryKey: ['appointments', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/appointments/${id}/`);
return {
id: String(data.id),
resourceId: data.resource_id ? String(data.resource_id) : null,
customerId: String(data.customer_id || data.customer),
customerName: data.customer_name || '',
serviceId: String(data.service_id || data.service),
startTime: new Date(data.start_time),
durationMinutes: data.duration_minutes || calculateDuration(data.start_time, data.end_time),
status: data.status as AppointmentStatus,
notes: data.notes || '',
};
},
enabled: !!id,
});
};
/**
* Hook to create an appointment
*/
export const useCreateAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (appointmentData: Omit<Appointment, 'id'>) => {
const startTime = appointmentData.startTime;
const endTime = new Date(startTime.getTime() + appointmentData.durationMinutes * 60000);
const backendData: Record<string, unknown> = {
service: parseInt(appointmentData.serviceId),
resource: appointmentData.resourceId ? parseInt(appointmentData.resourceId) : null,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
notes: appointmentData.notes || '',
};
// Include customer if provided (for business-created appointments)
if (appointmentData.customerId) {
backendData.customer = parseInt(appointmentData.customerId);
}
const { data } = await apiClient.post('/api/appointments/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to update an appointment with optimistic updates for instant UI feedback
*/
export const useUpdateAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Appointment> }) => {
const backendData: any = {};
if (updates.serviceId) backendData.service = parseInt(updates.serviceId);
if (updates.resourceId !== undefined) {
backendData.resource = updates.resourceId ? parseInt(updates.resourceId) : null;
}
if (updates.startTime) {
backendData.start_time = updates.startTime.toISOString();
// Calculate end_time if we have duration, otherwise backend will keep existing duration
if (updates.durationMinutes) {
const endTime = new Date(updates.startTime.getTime() + updates.durationMinutes * 60000);
backendData.end_time = endTime.toISOString();
}
} else if (updates.durationMinutes) {
// If only duration changed, we need to get the current appointment to calculate new end time
// For now, just send duration and let backend handle it
// This case is handled by the resize logic which sends both startTime and durationMinutes
}
if (updates.status) backendData.status = updates.status;
if (updates.notes !== undefined) backendData.notes = updates.notes;
const { data } = await apiClient.patch(`/api/appointments/${id}/`, backendData);
return data;
},
// Optimistic update: update UI immediately before API call completes
onMutate: async ({ id, updates }) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['appointments'] });
// Get all appointment queries and update them optimistically
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
const previousData: { queryKey: unknown[]; data: Appointment[] | undefined }[] = [];
appointmentQueries.forEach((query) => {
const data = queryClient.getQueryData<Appointment[]>(query.queryKey);
if (data) {
previousData.push({ queryKey: query.queryKey, data });
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
return old.map((apt) =>
apt.id === id ? { ...apt, ...updates } : apt
);
});
}
});
// Return context with the previous values for rollback
return { previousData };
},
// If mutation fails, rollback to the previous values
onError: (error, _variables, context) => {
console.error('Failed to update appointment', error);
if (context?.previousData) {
context.previousData.forEach(({ queryKey, data }) => {
queryClient.setQueryData(queryKey, data);
});
}
},
// Always refetch after error or success to ensure server state
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to delete an appointment with optimistic updates
*/
export const useDeleteAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/appointments/${id}/`);
return id;
},
// Optimistic update: remove from UI immediately
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['appointments'] });
// Get all appointment queries and update them optimistically
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
const previousData: { queryKey: unknown[]; data: Appointment[] | undefined }[] = [];
appointmentQueries.forEach((query) => {
const data = queryClient.getQueryData<Appointment[]>(query.queryKey);
if (data) {
previousData.push({ queryKey: query.queryKey, data });
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
return old.filter((apt) => apt.id !== id);
});
}
});
return { previousData };
},
onError: (error, _id, context) => {
console.error('Failed to delete appointment', error);
if (context?.previousData) {
context.previousData.forEach(({ queryKey, data }) => {
queryClient.setQueryData(queryKey, data);
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to reschedule an appointment (update start time and resource)
*/
export const useRescheduleAppointment = () => {
const updateMutation = useUpdateAppointment();
return useMutation({
mutationFn: async ({
id,
newStartTime,
newResourceId,
}: {
id: string;
newStartTime: Date;
newResourceId?: string | null;
}) => {
const appointment = await apiClient.get(`/api/appointments/${id}/`);
const durationMinutes = appointment.data.duration_minutes;
return updateMutation.mutateAsync({
id,
updates: {
startTime: newStartTime,
durationMinutes,
resourceId: newResourceId !== undefined ? newResourceId : undefined,
},
});
},
});
};

View File

@@ -0,0 +1,231 @@
/**
* Authentication Hooks
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
login,
logout,
getCurrentUser,
masquerade,
stopMasquerade,
LoginCredentials,
User,
MasqueradeStackEntry
} from '../api/auth';
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
/**
* Hook to get current user
*/
export const useCurrentUser = () => {
return useQuery<User | null, Error>({
queryKey: ['currentUser'],
queryFn: async () => {
// Check if token exists before making request (from cookie)
const token = getCookie('access_token');
if (!token) {
return null; // No token, return null instead of making request
}
try {
return await getCurrentUser();
} catch (error) {
// If getCurrentUser fails (e.g., 401), return null
// The API client interceptor will handle token refresh
console.error('Failed to get current user:', error);
return null;
}
},
retry: 1, // Retry once in case of token refresh
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnMount: true, // Always refetch when component mounts
refetchOnWindowFocus: false,
});
};
/**
* Hook to login
*/
export const useLogin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: login,
onSuccess: (data) => {
// Store tokens in cookies (domain=.lvh.me for cross-subdomain access)
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
// Set user in cache
queryClient.setQueryData(['currentUser'], data.user);
},
});
};
/**
* Hook to logout
*/
export const useLogout = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: logout,
onSuccess: () => {
// Clear tokens (from cookies)
deleteCookie('access_token');
deleteCookie('refresh_token');
// Clear user cache
queryClient.removeQueries({ queryKey: ['currentUser'] });
queryClient.clear();
// Redirect to login page
window.location.href = '/login';
},
});
};
/**
* Check if user is authenticated
*/
export const useIsAuthenticated = (): boolean => {
const { data: user, isLoading } = useCurrentUser();
return !isLoading && !!user;
};
/**
* Hook to masquerade as another user
*/
export const useMasquerade = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (username: string) => {
// Get current masquerading stack from localStorage
const stackJson = localStorage.getItem('masquerade_stack');
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
// Call masquerade API with current stack
return masquerade(username, currentStack);
},
onSuccess: async (data) => {
// Store the updated masquerading stack
if (data.masquerade_stack) {
localStorage.setItem('masquerade_stack', JSON.stringify(data.masquerade_stack));
}
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
let targetSubdomain: string | null = null;
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform';
} else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.lvh.me`;
if (needsRedirect) {
// CRITICAL: Clear the session cookie BEFORE redirect
// Call logout API to clear HttpOnly sessionid cookie
try {
await fetch('http://api.lvh.me:8000/api/auth/logout/', {
method: 'POST',
credentials: 'include',
});
} catch (e) {
// Continue anyway
}
const portStr = currentPort ? `:${currentPort}` : '';
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
const redirectUrl = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`;
window.location.href = redirectUrl;
return;
}
// If no redirect needed (same subdomain), we can just set cookies and reload
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
queryClient.setQueryData(['currentUser'], data.user);
window.location.reload();
},
});
};
/**
* Hook to stop masquerading and return to previous user
*/
export const useStopMasquerade = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
// Get current masquerading stack from localStorage
const stackJson = localStorage.getItem('masquerade_stack');
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
if (currentStack.length === 0) {
throw new Error('No masquerading session to stop');
}
// Call stop_masquerade API with current stack
return stopMasquerade(currentStack);
},
onSuccess: async (data) => {
// Update the masquerading stack
if (data.masquerade_stack && data.masquerade_stack.length > 0) {
localStorage.setItem('masquerade_stack', JSON.stringify(data.masquerade_stack));
} else {
// Clear the stack if empty
localStorage.removeItem('masquerade_stack');
}
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
let targetSubdomain: string | null = null;
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform';
} else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.lvh.me`;
if (needsRedirect) {
// CRITICAL: Clear the session cookie BEFORE redirect
try {
await fetch('http://api.lvh.me:8000/api/auth/logout/', {
method: 'POST',
credentials: 'include',
});
} catch (e) {
// Continue anyway
}
const portStr = currentPort ? `:${currentPort}` : '';
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
const redirectUrl = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`;
window.location.href = redirectUrl;
return;
}
// If no redirect needed (same subdomain), we can just set cookies and reload
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
queryClient.setQueryData(['currentUser'], data.user);
window.location.reload();
},
});
};

View File

@@ -0,0 +1,144 @@
/**
* Business Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Business } from '../types';
import { getCookie } from '../utils/cookies';
/**
* Hook to get current business
*/
export const useCurrentBusiness = () => {
// Check token outside the query to use as dependency
const token = getCookie('access_token');
return useQuery<Business | null>({
queryKey: ['currentBusiness', !!token], // Include token presence in query key to refetch when token changes
queryFn: async () => {
// Check if token exists before making request (from cookie)
const currentToken = getCookie('access_token');
if (!currentToken) {
return null; // No token, return null instead of making request
}
const { data } = await apiClient.get('/api/business/current/');
// Transform backend format to frontend format
return {
id: String(data.id),
name: data.name,
subdomain: data.subdomain,
primaryColor: data.primary_color,
secondaryColor: data.secondary_color,
logoUrl: data.logo_url,
whitelabelEnabled: data.whitelabel_enabled,
plan: data.tier, // Map tier to plan
status: data.status,
joinedAt: data.created_at ? new Date(data.created_at) : undefined,
resourcesCanReschedule: data.resources_can_reschedule,
requirePaymentMethodToBook: data.require_payment_method_to_book,
cancellationWindowHours: data.cancellation_window_hours,
lateCancellationFeePercent: data.late_cancellation_fee_percent,
initialSetupComplete: data.initial_setup_complete,
websitePages: data.website_pages || {},
customerDashboardContent: data.customer_dashboard_content || [],
};
},
});
};
/**
* Hook to update business settings
*/
export const useUpdateBusiness = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updates: Partial<Business>) => {
const backendData: any = {};
// Map frontend fields to backend fields
if (updates.name) backendData.name = updates.name;
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl;
if (updates.whitelabelEnabled !== undefined) {
backendData.whitelabel_enabled = updates.whitelabelEnabled;
}
if (updates.resourcesCanReschedule !== undefined) {
backendData.resources_can_reschedule = updates.resourcesCanReschedule;
}
if (updates.requirePaymentMethodToBook !== undefined) {
backendData.require_payment_method_to_book = updates.requirePaymentMethodToBook;
}
if (updates.cancellationWindowHours !== undefined) {
backendData.cancellation_window_hours = updates.cancellationWindowHours;
}
if (updates.lateCancellationFeePercent !== undefined) {
backendData.late_cancellation_fee_percent = updates.lateCancellationFeePercent;
}
if (updates.initialSetupComplete !== undefined) {
backendData.initial_setup_complete = updates.initialSetupComplete;
}
if (updates.websitePages !== undefined) {
backendData.website_pages = updates.websitePages;
}
if (updates.customerDashboardContent !== undefined) {
backendData.customer_dashboard_content = updates.customerDashboardContent;
}
const { data } = await apiClient.patch('/api/business/current/update/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentBusiness'] });
},
});
};
/**
* Hook to get all resources for the current business
*/
export const useResources = () => {
return useQuery({
queryKey: ['resources'],
queryFn: async () => {
const { data } = await apiClient.get('/api/resources/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to create a new resource
*/
export const useCreateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (resourceData: { name: string; type: string; user_id?: string }) => {
const { data } = await apiClient.post('/api/resources/', resourceData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to get all users for the current business
*/
export const useBusinessUsers = () => {
return useQuery({
queryKey: ['businessUsers'],
queryFn: async () => {
const { data } = await apiClient.get('/api/business/users/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};

View File

@@ -0,0 +1,34 @@
/**
* Business OAuth Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getBusinessOAuthSettings, updateBusinessOAuthSettings } from '../api/business';
import { BusinessOAuthSettings, BusinessOAuthSettingsResponse } from '../types';
/**
* Hook to get business OAuth settings and available providers
*/
export const useBusinessOAuthSettings = () => {
return useQuery<BusinessOAuthSettingsResponse>({
queryKey: ['businessOAuthSettings'],
queryFn: getBusinessOAuthSettings,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update business OAuth settings
*/
export const useUpdateBusinessOAuthSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: Partial<BusinessOAuthSettings>) =>
updateBusinessOAuthSettings(settings),
onSuccess: (data) => {
// Update the cached data
queryClient.setQueryData(['businessOAuthSettings'], data);
},
});
};

View File

@@ -0,0 +1,32 @@
/**
* React Query hooks for managing business OAuth credentials
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getBusinessOAuthCredentials, updateBusinessOAuthCredentials } from '../api/business';
import { BusinessOAuthCredentials } from '../types';
/**
* Fetch business OAuth credentials
*/
export const useBusinessOAuthCredentials = () => {
return useQuery({
queryKey: ['businessOAuthCredentials'],
queryFn: getBusinessOAuthCredentials,
});
};
/**
* Update business OAuth credentials
*/
export const useUpdateBusinessOAuthCredentials = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (credentials: Partial<BusinessOAuthCredentials>) =>
updateBusinessOAuthCredentials(credentials),
onSuccess: (data) => {
queryClient.setQueryData(['businessOAuthCredentials'], data);
},
});
};

View File

@@ -0,0 +1,83 @@
/**
* React Query hooks for custom domain management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getCustomDomains,
addCustomDomain,
deleteCustomDomain,
verifyCustomDomain,
setPrimaryDomain,
} from '../api/customDomains';
import { CustomDomain } from '../types';
/**
* Hook to fetch all custom domains for the current business
*/
export const useCustomDomains = () => {
return useQuery<CustomDomain[], Error>({
queryKey: ['customDomains'],
queryFn: getCustomDomains,
});
};
/**
* Hook to add a new custom domain
*/
export const useAddCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<CustomDomain, Error, string>({
mutationFn: addCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to delete a custom domain
*/
export const useDeleteCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: deleteCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to verify a custom domain
*/
export const useVerifyCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<{ verified: boolean; message: string }, Error, number>({
mutationFn: verifyCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to set a custom domain as primary
*/
export const useSetPrimaryDomain = () => {
const queryClient = useQueryClient();
return useMutation<CustomDomain, Error, number>({
mutationFn: setPrimaryDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};

View File

@@ -0,0 +1,118 @@
/**
* Customer Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Customer } from '../types';
interface CustomerFilters {
status?: 'Active' | 'Inactive' | 'Blocked';
search?: string;
}
/**
* Hook to fetch customers with optional filters
*/
export const useCustomers = (filters?: CustomerFilters) => {
return useQuery<Customer[]>({
queryKey: ['customers', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.search) params.append('search', filters.search);
const { data } = await apiClient.get(`/api/customers/?${params}`);
// Transform backend format to frontend format
return data.map((c: any) => ({
id: String(c.id),
name: c.name || c.user?.name || '',
email: c.email || c.user?.email || '',
phone: c.phone || '',
city: c.city,
state: c.state,
zip: c.zip,
totalSpend: parseFloat(c.total_spend || 0),
lastVisit: c.last_visit ? new Date(c.last_visit) : null,
status: c.status,
avatarUrl: c.avatar_url,
tags: c.tags || [],
userId: String(c.user_id || c.user),
paymentMethods: [], // Will be populated when payment feature is implemented
user_data: c.user_data, // Include user_data for masquerading
}));
},
});
};
/**
* Hook to create a customer
*/
export const useCreateCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (customerData: Partial<Customer>) => {
const backendData = {
user: customerData.userId ? parseInt(customerData.userId) : undefined,
phone: customerData.phone,
city: customerData.city,
state: customerData.state,
zip: customerData.zip,
status: customerData.status,
avatar_url: customerData.avatarUrl,
tags: customerData.tags,
};
const { data } = await apiClient.post('/api/customers/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};
/**
* Hook to update a customer
*/
export const useUpdateCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Customer> }) => {
const backendData = {
phone: updates.phone,
city: updates.city,
state: updates.state,
zip: updates.zip,
status: updates.status,
avatar_url: updates.avatarUrl,
tags: updates.tags,
};
const { data } = await apiClient.patch(`/api/customers/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};
/**
* Hook to delete a customer
*/
export const useDeleteCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/customers/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};

View File

@@ -0,0 +1,190 @@
/**
* Domain Management Hooks
* React Query hooks for NameSilo domain integration
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as domainsApi from '../api/domains';
import type {
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
} from '../api/domains';
// Query keys
const domainKeys = {
all: ['domains'] as const,
prices: () => [...domainKeys.all, 'prices'] as const,
registrations: () => [...domainKeys.all, 'registrations'] as const,
registration: (id: number) => [...domainKeys.registrations(), id] as const,
history: () => [...domainKeys.all, 'history'] as const,
search: (query: string, tlds: string[]) => [...domainKeys.all, 'search', query, tlds] as const,
};
// ============================================
// Search & Pricing
// ============================================
/**
* Hook to search for domain availability
*/
export const useDomainSearch = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ query, tlds }: { query: string; tlds?: string[] }) =>
domainsApi.searchDomains(query, tlds),
onSuccess: () => {
// Invalidate search history since new search was added
queryClient.invalidateQueries({ queryKey: domainKeys.history() });
},
});
};
/**
* Hook to get TLD pricing
*/
export const useDomainPrices = () => {
return useQuery({
queryKey: domainKeys.prices(),
queryFn: domainsApi.getDomainPrices,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
// ============================================
// Registration
// ============================================
/**
* Hook to register a new domain
*/
export const useRegisterDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: DomainRegisterRequest) => domainsApi.registerDomain(data),
onSuccess: () => {
// Invalidate registrations list
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
// Also invalidate custom domains since we auto-configure
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to get all registered domains
*/
export const useRegisteredDomains = () => {
return useQuery({
queryKey: domainKeys.registrations(),
queryFn: domainsApi.getRegisteredDomains,
staleTime: 30 * 1000, // 30 seconds
});
};
/**
* Hook to get a single domain registration
*/
export const useDomainRegistration = (id: number) => {
return useQuery({
queryKey: domainKeys.registration(id),
queryFn: () => domainsApi.getDomainRegistration(id),
enabled: !!id,
});
};
// ============================================
// Domain Management
// ============================================
/**
* Hook to update nameservers
*/
export const useUpdateNameservers = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, nameservers }: { id: number; nameservers: string[] }) =>
domainsApi.updateNameservers(id, nameservers),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to toggle auto-renewal
*/
export const useToggleAutoRenew = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, autoRenew }: { id: number; autoRenew: boolean }) =>
domainsApi.toggleAutoRenew(id, autoRenew),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to renew a domain
*/
export const useRenewDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, years }: { id: number; years?: number }) =>
domainsApi.renewDomain(id, years),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to sync domain info from NameSilo
*/
export const useSyncDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => domainsApi.syncDomain(id),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
// ============================================
// History
// ============================================
/**
* Hook to get search history
*/
export const useSearchHistory = () => {
return useQuery({
queryKey: domainKeys.history(),
queryFn: domainsApi.getSearchHistory,
staleTime: 60 * 1000, // 1 minute
});
};
// Re-export types for convenience
export type {
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
RegistrantContact,
} from '../api/domains';

View File

@@ -0,0 +1,104 @@
/**
* OAuth Hooks
* React Query hooks for OAuth authentication flows
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
getOAuthProviders,
getOAuthConnections,
initiateOAuth,
handleOAuthCallback,
disconnectOAuth,
OAuthProvider,
OAuthConnection,
OAuthTokenResponse,
} from '../api/oauth';
import { setCookie } from '../utils/cookies';
/**
* Hook to get list of enabled OAuth providers
*/
export const useOAuthProviders = () => {
return useQuery<OAuthProvider[], Error>({
queryKey: ['oauthProviders'],
queryFn: getOAuthProviders,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to get user's connected OAuth accounts
*/
export const useOAuthConnections = () => {
return useQuery<OAuthConnection[], Error>({
queryKey: ['oauthConnections'],
queryFn: getOAuthConnections,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to initiate OAuth flow
*/
export const useInitiateOAuth = () => {
return useMutation({
mutationFn: async (provider: string) => {
const response = await initiateOAuth(provider);
return { provider, authorizationUrl: response.authorization_url };
},
onSuccess: ({ authorizationUrl }) => {
// Open OAuth authorization URL in current window
window.location.href = authorizationUrl;
},
});
};
/**
* Hook to handle OAuth callback and exchange code for tokens
*/
export const useOAuthCallback = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
provider,
code,
state,
}: {
provider: string;
code: string;
state: string;
}) => {
return handleOAuthCallback(provider, code, state);
},
onSuccess: (data: OAuthTokenResponse) => {
// Store tokens in cookies
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
// Set user in cache
queryClient.setQueryData(['currentUser'], data.user);
// Invalidate OAuth connections to refetch
queryClient.invalidateQueries({ queryKey: ['oauthConnections'] });
},
});
};
/**
* Hook to disconnect OAuth account
*/
export const useDisconnectOAuth = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: disconnectOAuth,
onSuccess: () => {
// Invalidate connections list to refetch
queryClient.invalidateQueries({ queryKey: ['oauthConnections'] });
},
});
};

View File

@@ -0,0 +1,154 @@
/**
* Payment Hooks
* React Query hooks for payment configuration management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as paymentsApi from '../api/payments';
// ============================================================================
// Query Keys
// ============================================================================
export const paymentKeys = {
all: ['payments'] as const,
config: () => [...paymentKeys.all, 'config'] as const,
apiKeys: () => [...paymentKeys.all, 'apiKeys'] as const,
connectStatus: () => [...paymentKeys.all, 'connectStatus'] as const,
};
// ============================================================================
// Unified Configuration Hook
// ============================================================================
/**
* Get unified payment configuration status.
* Returns the complete payment setup for the business.
*/
export const usePaymentConfig = () => {
return useQuery({
queryKey: paymentKeys.config(),
queryFn: () => paymentsApi.getPaymentConfig().then(res => res.data),
staleTime: 30 * 1000, // 30 seconds
});
};
// ============================================================================
// API Keys Hooks (Free Tier)
// ============================================================================
/**
* Get current API key configuration (masked).
*/
export const useApiKeys = () => {
return useQuery({
queryKey: paymentKeys.apiKeys(),
queryFn: () => paymentsApi.getApiKeys().then(res => res.data),
staleTime: 30 * 1000,
});
};
/**
* Validate API keys without saving.
*/
export const useValidateApiKeys = () => {
return useMutation({
mutationFn: ({ secretKey, publishableKey }: { secretKey: string; publishableKey: string }) =>
paymentsApi.validateApiKeys(secretKey, publishableKey).then(res => res.data),
});
};
/**
* Save API keys.
*/
export const useSaveApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ secretKey, publishableKey }: { secretKey: string; publishableKey: string }) =>
paymentsApi.saveApiKeys(secretKey, publishableKey).then(res => res.data),
onSuccess: () => {
// Invalidate payment config to refresh status
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
/**
* Re-validate stored API keys.
*/
export const useRevalidateApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => paymentsApi.revalidateApiKeys().then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
/**
* Delete stored API keys.
*/
export const useDeleteApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => paymentsApi.deleteApiKeys().then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
// ============================================================================
// Stripe Connect Hooks (Paid Tiers)
// ============================================================================
/**
* Get current Connect account status.
*/
export const useConnectStatus = () => {
return useQuery({
queryKey: paymentKeys.connectStatus(),
queryFn: () => paymentsApi.getConnectStatus().then(res => res.data),
staleTime: 30 * 1000,
// Only fetch if we might have a Connect account
enabled: true,
});
};
/**
* Initiate Connect account onboarding.
*/
export const useConnectOnboarding = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ refreshUrl, returnUrl }: { refreshUrl: string; returnUrl: string }) =>
paymentsApi.initiateConnectOnboarding(refreshUrl, returnUrl).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.connectStatus() });
},
});
};
/**
* Refresh Connect onboarding link.
*/
export const useRefreshConnectLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ refreshUrl, returnUrl }: { refreshUrl: string; returnUrl: string }) =>
paymentsApi.refreshConnectOnboardingLink(refreshUrl, returnUrl).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.connectStatus() });
},
});
};

View File

@@ -0,0 +1,41 @@
/**
* Platform Hooks
* React Query hooks for platform-level operations
*/
import { useQuery } from '@tanstack/react-query';
import { getBusinesses, getUsers, getBusinessUsers } from '../api/platform';
/**
* Hook to get all businesses (platform admin only)
*/
export const useBusinesses = () => {
return useQuery({
queryKey: ['platform', 'businesses'],
queryFn: getBusinesses,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to get all users (platform admin only)
*/
export const usePlatformUsers = () => {
return useQuery({
queryKey: ['platform', 'users'],
queryFn: getUsers,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to get users for a specific business
*/
export const useBusinessUsers = (businessId: number | null) => {
return useQuery({
queryKey: ['platform', 'business-users', businessId],
queryFn: () => getBusinessUsers(businessId!),
enabled: !!businessId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};

View File

@@ -0,0 +1,36 @@
/**
* Platform OAuth Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getPlatformOAuthSettings,
updatePlatformOAuthSettings,
PlatformOAuthSettings,
PlatformOAuthSettingsUpdate,
} from '../api/platformOAuth';
/**
* Hook to get platform OAuth settings
*/
export const usePlatformOAuthSettings = () => {
return useQuery<PlatformOAuthSettings>({
queryKey: ['platformOAuthSettings'],
queryFn: getPlatformOAuthSettings,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update platform OAuth settings
*/
export const useUpdatePlatformOAuthSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updatePlatformOAuthSettings,
onSuccess: (data) => {
queryClient.setQueryData(['platformOAuthSettings'], data);
},
});
};

View File

@@ -0,0 +1,193 @@
/**
* Platform Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface PlatformSettings {
stripe_secret_key_masked: string;
stripe_publishable_key_masked: string;
stripe_webhook_secret_masked: string;
stripe_account_id: string;
stripe_account_name: string;
stripe_keys_validated_at: string | null;
stripe_validation_error: string;
has_stripe_keys: boolean;
stripe_keys_from_env: boolean;
updated_at: string;
}
export interface StripeKeysUpdate {
stripe_secret_key?: string;
stripe_publishable_key?: string;
stripe_webhook_secret?: string;
}
export interface SubscriptionPlan {
id: number;
name: string;
description: string;
plan_type: 'base' | 'addon';
stripe_product_id: string;
stripe_price_id: string;
price_monthly: string | null;
price_yearly: string | null;
business_tier: string;
features: string[];
transaction_fee_percent: string;
transaction_fee_fixed: string;
is_active: boolean;
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface SubscriptionPlanCreate {
name: string;
description?: string;
plan_type?: 'base' | 'addon';
price_monthly?: number | null;
price_yearly?: number | null;
business_tier?: string;
features?: string[];
transaction_fee_percent?: number;
transaction_fee_fixed?: number;
is_active?: boolean;
is_public?: boolean;
create_stripe_product?: boolean;
stripe_product_id?: string;
stripe_price_id?: string;
}
/**
* Hook to get platform settings
*/
export const usePlatformSettings = () => {
return useQuery<PlatformSettings>({
queryKey: ['platformSettings'],
queryFn: async () => {
const { data } = await apiClient.get('/api/platform/settings/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update platform Stripe keys
*/
export const useUpdateStripeKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (keys: StripeKeysUpdate) => {
const { data } = await apiClient.post('/api/platform/settings/stripe/keys/', keys);
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(['platformSettings'], data);
},
});
};
/**
* Hook to validate platform Stripe keys
*/
export const useValidateStripeKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post('/api/platform/settings/stripe/validate/');
return data;
},
onSuccess: (data) => {
if (data.settings) {
queryClient.setQueryData(['platformSettings'], data.settings);
}
},
});
};
/**
* Hook to get subscription plans
*/
export const useSubscriptionPlans = () => {
return useQuery<SubscriptionPlan[]>({
queryKey: ['subscriptionPlans'],
queryFn: async () => {
const { data } = await apiClient.get('/api/platform/subscription-plans/');
return data;
},
staleTime: 5 * 60 * 1000,
});
};
/**
* Hook to create a subscription plan
*/
export const useCreateSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (plan: SubscriptionPlanCreate) => {
const { data } = await apiClient.post('/api/platform/subscription-plans/', plan);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to update a subscription plan
*/
export const useUpdateSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }: Partial<SubscriptionPlan> & { id: number }) => {
const { data } = await apiClient.patch(`/api/platform/subscription-plans/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to delete a subscription plan
*/
export const useDeleteSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const { data } = await apiClient.delete(`/api/platform/subscription-plans/${id}/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to sync plans with Stripe
*/
export const useSyncPlansWithStripe = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post('/api/platform/subscription-plans/sync_with_stripe/');
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};

View File

@@ -0,0 +1,248 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as profileApi from '../api/profile';
// Profile hooks
export const useProfile = () => {
return useQuery({
queryKey: ['profile'],
queryFn: profileApi.getProfile,
});
};
export const useUpdateProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.updateProfile,
onSuccess: (data) => {
queryClient.setQueryData(['profile'], data);
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useUploadAvatar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.uploadAvatar,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useDeleteAvatar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.deleteAvatar,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Email hooks
export const useSendVerificationEmail = () => {
return useMutation({
mutationFn: profileApi.sendVerificationEmail,
});
};
export const useVerifyEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useRequestEmailChange = () => {
return useMutation({
mutationFn: profileApi.requestEmailChange,
});
};
export const useConfirmEmailChange = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.confirmEmailChange,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Password hooks
export const useChangePassword = () => {
return useMutation({
mutationFn: ({
currentPassword,
newPassword,
}: {
currentPassword: string;
newPassword: string;
}) => profileApi.changePassword(currentPassword, newPassword),
});
};
// 2FA hooks
export const useSetupTOTP = () => {
return useMutation({
mutationFn: profileApi.setupTOTP,
});
};
export const useVerifyTOTP = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyTOTP,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useDisableTOTP = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.disableTOTP,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useRecoveryCodes = () => {
return useQuery({
queryKey: ['recoveryCodes'],
queryFn: profileApi.getRecoveryCodes,
enabled: false, // Only fetch on demand
});
};
export const useRegenerateRecoveryCodes = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.regenerateRecoveryCodes,
onSuccess: (codes) => {
queryClient.setQueryData(['recoveryCodes'], codes);
},
});
};
// Phone verification hooks
export const useSendPhoneVerification = () => {
return useMutation({
mutationFn: profileApi.sendPhoneVerification,
});
};
export const useVerifyPhoneCode = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyPhoneCode,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Session hooks
export const useSessions = () => {
return useQuery({
queryKey: ['sessions'],
queryFn: profileApi.getSessions,
});
};
export const useRevokeSession = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.revokeSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
};
export const useRevokeOtherSessions = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.revokeOtherSessions,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
};
export const useLoginHistory = () => {
return useQuery({
queryKey: ['loginHistory'],
queryFn: profileApi.getLoginHistory,
});
};
// Multiple email hooks
export const useUserEmails = () => {
return useQuery({
queryKey: ['userEmails'],
queryFn: profileApi.getUserEmails,
});
};
export const useAddUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.addUserEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useDeleteUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.deleteUserEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useSendUserEmailVerification = () => {
return useMutation({
mutationFn: profileApi.sendUserEmailVerification,
});
};
export const useVerifyUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ emailId, token }: { emailId: number; token: string }) =>
profileApi.verifyUserEmail(emailId, token),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useSetPrimaryEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.setPrimaryEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};

View File

@@ -0,0 +1,118 @@
/**
* Resource Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Resource, ResourceType } from '../types';
interface ResourceFilters {
type?: ResourceType;
}
/**
* Hook to fetch resources with optional type filter
*/
export const useResources = (filters?: ResourceFilters) => {
return useQuery<Resource[]>({
queryKey: ['resources', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.type) params.append('type', filters.type);
const { data } = await apiClient.get(`/api/resources/?${params}`);
// Transform backend format to frontend format
return data.map((r: any) => ({
id: String(r.id),
name: r.name,
type: r.type as ResourceType,
userId: r.user_id ? String(r.user_id) : undefined,
}));
},
});
};
/**
* Hook to get a single resource
*/
export const useResource = (id: string) => {
return useQuery<Resource>({
queryKey: ['resources', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/resources/${id}/`);
return {
id: String(data.id),
name: data.name,
type: data.type as ResourceType,
userId: data.user_id ? String(data.user_id) : undefined,
};
},
enabled: !!id,
});
};
/**
* Hook to create a resource
*/
export const useCreateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (resourceData: Omit<Resource, 'id'>) => {
const backendData = {
name: resourceData.name,
type: resourceData.type,
user: resourceData.userId ? parseInt(resourceData.userId) : null,
timezone: 'UTC', // Default timezone
};
const { data } = await apiClient.post('/api/resources/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to update a resource
*/
export const useUpdateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Resource> }) => {
const backendData: any = {};
if (updates.name) backendData.name = updates.name;
if (updates.type) backendData.type = updates.type;
if (updates.userId !== undefined) {
backendData.user = updates.userId ? parseInt(updates.userId) : null;
}
const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to delete a resource
*/
export const useDeleteResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/resources/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
/**
* Hook to scroll to top on route changes
* Should be used in layout components to ensure scroll restoration
* works consistently across all routes
*/
export function useScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
}

View File

@@ -0,0 +1,112 @@
/**
* Service Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Service } from '../types';
/**
* Hook to fetch all services for current business
*/
export const useServices = () => {
return useQuery<Service[]>({
queryKey: ['services'],
queryFn: async () => {
const { data } = await apiClient.get('/api/services/');
// Transform backend format to frontend format
return data.map((s: any) => ({
id: String(s.id),
name: s.name,
durationMinutes: s.duration || s.duration_minutes,
price: parseFloat(s.price),
description: s.description || '',
}));
},
});
};
/**
* Hook to get a single service
*/
export const useService = (id: string) => {
return useQuery<Service>({
queryKey: ['services', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/services/${id}/`);
return {
id: String(data.id),
name: data.name,
durationMinutes: data.duration || data.duration_minutes,
price: parseFloat(data.price),
description: data.description || '',
};
},
enabled: !!id,
});
};
/**
* Hook to create a service
*/
export const useCreateService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (serviceData: Omit<Service, 'id'>) => {
const backendData = {
name: serviceData.name,
duration: serviceData.durationMinutes,
price: serviceData.price.toString(),
description: serviceData.description,
};
const { data } = await apiClient.post('/api/services/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to update a service
*/
export const useUpdateService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Service> }) => {
const backendData: any = {};
if (updates.name) backendData.name = updates.name;
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
if (updates.price) backendData.price = updates.price.toString();
if (updates.description !== undefined) backendData.description = updates.description;
const { data } = await apiClient.patch(`/api/services/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to delete a service
*/
export const useDeleteService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/services/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};

View File

@@ -0,0 +1,197 @@
/**
* Transaction Analytics Hooks
*
* React Query hooks for fetching and managing transaction analytics data.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getTransactions,
getTransaction,
getTransactionSummary,
getStripeCharges,
getStripePayouts,
getStripeBalance,
exportTransactions,
getTransactionDetail,
refundTransaction,
TransactionFilters,
ExportRequest,
RefundRequest,
} from '../api/payments';
/**
* Hook to fetch paginated transaction list with optional filters.
*/
export const useTransactions = (filters?: TransactionFilters) => {
return useQuery({
queryKey: ['transactions', filters],
queryFn: async () => {
const { data } = await getTransactions(filters);
return data;
},
staleTime: 30 * 1000, // 30 seconds
});
};
/**
* Hook to fetch a single transaction by ID.
*/
export const useTransaction = (id: number) => {
return useQuery({
queryKey: ['transaction', id],
queryFn: async () => {
const { data } = await getTransaction(id);
return data;
},
enabled: !!id,
});
};
/**
* Hook to fetch transaction summary/analytics.
*/
export const useTransactionSummary = (filters?: Pick<TransactionFilters, 'start_date' | 'end_date'>) => {
return useQuery({
queryKey: ['transactionSummary', filters],
queryFn: async () => {
const { data } = await getTransactionSummary(filters);
return data;
},
staleTime: 60 * 1000, // 1 minute
});
};
/**
* Hook to fetch Stripe charges directly from Stripe API.
*/
export const useStripeCharges = (limit: number = 20) => {
return useQuery({
queryKey: ['stripeCharges', limit],
queryFn: async () => {
const { data } = await getStripeCharges(limit);
return data;
},
staleTime: 30 * 1000,
});
};
/**
* Hook to fetch Stripe payouts.
*/
export const useStripePayouts = (limit: number = 20) => {
return useQuery({
queryKey: ['stripePayouts', limit],
queryFn: async () => {
const { data } = await getStripePayouts(limit);
return data;
},
staleTime: 30 * 1000,
});
};
/**
* Hook to fetch current Stripe balance.
*/
export const useStripeBalance = () => {
return useQuery({
queryKey: ['stripeBalance'],
queryFn: async () => {
const { data } = await getStripeBalance();
return data;
},
staleTime: 60 * 1000, // 1 minute
refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes
});
};
/**
* Hook to export transaction data.
* Returns a mutation that triggers file download.
*/
export const useExportTransactions = () => {
return useMutation({
mutationFn: async (request: ExportRequest) => {
const response = await exportTransactions(request);
return response;
},
onSuccess: (response, request) => {
// Create blob URL and trigger download
const blob = new Blob([response.data], { type: response.headers['content-type'] });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Determine file extension based on format
const extensions: Record<string, string> = {
csv: 'csv',
xlsx: 'xlsx',
pdf: 'pdf',
quickbooks: 'iif',
};
const ext = extensions[request.format] || 'txt';
link.download = `transactions.${ext}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
});
};
/**
* Hook to invalidate all transaction-related queries.
* Useful after actions that modify transaction data.
*/
export const useInvalidateTransactions = () => {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['transactionSummary'] });
queryClient.invalidateQueries({ queryKey: ['stripeCharges'] });
queryClient.invalidateQueries({ queryKey: ['stripePayouts'] });
queryClient.invalidateQueries({ queryKey: ['stripeBalance'] });
queryClient.invalidateQueries({ queryKey: ['transactionDetail'] });
};
};
/**
* Hook to fetch detailed transaction information including refund data.
*/
export const useTransactionDetail = (id: number | null) => {
return useQuery({
queryKey: ['transactionDetail', id],
queryFn: async () => {
if (!id) return null;
const { data } = await getTransactionDetail(id);
return data;
},
enabled: !!id,
staleTime: 10 * 1000, // 10 seconds (refresh often for live data)
});
};
/**
* Hook to issue a refund for a transaction.
* Automatically invalidates transaction queries on success.
*/
export const useRefundTransaction = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ transactionId, request }: { transactionId: number; request?: RefundRequest }) => {
const { data } = await refundTransaction(transactionId, request);
return data;
},
onSuccess: (data, variables) => {
// Invalidate all relevant queries
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['transactionSummary'] });
queryClient.invalidateQueries({ queryKey: ['transactionDetail', variables.transactionId] });
queryClient.invalidateQueries({ queryKey: ['stripeCharges'] });
queryClient.invalidateQueries({ queryKey: ['stripeBalance'] });
},
});
};

View File

@@ -0,0 +1,208 @@
/**
* WebSocket hook for real-time user notifications.
* Connects to the backend WebSocket and updates React Query cache.
*/
import { useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCookie } from '../utils/cookies';
import { UserEmail } from '../api/profile';
interface WebSocketMessage {
type: 'connection_established' | 'email_verified' | 'profile_updated' | 'pong';
email_id?: number;
email?: string;
user_id?: string;
message?: string;
fields?: string[];
}
interface UseUserNotificationsOptions {
enabled?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Event) => void;
onEmailVerified?: (emailId: number, email: string) => void;
}
/**
* Hook for real-time user notifications via WebSocket.
*/
export function useUserNotifications(options: UseUserNotificationsOptions = {}) {
const { enabled = true, onConnected, onDisconnected, onError, onEmailVerified } = options;
const queryClient = useQueryClient();
// Use refs for callbacks to avoid recreating connect function
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
const onEmailVerifiedRef = useRef(onEmailVerified);
// Update refs when callbacks change
useEffect(() => {
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
onEmailVerifiedRef.current = onEmailVerified;
}, [onConnected, onDisconnected, onError, onEmailVerified]);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
const maxReconnectAttempts = 5;
const updateEmailQueryCache = useCallback((emailId: number) => {
// Update the userEmails query cache to mark the email as verified
queryClient.setQueryData<UserEmail[]>(['userEmails'], (old) => {
if (!old) return old;
return old.map((email) =>
email.id === emailId ? { ...email, verified: true } : email
);
});
}, [queryClient]);
const disconnect = useCallback(() => {
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
isConnectingRef.current = false;
}, []);
const connect = useCallback(() => {
// Prevent multiple simultaneous connection attempts
if (isConnectingRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
const token = getCookie('access_token');
if (!token) {
console.log('UserNotifications WebSocket: Missing token, skipping connection');
return;
}
isConnectingRef.current = true;
// Close existing connection if any
if (wsRef.current) {
wsRef.current.close();
}
// Determine WebSocket host - use api subdomain for WebSocket
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
const url = `${wsProtocol}//${wsHost}/ws/user/?token=${token}`;
console.log('UserNotifications WebSocket: Connecting');
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('UserNotifications WebSocket: Connected');
reconnectAttemptsRef.current = 0;
isConnectingRef.current = false;
onConnectedRef.current?.();
// Start ping interval to keep connection alive
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Ping every 30 seconds
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'connection_established':
console.log('UserNotifications WebSocket: Connection confirmed -', message.message);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'email_verified':
console.log('UserNotifications WebSocket: Email verified', message.email);
if (message.email_id) {
updateEmailQueryCache(message.email_id);
onEmailVerifiedRef.current?.(message.email_id, message.email || '');
}
break;
case 'profile_updated':
console.log('UserNotifications WebSocket: Profile updated', message.fields);
// Invalidate profile queries to refresh data
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
break;
default:
console.log('UserNotifications WebSocket: Unknown message type', message);
}
} catch (err) {
console.error('UserNotifications WebSocket: Failed to parse message', err);
}
};
ws.onerror = (error) => {
console.error('UserNotifications WebSocket: Error', error);
isConnectingRef.current = false;
onErrorRef.current?.(error);
};
ws.onclose = (event) => {
console.log('UserNotifications WebSocket: Disconnected', event.code, event.reason);
isConnectingRef.current = false;
onDisconnectedRef.current?.();
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Only attempt reconnection if this wasn't a deliberate close
// Code 1000 = normal closure, 1001 = going away (page unload)
if (event.code !== 1000 && event.code !== 1001 && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`UserNotifications WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
wsRef.current = ws;
}, [queryClient, updateEmailQueryCache]);
useEffect(() => {
if (enabled) {
connect();
}
return () => {
disconnect();
};
}, [enabled, connect, disconnect]);
return {
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
reconnect: connect,
disconnect,
};
}

View File

@@ -0,0 +1,62 @@
/**
* i18n Configuration
* Internationalization setup using react-i18next
*/
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translation files
import en from './locales/en.json';
import es from './locales/es.json';
import fr from './locales/fr.json';
import de from './locales/de.json';
import pt from './locales/pt.json';
import ja from './locales/ja.json';
import zh from './locales/zh.json';
export const supportedLanguages = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'pt', name: 'Português', flag: '🇧🇷' },
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
{ code: 'zh', name: '中文', flag: '🇨🇳' },
] as const;
export type SupportedLanguage = typeof supportedLanguages[number]['code'];
const resources = {
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
de: { translation: de },
pt: { translation: pt },
ja: { translation: ja },
zh: { translation: zh },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
debug: false, // Disable debug logging
interpolation: {
escapeValue: false, // React already escapes values
},
detection: {
// Order of language detection
order: ['localStorage', 'navigator', 'htmlTag'],
// Cache user language preference
caches: ['localStorage'],
lookupLocalStorage: 'smoothschedule_language',
},
});
export default i18n;

View File

@@ -0,0 +1,688 @@
{
"common": {
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg",
"save": "Speichern",
"saveChanges": "Änderungen speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"update": "Aktualisieren",
"close": "Schließen",
"confirm": "Bestätigen",
"back": "Zurück",
"next": "Weiter",
"search": "Suchen",
"filter": "Filtern",
"actions": "Aktionen",
"settings": "Einstellungen",
"reload": "Neu laden",
"viewAll": "Alle Anzeigen",
"learnMore": "Mehr Erfahren",
"poweredBy": "Bereitgestellt von",
"required": "Erforderlich",
"optional": "Optional",
"masquerade": "Als Benutzer agieren",
"masqueradeAsUser": "Als Benutzer agieren"
},
"auth": {
"signIn": "Anmelden",
"signOut": "Abmelden",
"signingIn": "Anmeldung läuft...",
"username": "Benutzername",
"password": "Passwort",
"enterUsername": "Geben Sie Ihren Benutzernamen ein",
"enterPassword": "Geben Sie Ihr Passwort ein",
"welcomeBack": "Willkommen zurück",
"pleaseEnterDetails": "Bitte geben Sie Ihre Daten ein, um sich anzumelden.",
"authError": "Authentifizierungsfehler",
"invalidCredentials": "Ungültige Anmeldedaten",
"orContinueWith": "Oder fortfahren mit",
"loginAtSubdomain": "Bitte melden Sie sich bei Ihrer Geschäfts-Subdomain an. Mitarbeiter und Kunden können sich nicht von der Hauptseite aus anmelden.",
"forgotPassword": "Passwort vergessen?",
"rememberMe": "Angemeldet bleiben",
"twoFactorRequired": "Zwei-Faktor-Authentifizierung erforderlich",
"enterCode": "Bestätigungscode eingeben",
"verifyCode": "Code Bestätigen"
},
"nav": {
"dashboard": "Dashboard",
"scheduler": "Terminplaner",
"customers": "Kunden",
"resources": "Ressourcen",
"payments": "Zahlungen",
"messages": "Nachrichten",
"staff": "Personal",
"businessSettings": "Geschäftseinstellungen",
"profile": "Profil",
"platformDashboard": "Plattform-Dashboard",
"businesses": "Unternehmen",
"users": "Benutzer",
"support": "Support",
"platformSettings": "Plattform-Einstellungen"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Willkommen, {{name}}!",
"todayOverview": "Heutige Übersicht",
"upcomingAppointments": "Bevorstehende Termine",
"recentActivity": "Neueste Aktivitäten",
"quickActions": "Schnellaktionen",
"totalRevenue": "Gesamtumsatz",
"totalAppointments": "Termine Gesamt",
"newCustomers": "Neue Kunden",
"pendingPayments": "Ausstehende Zahlungen"
},
"scheduler": {
"title": "Terminplaner",
"newAppointment": "Neuer Termin",
"editAppointment": "Termin Bearbeiten",
"deleteAppointment": "Termin Löschen",
"selectResource": "Ressource Auswählen",
"selectService": "Service Auswählen",
"selectCustomer": "Kunde Auswählen",
"selectDate": "Datum Auswählen",
"selectTime": "Uhrzeit Auswählen",
"duration": "Dauer",
"notes": "Notizen",
"status": "Status",
"confirmed": "Bestätigt",
"pending": "Ausstehend",
"cancelled": "Storniert",
"completed": "Abgeschlossen",
"noShow": "Nicht Erschienen",
"today": "Heute",
"week": "Woche",
"month": "Monat",
"day": "Tag",
"timeline": "Zeitachse",
"agenda": "Agenda",
"allResources": "Alle Ressourcen"
},
"customers": {
"title": "Kunden",
"description": "Verwalten Sie Ihren Kundenstamm und sehen Sie die Historie ein.",
"addCustomer": "Kunde Hinzufügen",
"editCustomer": "Kunde Bearbeiten",
"customerDetails": "Kundendetails",
"name": "Name",
"fullName": "Vollständiger Name",
"email": "E-Mail",
"emailAddress": "E-Mail-Adresse",
"phone": "Telefon",
"phoneNumber": "Telefonnummer",
"address": "Adresse",
"city": "Stadt",
"state": "Bundesland",
"zipCode": "PLZ",
"tags": "Tags",
"tagsPlaceholder": "z.B. VIP, Empfehlung",
"tagsCommaSeparated": "Tags (kommagetrennt)",
"appointmentHistory": "Terminverlauf",
"noAppointments": "Noch keine Termine",
"totalSpent": "Gesamtausgaben",
"totalSpend": "Gesamtausgaben",
"lastVisit": "Letzter Besuch",
"nextAppointment": "Nächster Termin",
"contactInfo": "Kontaktinfo",
"status": "Status",
"active": "Aktiv",
"inactive": "Inaktiv",
"never": "Nie",
"customer": "Kunde",
"searchPlaceholder": "Nach Name, E-Mail oder Telefon suchen...",
"filters": "Filter",
"noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.",
"addNewCustomer": "Neuen Kunden Hinzufügen",
"createCustomer": "Kunden Erstellen",
"errorLoading": "Fehler beim Laden der Kunden"
},
"staff": {
"title": "Personal & Management",
"description": "Benutzerkonten und Berechtigungen verwalten.",
"inviteStaff": "Personal Einladen",
"name": "Name",
"role": "Rolle",
"bookableResource": "Buchbare Ressource",
"makeBookable": "Buchbar Machen",
"yes": "Ja",
"errorLoading": "Fehler beim Laden des Personals",
"inviteModalTitle": "Personal Einladen",
"inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein."
},
"resources": {
"title": "Ressourcen",
"description": "Verwalten Sie Ihr Personal, Räume und Geräte.",
"addResource": "Ressource Hinzufügen",
"editResource": "Ressource Bearbeiten",
"resourceDetails": "Ressourcendetails",
"resourceName": "Ressourcenname",
"name": "Name",
"type": "Typ",
"resourceType": "Ressourcentyp",
"availability": "Verfügbarkeit",
"services": "Services",
"schedule": "Zeitplan",
"active": "Aktiv",
"inactive": "Inaktiv",
"upcoming": "Bevorstehend",
"appointments": "Termine",
"viewCalendar": "Kalender Anzeigen",
"noResourcesFound": "Keine Ressourcen gefunden.",
"addNewResource": "Neue Ressource Hinzufügen",
"createResource": "Ressource Erstellen",
"staffMember": "Mitarbeiter",
"room": "Raum",
"equipment": "Gerät",
"resourceNote": "Ressourcen sind Platzhalter für die Terminplanung. Personal kann Terminen separat zugewiesen werden.",
"errorLoading": "Fehler beim Laden der Ressourcen"
},
"services": {
"title": "Services",
"addService": "Service Hinzufügen",
"editService": "Service Bearbeiten",
"name": "Name",
"description": "Beschreibung",
"duration": "Dauer",
"price": "Preis",
"category": "Kategorie",
"active": "Aktiv"
},
"payments": {
"title": "Zahlungen",
"transactions": "Transaktionen",
"invoices": "Rechnungen",
"amount": "Betrag",
"status": "Status",
"date": "Datum",
"method": "Methode",
"paid": "Bezahlt",
"unpaid": "Unbezahlt",
"refunded": "Erstattet",
"pending": "Ausstehend",
"viewDetails": "Details Anzeigen",
"issueRefund": "Erstattung Ausstellen",
"sendReminder": "Erinnerung Senden",
"paymentSettings": "Zahlungseinstellungen",
"stripeConnect": "Stripe Connect",
"apiKeys": "API-Schlüssel"
},
"settings": {
"title": "Einstellungen",
"businessSettings": "Geschäftseinstellungen",
"businessSettingsDescription": "Verwalten Sie Ihr Branding, Ihre Domain und Richtlinien.",
"domainIdentity": "Domain & Identität",
"bookingPolicy": "Buchungs- und Stornierungsrichtlinie",
"savedSuccessfully": "Einstellungen erfolgreich gespeichert",
"general": "Allgemein",
"branding": "Markengestaltung",
"notifications": "Benachrichtigungen",
"security": "Sicherheit",
"integrations": "Integrationen",
"billing": "Abrechnung",
"businessName": "Firmenname",
"subdomain": "Subdomain",
"primaryColor": "Primärfarbe",
"secondaryColor": "Sekundärfarbe",
"logo": "Logo",
"uploadLogo": "Logo Hochladen",
"timezone": "Zeitzone",
"language": "Sprache",
"currency": "Währung",
"dateFormat": "Datumsformat",
"timeFormat": "Zeitformat",
"oauth": {
"title": "OAuth-Einstellungen",
"enabledProviders": "Aktivierte Anbieter",
"allowRegistration": "Registrierung über OAuth erlauben",
"autoLinkByEmail": "Konten automatisch per E-Mail verknüpfen",
"customCredentials": "Eigene OAuth-Anmeldedaten",
"customCredentialsDesc": "Verwenden Sie Ihre eigenen OAuth-Anmeldedaten für ein White-Label-Erlebnis",
"platformCredentials": "Plattform-Anmeldedaten",
"platformCredentialsDesc": "Verwendung der von der Plattform bereitgestellten OAuth-Anmeldedaten",
"clientId": "Client-ID",
"clientSecret": "Client-Geheimnis",
"paidTierOnly": "Eigene OAuth-Anmeldedaten sind nur für kostenpflichtige Tarife verfügbar"
}
},
"profile": {
"title": "Profileinstellungen",
"personalInfo": "Persönliche Informationen",
"changePassword": "Passwort Ändern",
"twoFactor": "Zwei-Faktor-Authentifizierung",
"sessions": "Aktive Sitzungen",
"emails": "E-Mail-Adressen",
"preferences": "Einstellungen",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort Bestätigen",
"passwordChanged": "Passwort erfolgreich geändert",
"enable2FA": "Zwei-Faktor-Authentifizierung Aktivieren",
"disable2FA": "Zwei-Faktor-Authentifizierung Deaktivieren",
"scanQRCode": "QR-Code Scannen",
"enterBackupCode": "Backup-Code Eingeben",
"recoveryCodes": "Wiederherstellungscodes"
},
"platform": {
"title": "Plattformverwaltung",
"dashboard": "Plattform-Dashboard",
"overview": "Plattformübersicht",
"overviewDescription": "Globale Metriken für alle Mandanten.",
"mrrGrowth": "MRR-Wachstum",
"totalBusinesses": "Unternehmen Gesamt",
"totalUsers": "Benutzer Gesamt",
"monthlyRevenue": "Monatlicher Umsatz",
"activeSubscriptions": "Aktive Abonnements",
"recentSignups": "Neueste Anmeldungen",
"supportTickets": "Support-Tickets",
"supportDescription": "Probleme von Mandanten lösen.",
"reportedBy": "Gemeldet von",
"priority": "Priorität",
"businessManagement": "Unternehmensverwaltung",
"userManagement": "Benutzerverwaltung",
"masquerade": "Als Benutzer agieren",
"masqueradeAs": "Agieren als",
"exitMasquerade": "Benutzeransicht Beenden",
"businesses": "Unternehmen",
"businessesDescription": "Mandanten, Pläne und Zugriffe verwalten.",
"addNewTenant": "Neuen Mandanten Hinzufügen",
"searchBusinesses": "Unternehmen suchen...",
"businessName": "Firmenname",
"subdomain": "Subdomain",
"plan": "Plan",
"status": "Status",
"joined": "Beigetreten",
"userDirectory": "Benutzerverzeichnis",
"userDirectoryDescription": "Alle Benutzer der Plattform anzeigen und verwalten.",
"searchUsers": "Benutzer nach Name oder E-Mail suchen...",
"allRoles": "Alle Rollen",
"user": "Benutzer",
"role": "Rolle",
"email": "E-Mail",
"noUsersFound": "Keine Benutzer gefunden, die Ihren Filtern entsprechen.",
"roles": {
"superuser": "Superuser",
"platformManager": "Plattform-Manager",
"businessOwner": "Geschäftsinhaber",
"staff": "Personal",
"customer": "Kunde"
},
"settings": {
"title": "Plattform-Einstellungen",
"description": "Plattformweite Einstellungen und Integrationen konfigurieren",
"tiersPricing": "Stufen und Preise",
"oauthProviders": "OAuth-Anbieter",
"general": "Allgemein",
"oauth": "OAuth-Anbieter",
"payments": "Zahlungen",
"email": "E-Mail",
"branding": "Markengestaltung"
}
},
"errors": {
"generic": "Etwas ist schief gelaufen. Bitte versuchen Sie es erneut.",
"networkError": "Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.",
"unauthorized": "Sie sind nicht berechtigt, diese Aktion durchzuführen.",
"notFound": "Die angeforderte Ressource wurde nicht gefunden.",
"validation": "Bitte überprüfen Sie Ihre Eingabe und versuchen Sie es erneut.",
"businessNotFound": "Unternehmen Nicht Gefunden",
"wrongLocation": "Falscher Standort",
"accessDenied": "Zugriff Verweigert"
},
"validation": {
"required": "Dieses Feld ist erforderlich",
"email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"minLength": "Muss mindestens {{min}} Zeichen haben",
"maxLength": "Darf maximal {{max}} Zeichen haben",
"passwordMatch": "Passwörter stimmen nicht überein",
"invalidPhone": "Bitte geben Sie eine gültige Telefonnummer ein"
},
"time": {
"minutes": "Minuten",
"hours": "Stunden",
"days": "Tage",
"today": "Heute",
"tomorrow": "Morgen",
"yesterday": "Gestern",
"thisWeek": "Diese Woche",
"thisMonth": "Diesen Monat",
"am": "AM",
"pm": "PM"
},
"marketing": {
"tagline": "Orchestrieren Sie Ihr Unternehmen mit Präzision.",
"description": "Die All-in-One-Terminplanungsplattform für Unternehmen jeder Größe. Verwalten Sie Ressourcen, Personal und Buchungen mühelos.",
"copyright": "Smooth Schedule Inc.",
"nav": {
"features": "Funktionen",
"pricing": "Preise",
"about": "Über uns",
"contact": "Kontakt",
"login": "Anmelden",
"getStarted": "Loslegen",
"startFreeTrial": "Kostenlos testen"
},
"hero": {
"headline": "Terminplanung Vereinfacht",
"subheadline": "Die All-in-One-Plattform für Termine, Ressourcen und Kunden. Starten Sie kostenlos, skalieren Sie nach Bedarf.",
"cta": "Kostenlos testen",
"secondaryCta": "Demo ansehen",
"trustedBy": "Über 1.000 Unternehmen vertrauen uns"
},
"features": {
"title": "Alles was Sie brauchen",
"subtitle": "Leistungsstarke Funktionen für Ihr Dienstleistungsunternehmen",
"scheduling": {
"title": "Intelligente Terminplanung",
"description": "Drag-and-Drop-Kalender mit Echtzeit-Verfügbarkeit, automatischen Erinnerungen und Konfliktererkennung."
},
"resources": {
"title": "Ressourcenverwaltung",
"description": "Verwalten Sie Personal, Räume und Ausrüstung. Konfigurieren Sie Verfügbarkeit, Fähigkeiten und Buchungsregeln."
},
"customers": {
"title": "Kundenportal",
"description": "Self-Service-Portal für Kunden. Verlauf einsehen, Termine verwalten und Zahlungsmethoden speichern."
},
"payments": {
"title": "Integrierte Zahlungen",
"description": "Akzeptieren Sie Online-Zahlungen mit Stripe. Anzahlungen, Vollzahlungen und automatische Rechnungsstellung."
},
"multiTenant": {
"title": "Multi-Standort-Support",
"description": "Verwalten Sie mehrere Standorte oder Marken von einem Dashboard mit isolierten Daten."
},
"whiteLabel": {
"title": "White-Label bereit",
"description": "Eigene Domain, Branding und SmoothSchedule-Branding entfernen für ein nahtloses Erlebnis."
},
"analytics": {
"title": "Analysen & Berichte",
"description": "Verfolgen Sie Umsatz, Termine und Kundentrends mit schönen Dashboards."
},
"integrations": {
"title": "Leistungsstarke Integrationen",
"description": "Verbinden Sie sich mit Google Calendar, Zoom, Stripe und mehr. API-Zugang für eigene Integrationen."
}
},
"howItWorks": {
"title": "In wenigen Minuten starten",
"subtitle": "Drei einfache Schritte zur Transformation Ihrer Terminplanung",
"step1": {
"title": "Konto erstellen",
"description": "Registrieren Sie sich kostenlos und richten Sie Ihr Unternehmensprofil in Minuten ein."
},
"step2": {
"title": "Dienste hinzufügen",
"description": "Konfigurieren Sie Ihre Dienste, Preise und verfügbaren Ressourcen."
},
"step3": {
"title": "Buchungen starten",
"description": "Teilen Sie Ihren Buchungslink und lassen Sie Kunden sofort Termine buchen."
}
},
"pricing": {
"title": "Einfache, transparente Preise",
"subtitle": "Starten Sie kostenlos, upgraden Sie nach Bedarf. Keine versteckten Gebühren.",
"monthly": "Monatlich",
"annual": "Jährlich",
"annualSave": "20% sparen",
"perMonth": "/Monat",
"period": "Monat",
"popular": "Beliebteste",
"mostPopular": "Beliebteste",
"getStarted": "Loslegen",
"contactSales": "Vertrieb kontaktieren",
"freeTrial": "14 Tage kostenlos testen",
"noCredit": "Keine Kreditkarte erforderlich",
"features": "Funktionen",
"tiers": {
"free": {
"name": "Kostenlos",
"description": "Perfekt zum Einstieg",
"price": "0",
"features": [
"Bis zu 2 Ressourcen",
"Basis-Terminplanung",
"Kundenverwaltung",
"Direkte Stripe-Integration",
"Subdomain (firma.smoothschedule.com)",
"Community-Support"
],
"transactionFee": "2,5% + 0,30€ pro Transaktion"
},
"professional": {
"name": "Professional",
"description": "Für wachsende Unternehmen",
"price": "29",
"annualPrice": "290",
"features": [
"Bis zu 10 Ressourcen",
"Eigene Domain",
"Stripe Connect (niedrigere Gebühren)",
"White-Label-Branding",
"E-Mail-Erinnerungen",
"Prioritäts-E-Mail-Support"
],
"transactionFee": "1,5% + 0,25€ pro Transaktion"
},
"business": {
"name": "Business",
"description": "Für etablierte Teams",
"price": "79",
"annualPrice": "790",
"features": [
"Unbegrenzte Ressourcen",
"Alle Professional-Funktionen",
"Teamverwaltung",
"Erweiterte Analysen",
"API-Zugang",
"Telefon-Support"
],
"transactionFee": "0,5% + 0,20€ pro Transaktion"
},
"enterprise": {
"name": "Enterprise",
"description": "Für große Organisationen",
"price": "Individuell",
"features": [
"Alle Business-Funktionen",
"Individuelle Integrationen",
"Dedizierter Success Manager",
"SLA-Garantien",
"Individuelle Verträge",
"On-Premise-Option"
],
"transactionFee": "Individuelle Transaktionsgebühren"
}
}
},
"testimonials": {
"title": "Beliebt bei Unternehmen überall",
"subtitle": "Sehen Sie, was unsere Kunden sagen"
},
"stats": {
"appointments": "Geplante Termine",
"businesses": "Unternehmen",
"countries": "Länder",
"uptime": "Verfügbarkeit"
},
"signup": {
"title": "Konto erstellen",
"subtitle": "Starten Sie Ihre kostenlose Testversion heute. Keine Kreditkarte erforderlich.",
"steps": {
"business": "Unternehmen",
"account": "Konto",
"plan": "Plan",
"confirm": "Bestätigen"
},
"businessInfo": {
"title": "Erzählen Sie uns von Ihrem Unternehmen",
"name": "Unternehmensname",
"namePlaceholder": "z.B., Acme Salon & Spa",
"subdomain": "Wählen Sie Ihre Subdomain",
"checking": "Verfügbarkeit prüfen...",
"available": "Verfügbar!",
"taken": "Bereits vergeben"
},
"accountInfo": {
"title": "Admin-Konto erstellen",
"firstName": "Vorname",
"lastName": "Nachname",
"email": "E-Mail-Adresse",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen"
},
"planSelection": {
"title": "Plan wählen"
},
"confirm": {
"title": "Überprüfen Sie Ihre Angaben",
"business": "Unternehmen",
"account": "Konto",
"plan": "Gewählter Plan",
"terms": "Mit der Kontoerstellung akzeptieren Sie unsere Nutzungsbedingungen und Datenschutzrichtlinie."
},
"errors": {
"businessNameRequired": "Unternehmensname ist erforderlich",
"subdomainRequired": "Subdomain ist erforderlich",
"subdomainTooShort": "Subdomain muss mindestens 3 Zeichen haben",
"subdomainInvalid": "Subdomain darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten",
"subdomainTaken": "Diese Subdomain ist bereits vergeben",
"firstNameRequired": "Vorname ist erforderlich",
"lastNameRequired": "Nachname ist erforderlich",
"emailRequired": "E-Mail ist erforderlich",
"emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordRequired": "Passwort ist erforderlich",
"passwordTooShort": "Passwort muss mindestens 8 Zeichen haben",
"passwordMismatch": "Passwörter stimmen nicht überein",
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
},
"success": {
"title": "Willkommen bei Smooth Schedule!",
"message": "Ihr Konto wurde erfolgreich erstellt.",
"yourUrl": "Ihre Buchungs-URL",
"checkEmail": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet. Bitte bestätigen Sie Ihre E-Mail, um alle Funktionen zu aktivieren.",
"goToLogin": "Zur Anmeldung"
},
"back": "Zurück",
"next": "Weiter",
"creating": "Konto wird erstellt...",
"createAccount": "Konto erstellen",
"haveAccount": "Haben Sie bereits ein Konto?",
"signIn": "Anmelden"
},
"faq": {
"title": "Häufig gestellte Fragen",
"subtitle": "Fragen? Wir haben Antworten.",
"questions": {
"trial": {
"question": "Bieten Sie eine kostenlose Testversion an?",
"answer": "Ja! Alle kostenpflichtigen Pläne beinhalten 14 Tage kostenlose Testversion. Keine Kreditkarte zum Start erforderlich."
},
"cancel": {
"question": "Kann ich jederzeit kündigen?",
"answer": "Absolut. Sie können Ihr Abonnement jederzeit ohne Kündigungsgebühren beenden."
},
"payment": {
"question": "Welche Zahlungsmethoden akzeptieren Sie?",
"answer": "Wir akzeptieren alle gängigen Kreditkarten über Stripe, einschließlich Visa, Mastercard und American Express."
},
"migrate": {
"question": "Kann ich von einer anderen Plattform migrieren?",
"answer": "Ja! Unser Team kann Ihnen helfen, Ihre vorhandenen Daten von anderen Planungsplattformen zu migrieren."
},
"support": {
"question": "Welche Art von Support bieten Sie an?",
"answer": "Der kostenlose Plan beinhaltet Community-Support. Professional und höher haben E-Mail-Support, Business/Enterprise haben Telefon-Support."
},
"customDomain": {
"question": "Wie funktionieren eigene Domains?",
"answer": "Professional und höhere Pläne können Ihre eigene Domain (z.B. buchen.ihrefirma.com) anstelle unserer Subdomain verwenden."
}
}
},
"about": {
"title": "Über Smooth Schedule",
"subtitle": "Unsere Mission ist es, die Terminplanung für Unternehmen überall zu vereinfachen.",
"story": {
"title": "Unsere Geschichte",
"content": "Smooth Schedule wurde mit einer einfachen Überzeugung gegründet: Terminplanung sollte nicht kompliziert sein. Wir haben eine Plattform gebaut, die es Unternehmen jeder Größe erleichtert, ihre Termine, Ressourcen und Kunden zu verwalten."
},
"mission": {
"title": "Unsere Mission",
"content": "Dienstleistungsunternehmen mit den Werkzeugen auszustatten, die sie zum Wachsen brauchen, und gleichzeitig ihren Kunden ein nahtloses Buchungserlebnis zu bieten."
},
"values": {
"title": "Unsere Werte",
"simplicity": {
"title": "Einfachheit",
"description": "Wir glauben, dass leistungsstarke Software auch einfach zu bedienen sein kann."
},
"reliability": {
"title": "Zuverlässigkeit",
"description": "Ihr Unternehmen hängt von uns ab, deshalb machen wir bei der Verfügbarkeit keine Kompromisse."
},
"transparency": {
"title": "Transparenz",
"description": "Keine versteckten Gebühren, keine Überraschungen. Was Sie sehen, ist was Sie bekommen."
},
"support": {
"title": "Support",
"description": "Wir sind hier, um Ihnen bei jedem Schritt zum Erfolg zu verhelfen."
}
}
},
"contact": {
"title": "Kontaktieren Sie uns",
"subtitle": "Fragen? Wir würden gerne von Ihnen hören.",
"form": {
"name": "Ihr Name",
"namePlaceholder": "Max Mustermann",
"email": "E-Mail-Adresse",
"emailPlaceholder": "sie@beispiel.de",
"subject": "Betreff",
"subjectPlaceholder": "Wie können wir helfen?",
"message": "Nachricht",
"messagePlaceholder": "Erzählen Sie uns mehr über Ihre Anforderungen...",
"submit": "Nachricht senden",
"sending": "Wird gesendet...",
"success": "Danke für Ihre Nachricht! Wir melden uns bald.",
"error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
},
"info": {
"email": "support@smoothschedule.com",
"phone": "+1 (555) 123-4567",
"address": "123 Schedule Street, San Francisco, CA 94102"
},
"sales": {
"title": "Mit dem Vertrieb sprechen",
"description": "Interessiert an unserem Enterprise-Plan? Unser Vertriebsteam freut sich auf ein Gespräch."
}
},
"cta": {
"ready": "Bereit loszulegen?",
"readySubtitle": "Schließen Sie sich Tausenden von Unternehmen an, die bereits SmoothSchedule nutzen.",
"startFree": "Kostenlos testen",
"noCredit": "Keine Kreditkarte erforderlich"
},
"footer": {
"product": "Produkt",
"company": "Unternehmen",
"legal": "Rechtliches",
"features": "Funktionen",
"pricing": "Preise",
"integrations": "Integrationen",
"about": "Über uns",
"blog": "Blog",
"careers": "Karriere",
"contact": "Kontakt",
"terms": "AGB",
"privacy": "Datenschutz",
"cookies": "Cookies",
"allRightsReserved": "Alle Rechte vorbehalten."
}
}
}

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