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>
1
legacy_reference/frontend/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_DEV_MODE=true
|
||||
24
legacy_reference/frontend/.gitignore
vendored
Normal 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?
|
||||
159
legacy_reference/frontend/CLAUDE.md
Normal 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)
|
||||
29
legacy_reference/frontend/CSP-PRODUCTION.md
Normal 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/
|
||||
16
legacy_reference/frontend/Dockerfile
Normal 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"]
|
||||
42
legacy_reference/frontend/Dockerfile.prod
Normal 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;"]
|
||||
16
legacy_reference/frontend/README.md
Normal 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.
|
||||
423
legacy_reference/frontend/README_INTEGRATION.md
Normal 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.
|
||||
17
legacy_reference/frontend/capture-original.js
Normal 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();
|
||||
})();
|
||||
12
legacy_reference/frontend/docker-entrypoint.sh
Normal 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 "$@"
|
||||
29
legacy_reference/frontend/eslint.config.js
Normal 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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
48
legacy_reference/frontend/index.html
Normal 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>
|
||||
60
legacy_reference/frontend/nginx.conf
Normal 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
50
legacy_reference/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.
|
||||
```
|
||||
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 336 KiB |
85
legacy_reference/frontend/playwright-report/index.html
Normal file
58
legacy_reference/frontend/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
6
legacy_reference/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
legacy_reference/frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
legacy_reference/frontend/public/vite.svg
Normal 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 |
36
legacy_reference/frontend/src/App.css
Normal 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;
|
||||
}
|
||||
555
legacy_reference/frontend/src/App.tsx
Normal 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;
|
||||
113
legacy_reference/frontend/src/api/auth.ts
Normal 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;
|
||||
};
|
||||
106
legacy_reference/frontend/src/api/business.ts
Normal 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;
|
||||
};
|
||||
85
legacy_reference/frontend/src/api/client.ts
Normal 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;
|
||||
59
legacy_reference/frontend/src/api/config.ts
Normal 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';
|
||||
};
|
||||
51
legacy_reference/frontend/src/api/customDomains.ts
Normal 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;
|
||||
};
|
||||
181
legacy_reference/frontend/src/api/domains.ts
Normal 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;
|
||||
};
|
||||
93
legacy_reference/frontend/src/api/oauth.ts
Normal 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}/`);
|
||||
};
|
||||
433
legacy_reference/frontend/src/api/payments.ts
Normal 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 || {});
|
||||
56
legacy_reference/frontend/src/api/platform.ts
Normal 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;
|
||||
};
|
||||
90
legacy_reference/frontend/src/api/platformOAuth.ts
Normal 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;
|
||||
};
|
||||
210
legacy_reference/frontend/src/api/profile.ts
Normal 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/`);
|
||||
};
|
||||
1
legacy_reference/frontend/src/assets/react.svg
Normal 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 |
BIN
legacy_reference/frontend/src/assets/smooth_schedule_icon.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
247
legacy_reference/frontend/src/assets/smooth_schedule_icon.svg
Normal 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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
137
legacy_reference/frontend/src/components/BookingForm.css
Normal 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;
|
||||
}
|
||||
133
legacy_reference/frontend/src/components/BookingForm.jsx
Normal 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;
|
||||
269
legacy_reference/frontend/src/components/ConnectOnboarding.tsx
Normal 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;
|
||||
@@ -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;
|
||||
636
legacy_reference/frontend/src/components/DomainPurchase.tsx
Normal 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;
|
||||
111
legacy_reference/frontend/src/components/LanguageSelector.tsx
Normal 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;
|
||||
@@ -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;
|
||||
156
legacy_reference/frontend/src/components/OAuthButtons.tsx
Normal 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;
|
||||
329
legacy_reference/frontend/src/components/OnboardingWizard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
85
legacy_reference/frontend/src/components/PlatformSidebar.tsx
Normal 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;
|
||||
26
legacy_reference/frontend/src/components/Portal.tsx
Normal 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;
|
||||
252
legacy_reference/frontend/src/components/QuickAddAppointment.tsx
Normal 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;
|
||||
729
legacy_reference/frontend/src/components/ResourceCalendar.tsx
Normal 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;
|
||||
89
legacy_reference/frontend/src/components/ServiceList.css
Normal 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;
|
||||
}
|
||||
39
legacy_reference/frontend/src/components/ServiceList.jsx
Normal 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;
|
||||
174
legacy_reference/frontend/src/components/Sidebar.tsx
Normal 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;
|
||||
441
legacy_reference/frontend/src/components/StripeApiKeysForm.tsx
Normal 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;
|
||||
61
legacy_reference/frontend/src/components/TopBar.tsx
Normal 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;
|
||||
@@ -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;
|
||||
92
legacy_reference/frontend/src/components/TrialBanner.tsx
Normal 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;
|
||||
150
legacy_reference/frontend/src/components/UserProfileDropdown.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
136
legacy_reference/frontend/src/components/marketing/Footer.tsx
Normal 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">
|
||||
© {currentYear} {t('marketing.footer.copyright')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
166
legacy_reference/frontend/src/components/marketing/Hero.tsx
Normal 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;
|
||||
@@ -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;
|
||||
164
legacy_reference/frontend/src/components/marketing/Navbar.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
332
legacy_reference/frontend/src/hooks/useAppointmentWebSocket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
279
legacy_reference/frontend/src/hooks/useAppointments.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
231
legacy_reference/frontend/src/hooks/useAuth.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
};
|
||||
144
legacy_reference/frontend/src/hooks/useBusiness.ts
Normal 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
|
||||
});
|
||||
};
|
||||
34
legacy_reference/frontend/src/hooks/useBusinessOAuth.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
83
legacy_reference/frontend/src/hooks/useCustomDomains.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
118
legacy_reference/frontend/src/hooks/useCustomers.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
190
legacy_reference/frontend/src/hooks/useDomains.ts
Normal 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';
|
||||
104
legacy_reference/frontend/src/hooks/useOAuth.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
154
legacy_reference/frontend/src/hooks/usePayments.ts
Normal 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() });
|
||||
},
|
||||
});
|
||||
};
|
||||
41
legacy_reference/frontend/src/hooks/usePlatform.ts
Normal 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
|
||||
});
|
||||
};
|
||||
36
legacy_reference/frontend/src/hooks/usePlatformOAuth.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
193
legacy_reference/frontend/src/hooks/usePlatformSettings.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
248
legacy_reference/frontend/src/hooks/useProfile.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
118
legacy_reference/frontend/src/hooks/useResources.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
15
legacy_reference/frontend/src/hooks/useScrollToTop.ts
Normal 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]);
|
||||
}
|
||||
112
legacy_reference/frontend/src/hooks/useServices.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
197
legacy_reference/frontend/src/hooks/useTransactionAnalytics.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
208
legacy_reference/frontend/src/hooks/useUserNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
62
legacy_reference/frontend/src/i18n/index.ts
Normal 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;
|
||||
688
legacy_reference/frontend/src/i18n/locales/de.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||