Compare commits
47 Commits
main
...
new-websit
| Author | SHA1 | Date |
|---|---|---|
|
|
469e589489 | |
|
|
00eec1ce65 | |
|
|
9c1c83b51b | |
|
|
f895168357 | |
|
|
3195e98438 | |
|
|
f1a1fa192b | |
|
|
ce3d446b36 | |
|
|
3644673990 | |
|
|
4f8481101a | |
|
|
beddf93e86 | |
|
|
e21a352562 | |
|
|
7a9e4ce290 | |
|
|
04121a1a85 | |
|
|
7fbc22e430 | |
|
|
e81faf384f | |
|
|
e676d90d8b | |
|
|
d8688b012a | |
|
|
a7ed7dba30 | |
|
|
ec267696dc | |
|
|
4ea1d1c32f | |
|
|
fcae3e4841 | |
|
|
025c59a9df | |
|
|
34c2bf1e54 | |
|
|
13b1952749 | |
|
|
3aecfd8e96 | |
|
|
862a8ca995 | |
|
|
8d48036b32 | |
|
|
a44ecf888a | |
|
|
35d7a42e32 | |
|
|
4b941ff318 | |
|
|
0df281f799 | |
|
|
536c1dddc3 | |
|
|
61992077ec | |
|
|
79a004ea9b | |
|
|
1ab9bea8de | |
|
|
c08c251087 | |
|
|
8bd1a3f3e8 | |
|
|
83de08ddd9 | |
|
|
5d6d842a23 | |
|
|
894ae18b80 | |
|
|
b14468e5c0 | |
|
|
c399a12a53 | |
|
|
a1d010c71e | |
|
|
ba9597b77f | |
|
|
dcbfe10534 | |
|
|
e0012188b7 | |
|
|
0261caf965 |
|
|
@ -0,0 +1,121 @@
|
||||||
|
2025-05-10 00:33:24,807 - semgrep.notifications - WARNING - METRICS: Using configs from the Registry (like --config=p/ci) reports pseudonymous rule metrics to semgrep.dev.
|
||||||
|
To disable Registry rule metrics, use "--metrics=off".
|
||||||
|
Using configs only from local files (like --config=xyz.yml) does not enable metrics.
|
||||||
|
|
||||||
|
More information: https://semgrep.dev/docs/metrics
|
||||||
|
|
||||||
|
2025-05-10 00:33:24,810 - semgrep.run_scan - DEBUG - semgrep version 1.2.0
|
||||||
|
2025-05-10 00:33:24,819 - semgrep.git - DEBUG - Failed to get project url from 'git ls-remote': Command failed with exit code: 128
|
||||||
|
-----
|
||||||
|
Command failed with output:
|
||||||
|
fatal: No remote configured to list refs from.
|
||||||
|
|
||||||
|
|
||||||
|
Failed to run 'git ls-remote --get-url'. Possible reasons:
|
||||||
|
|
||||||
|
- the git binary is not available
|
||||||
|
- the current working directory is not a git repository
|
||||||
|
- the baseline commit is not a parent of the current commit
|
||||||
|
(if you are running through semgrep-app, check if you are setting `SEMGREP_BRANCH` or `SEMGREP_BASELINE_COMMIT` properly)
|
||||||
|
- the current working directory is not marked as safe
|
||||||
|
(fix with `git config --global --add safe.directory $(pwd)`)
|
||||||
|
|
||||||
|
Try running the command yourself to debug the issue.
|
||||||
|
2025-05-10 00:33:24,820 - semgrep.config_resolver - DEBUG - Loading local config from /home/runner/workspace/.config/.semgrep/semgrep_rules.json
|
||||||
|
2025-05-10 00:33:24,823 - semgrep.config_resolver - DEBUG - Done loading local config from /home/runner/workspace/.config/.semgrep/semgrep_rules.json
|
||||||
|
2025-05-10 00:33:24,831 - semgrep.config_resolver - DEBUG - Saving rules to /tmp/semgrep-mthvn42m.rules
|
||||||
|
2025-05-10 00:33:25,197 - semgrep.semgrep_core - DEBUG - Failed to open resource semgrep-core-proprietary: [Errno 2] No such file or directory: '/tmp/_MEI1ufMVi/semgrep/bin/semgrep-core-proprietary'.
|
||||||
|
2025-05-10 00:33:25,912 - semgrep.rule_lang - DEBUG - semgrep-core validation response: valid=True
|
||||||
|
2025-05-10 00:33:25,913 - semgrep.rule_lang - DEBUG - semgrep-core validation succeeded
|
||||||
|
2025-05-10 00:33:25,913 - semgrep.rule_lang - DEBUG - RPC validation succeeded
|
||||||
|
2025-05-10 00:33:25,913 - semgrep.config_resolver - DEBUG - loaded 1 configs in 1.0931837558746338
|
||||||
|
2025-05-10 00:33:26,154 - semgrep.run_scan - VERBOSE - running 1250 rules from 1 config /home/runner/workspace/.config/.semgrep/semgrep_rules.json_0
|
||||||
|
2025-05-10 00:33:26,154 - semgrep.run_scan - VERBOSE - No .semgrepignore found. Using default .semgrepignore rules. See the docs for the list of default ignores: https://semgrep.dev/docs/cli-usage/#ignore-files
|
||||||
|
2025-05-10 00:33:26,158 - semgrep.run_scan - VERBOSE - Rules:
|
||||||
|
2025-05-10 00:33:26,158 - semgrep.run_scan - VERBOSE - <SKIPPED DATA (too many entries; use --max-log-list-entries)>
|
||||||
|
2025-05-10 00:33:26,661 - semgrep.core_runner - DEBUG - Passing whole rules directly to semgrep_core
|
||||||
|
2025-05-10 00:33:26,855 - semgrep.core_runner - DEBUG - Running Semgrep engine with command:
|
||||||
|
2025-05-10 00:33:26,855 - semgrep.core_runner - DEBUG - /tmp/_MEI1ufMVi/semgrep/bin/opengrep-core -json -rules /tmp/tmp6vt4ey5_.json -j 8 -targets /tmp/tmpsta40moi -timeout 5 -timeout_threshold 3 -max_memory 0 -fast
|
||||||
|
2025-05-10 00:33:29,983 - semgrep.core_runner - DEBUG - --- semgrep-core stderr ---
|
||||||
|
[00.07][[34mINFO[0m]: Executed as: /tmp/_MEI1ufMVi/semgrep/bin/opengrep-core -json -rules /tmp/tmp6vt4ey5_.json -j 8 -targets /tmp/tmpsta40moi -timeout 5 -timeout_threshold 3 -max_memory 0 -fast
|
||||||
|
[00.07][[34mINFO[0m]: Version: 1.2.0
|
||||||
|
[00.07][[34mINFO[0m]: Parsing rules in /tmp/tmp6vt4ey5_.json
|
||||||
|
[00.82][[34mINFO[0m]: scan: processing 303 files (skipping 0), with 487 rules (skipping 0 )
|
||||||
|
[03.08][[34mINFO[0m]: Custom ignore pattern: None
|
||||||
|
[03.08][[34mINFO[0m]: Custom ignore pattern: None
|
||||||
|
--- end semgrep-core stderr ---
|
||||||
|
2025-05-10 00:33:29,991 - semgrep.rule_match - DEBUG - match_key = ('', PosixPath('client/index.html'), 'config..semgrep.vendored-rules.html.security.audit.missing-integrity') match_id = 0ee74fd49637bebe183eca7188dbde26e386314e62cc2e7ba1ee60b377b638243fcd84e6c6fa04886198ccacfa6a711bfbcc61a28f9ddc913d5b3c53083cbc90_0
|
||||||
|
2025-05-10 00:33:29,992 - semgrep.rule_match - DEBUG - match_key = (' rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / (?is).*integrity= (google-analytics\\.com|fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|googletagmanager\\.com) .*rel\\s*=\\s*[\'"]?preconnect.* href="... :// ..." href="//..." href=\'... :// ...\' href=\'//...\' src="... :// ..." src="//..." src=\'... :// ...\' src=\'//...\' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / > <script rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / >...</script>', PosixPath('client/index.html'), 'config..semgrep.vendored-rules.html.security.audit.missing-integrity') match_id = 1068497918b233fd4d3e50f287aaf6ab1a03059c638e7dd12249612bb34d624f7036ebd3f250440227c595b791530ce2bb70f5a13d3e4f169ddcde97bedfc6bc_0
|
||||||
|
2025-05-10 00:33:29,993 - semgrep.rule_match - DEBUG - match_key = (' rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / (?is).*integrity= (google-analytics\\.com|fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|googletagmanager\\.com) .*rel\\s*=\\s*[\'"]?preconnect.* href="... :// ..." href="//..." href=\'... :// ...\' href=\'//...\' src="... :// ..." src="//..." src=\'... :// ...\' src=\'//...\' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / > <script rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / >...</script>', PosixPath('client/index.html'), 'config..semgrep.vendored-rules.html.security.audit.missing-integrity') match_id = 1068497918b233fd4d3e50f287aaf6ab1a03059c638e7dd12249612bb34d624f7036ebd3f250440227c595b791530ce2bb70f5a13d3e4f169ddcde97bedfc6bc_0
|
||||||
|
2025-05-10 00:33:29,993 - semgrep.rule_match - DEBUG - match_key = (' rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / (?is).*integrity= (google-analytics\\.com|fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|googletagmanager\\.com) .*rel\\s*=\\s*[\'"]?preconnect.* href="... :// ..." href="//..." href=\'... :// ...\' href=\'//...\' src="... :// ..." src="//..." src=\'... :// ...\' src=\'//...\' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / > <script rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" / >...</script>', PosixPath('client/index.html'), 'config..semgrep.vendored-rules.html.security.audit.missing-integrity') match_id = 1068497918b233fd4d3e50f287aaf6ab1a03059c638e7dd12249612bb34d624f7036ebd3f250440227c595b791530ce2bb70f5a13d3e4f169ddcde97bedfc6bc_0
|
||||||
|
2025-05-10 00:33:29,994 - semgrep.rule_match - DEBUG - match_key = ('', PosixPath('client/index.html'), 'config..semgrep.vendored-rules.html.security.audit.missing-integrity') match_id = 0ee74fd49637bebe183eca7188dbde26e386314e62cc2e7ba1ee60b377b638243fcd84e6c6fa04886198ccacfa6a711bfbcc61a28f9ddc913d5b3c53083cbc90_0
|
||||||
|
2025-05-10 00:33:29,995 - semgrep.rule_match - DEBUG - match_key = (' type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" (?is).*integrity= (google-analytics\\.com|fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|googletagmanager\\.com) .*rel\\s*=\\s*[\'"]?preconnect.* href="... :// ..." href="//..." href=\'... :// ...\' href=\'//...\' src="... :// ..." src="//..." src=\'... :// ...\' src=\'//...\' <link type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" > <script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" >...</script>', PosixPath('client/index.html'), 'config..semgrep.vendored-rules.html.security.audit.missing-integrity') match_id = 0728b64e224596592d04447ba8a642ff94e1fb9fcc07be26d49dc7e7f6898e638ad16ffcaca086932c58f4c6400fe32603323afef02cf9bfebcb0e4a53562a40_0
|
||||||
|
2025-05-10 00:33:29,995 - semgrep.rule_match - DEBUG - match_key = (' type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" (?is).*integrity= (google-analytics\\.com|fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|googletagmanager\\.com) .*rel\\s*=\\s*[\'"]?preconnect.* href="... :// ..." href="//..." href=\'... :// ...\' href=\'//...\' src="... :// ..." src="//..." src=\'... :// ...\' src=\'//...\' <link type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" > <script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" >...</script>', PosixPath('client/index.html'), 'config..semgrep.vendored-rules.html.security.audit.missing-integrity') match_id = 0728b64e224596592d04447ba8a642ff94e1fb9fcc07be26d49dc7e7f6898e638ad16ffcaca086932c58f4c6400fe32603323afef02cf9bfebcb0e4a53562a40_0
|
||||||
|
2025-05-10 00:33:29,995 - semgrep.rule_match - DEBUG - match_key = (' type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" (?is).*integrity= (google-analytics\\.com|fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|googletagmanager\\.com) .*rel\\s*=\\s*[\'"]?preconnect.* href="... :// ..." href="//..." href=\'... :// ...\' href=\'//...\' src="... :// ..." src="//..." src=\'... :// ...\' src=\'//...\' <link type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" > <script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js" >...</script>', PosixPath('client/index.html'), 'config..semgrep.vendored-rules.html.security.audit.missing-integrity') match_id = 0728b64e224596592d04447ba8a642ff94e1fb9fcc07be26d49dc7e7f6898e638ad16ffcaca086932c58f4c6400fe32603323afef02cf9bfebcb0e4a53562a40_0
|
||||||
|
2025-05-10 00:33:29,998 - semgrep.core_runner - DEBUG - semgrep ran in 0:00:03.337440 on 103 files
|
||||||
|
2025-05-10 00:33:30,000 - semgrep.core_runner - DEBUG - findings summary: 2 warning, 0 error, 0 info
|
||||||
|
2025-05-10 00:33:30,004 - semgrep.app.auth - DEBUG - Getting API token from settings file
|
||||||
|
2025-05-10 00:33:30,004 - semgrep.app.auth - DEBUG - No API token found in settings file
|
||||||
|
2025-05-10 00:33:30,005 - semgrep.semgrep_core - DEBUG - Failed to open resource semgrep-core-proprietary: [Errno 2] No such file or directory: '/tmp/_MEI1ufMVi/semgrep/bin/semgrep-core-proprietary'.
|
||||||
|
2025-05-10 00:33:30,110 - semgrep.output - VERBOSE -
|
||||||
|
========================================
|
||||||
|
Files skipped:
|
||||||
|
========================================
|
||||||
|
|
||||||
|
Always skipped by Opengrep:
|
||||||
|
|
||||||
|
• <none>
|
||||||
|
|
||||||
|
Skipped by .gitignore:
|
||||||
|
(Disable by passing --no-git-ignore)
|
||||||
|
|
||||||
|
• <all files not listed by `git ls-files` were skipped>
|
||||||
|
|
||||||
|
Skipped by .semgrepignore:
|
||||||
|
- https://semgrep.dev/docs/ignoring-files-folders-code/#understand-semgrep-defaults
|
||||||
|
|
||||||
|
• <none>
|
||||||
|
|
||||||
|
Skipped by --include patterns:
|
||||||
|
|
||||||
|
• <none>
|
||||||
|
|
||||||
|
Skipped by --exclude patterns:
|
||||||
|
|
||||||
|
• <none>
|
||||||
|
|
||||||
|
Files skipped due to insufficient read permissions:
|
||||||
|
|
||||||
|
• <none>
|
||||||
|
|
||||||
|
Skipped by limiting to files smaller than 1000000 bytes:
|
||||||
|
(Adjust with the --max-target-bytes flag)
|
||||||
|
|
||||||
|
• attached_assets/8w-oevrI.jpeg
|
||||||
|
• attached_assets/DSC01368 2.jpeg
|
||||||
|
• attached_assets/DSC01380.jpeg
|
||||||
|
• attached_assets/DSC01394 2.jpeg
|
||||||
|
• attached_assets/DSC01466 2.jpeg
|
||||||
|
• attached_assets/Fadia-132.jpg
|
||||||
|
• attached_assets/Fadia-15.jpg
|
||||||
|
• attached_assets/Fadia-156.jpg
|
||||||
|
• attached_assets/save.jpeg
|
||||||
|
• client/src/assets/Fadia-132.jpg
|
||||||
|
• client/src/assets/Fadia-15.jpg
|
||||||
|
• client/src/assets/Fadia-156.jpg
|
||||||
|
• generated-icon.png
|
||||||
|
|
||||||
|
Partially analyzed due to parsing or internal Opengrep errors
|
||||||
|
|
||||||
|
• client/index.html (1 lines skipped)
|
||||||
|
• tailwind.config.ts (1 lines skipped)
|
||||||
|
|
||||||
|
2025-05-10 00:33:30,111 - semgrep.output - INFO - Some files were skipped or only partially analyzed.
|
||||||
|
Scan was limited to files tracked by git.
|
||||||
|
Partially scanned: 2 files only partially analyzed due to parsing or internal Opengrep errors
|
||||||
|
Scan skipped: 13 files larger than 1.0 MB
|
||||||
|
For a full list of skipped files, run opengrep with the --verbose flag.
|
||||||
|
|
||||||
|
Ran 443 rules on 103 files: 2 findings.
|
||||||
|
2025-05-10 00:33:30,112 - semgrep.app.version - DEBUG - Version cache does not exist
|
||||||
|
2025-05-10 00:33:30,133 - semgrep.metrics - VERBOSE - Not sending pseudonymous metrics since metrics are configured to OFF and registry usage is False
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
has_shown_metrics_notification: true
|
||||||
|
anonymous_user_id: e3c333de-dabb-4909-a90d-30c3616cc5f8
|
||||||
|
|
@ -3,4 +3,11 @@ dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/public
|
server/public
|
||||||
vite.config.ts.*
|
vite.config.ts.*
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
# Cloudflare Pages Deployment Guide
|
||||||
|
|
||||||
|
This guide will help you deploy your Pilates with Fadia website to Cloudflare Pages.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A Cloudflare account
|
||||||
|
2. Wrangler CLI installed: `npm install -g wrangler`
|
||||||
|
3. Your domain (optional, Cloudflare Pages provides a free subdomain)
|
||||||
|
|
||||||
|
## Setup Steps
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install root dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Install client dependencies
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Cloudflare KV Namespace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create KV namespace for production
|
||||||
|
wrangler kv:namespace create "STORAGE"
|
||||||
|
|
||||||
|
# Create KV namespace for preview
|
||||||
|
wrangler kv:namespace create "STORAGE" --preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `wrangler.toml` file with the actual namespace IDs returned from the commands above.
|
||||||
|
|
||||||
|
### 3. Set Environment Variables
|
||||||
|
|
||||||
|
In your Cloudflare Pages dashboard, go to Settings > Environment Variables and add:
|
||||||
|
|
||||||
|
**Production Environment:**
|
||||||
|
- `JWT_SECRET`: A secure random string for JWT signing
|
||||||
|
- `MAILCHIMP_API_KEY`: Your Mailchimp API key
|
||||||
|
- `MAILCHIMP_SERVER_PREFIX`: Your Mailchimp server prefix (e.g., "us1")
|
||||||
|
- `MAILCHIMP_LIST_ID`: Your Mailchimp list ID
|
||||||
|
|
||||||
|
**Preview Environment:**
|
||||||
|
- Same variables as production (or use test values)
|
||||||
|
|
||||||
|
### 4. Build and Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the client
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Deploy to Cloudflare Pages
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configure Custom Domain (Optional)
|
||||||
|
|
||||||
|
1. In Cloudflare Pages dashboard, go to your project
|
||||||
|
2. Click "Custom domains"
|
||||||
|
3. Add your domain
|
||||||
|
4. Follow the DNS configuration instructions
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
For local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start the Vite development server on `http://localhost:5173`.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The following API endpoints are available:
|
||||||
|
|
||||||
|
- `GET /api/classes` - Get all classes
|
||||||
|
- `GET /api/classes/:id` - Get specific class
|
||||||
|
- `POST /api/newsletter` - Subscribe to newsletter
|
||||||
|
- `POST /api/contact` - Send contact message
|
||||||
|
- `POST /api/auth/register` - User registration
|
||||||
|
- `POST /api/auth/login` - User login
|
||||||
|
- `GET /api/auth/me` - Get current user (requires JWT token)
|
||||||
|
- `GET /api/bookings` - Get user bookings (requires JWT token)
|
||||||
|
- `POST /api/bookings` - Create booking (requires JWT token)
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The application now uses JWT tokens instead of sessions:
|
||||||
|
|
||||||
|
1. Users register/login and receive a JWT token
|
||||||
|
2. The token is stored in localStorage
|
||||||
|
3. All authenticated requests include the token in the Authorization header
|
||||||
|
4. Tokens are verified on each request
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
Data is stored in Cloudflare KV:
|
||||||
|
|
||||||
|
- User accounts
|
||||||
|
- Newsletter subscriptions
|
||||||
|
- Contact messages
|
||||||
|
- User bookings
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build Issues
|
||||||
|
- Ensure all dependencies are installed
|
||||||
|
- Check that TypeScript compilation passes: `npm run check`
|
||||||
|
|
||||||
|
### API Issues
|
||||||
|
- Verify environment variables are set correctly
|
||||||
|
- Check Cloudflare Functions logs in the dashboard
|
||||||
|
- Ensure KV namespace is properly configured
|
||||||
|
|
||||||
|
### Authentication Issues
|
||||||
|
- Verify JWT_SECRET is set
|
||||||
|
- Check that tokens are being sent in Authorization headers
|
||||||
|
- Ensure token format is correct (Bearer <token>)
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. **JWT Secret**: Use a strong, random secret for JWT signing
|
||||||
|
2. **Environment Variables**: Never commit sensitive data to version control
|
||||||
|
3. **CORS**: The middleware handles CORS for all requests
|
||||||
|
4. **Input Validation**: All API endpoints validate input using Zod schemas
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- Static assets are served from Cloudflare's global CDN
|
||||||
|
- API functions run at the edge for low latency
|
||||||
|
- KV storage provides fast data access
|
||||||
|
- Images are optimized and cached
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
- Check Cloudflare Pages analytics for traffic and performance
|
||||||
|
- Monitor function invocations and errors in the dashboard
|
||||||
|
- Set up alerts for critical errors if needed
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Migration to Cloudflare Pages - Summary
|
||||||
|
|
||||||
|
## What Was Changed
|
||||||
|
|
||||||
|
### 1. Architecture Migration
|
||||||
|
- **From**: Express.js server with session-based authentication
|
||||||
|
- **To**: Static React app with Cloudflare Functions and JWT authentication
|
||||||
|
|
||||||
|
### 2. Backend Changes
|
||||||
|
- **API Routes**: Converted from Express routes to Cloudflare Functions
|
||||||
|
- `/api/classes` → `functions/api/classes.ts`
|
||||||
|
- `/api/newsletter` → `functions/api/newsletter.ts`
|
||||||
|
- `/api/contact` → `functions/api/contact.ts`
|
||||||
|
- `/api/auth/*` → `functions/api/auth.ts`
|
||||||
|
- `/api/bookings` → `functions/api/bookings.ts`
|
||||||
|
|
||||||
|
### 3. Authentication System
|
||||||
|
- **From**: Session-based authentication with Passport.js
|
||||||
|
- **To**: JWT token-based authentication
|
||||||
|
- **Changes**:
|
||||||
|
- Removed session middleware
|
||||||
|
- Implemented JWT signing/verification
|
||||||
|
- Updated client-side auth to use localStorage for token storage
|
||||||
|
- Modified API requests to include Authorization headers
|
||||||
|
|
||||||
|
### 4. Data Storage
|
||||||
|
- **From**: In-memory storage (lost on restart)
|
||||||
|
- **To**: Cloudflare KV storage (persistent)
|
||||||
|
- **Benefits**: Data persists across deployments and function invocations
|
||||||
|
|
||||||
|
### 5. Build Configuration
|
||||||
|
- **Frontend**: Remains Vite-based React app
|
||||||
|
- **Backend**: Now uses Cloudflare Functions instead of Express server
|
||||||
|
- **Deployment**: Single command deployment to Cloudflare Pages
|
||||||
|
|
||||||
|
## New File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── functions/ # Cloudflare Functions
|
||||||
|
│ ├── _middleware.ts # CORS middleware
|
||||||
|
│ └── api/
|
||||||
|
│ ├── auth.ts # Authentication endpoints
|
||||||
|
│ ├── bookings.ts # Booking management
|
||||||
|
│ ├── classes.ts # Class data
|
||||||
|
│ ├── contact.ts # Contact form
|
||||||
|
│ └── newsletter.ts # Newsletter signup
|
||||||
|
├── client/ # React frontend (unchanged)
|
||||||
|
├── wrangler.toml # Cloudflare configuration
|
||||||
|
├── DEPLOYMENT.md # Deployment instructions
|
||||||
|
└── test-deployment.js # Deployment testing script
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Benefits
|
||||||
|
|
||||||
|
### 1. Performance
|
||||||
|
- **Global CDN**: Static assets served from Cloudflare's global network
|
||||||
|
- **Edge Computing**: API functions run at the edge for low latency
|
||||||
|
- **Automatic Scaling**: Functions scale automatically with traffic
|
||||||
|
|
||||||
|
### 2. Cost
|
||||||
|
- **Free Tier**: Generous free tier for most use cases
|
||||||
|
- **No Server Management**: No need to manage servers or infrastructure
|
||||||
|
- **Pay-per-use**: Only pay for what you use
|
||||||
|
|
||||||
|
### 3. Reliability
|
||||||
|
- **99.9% Uptime**: Cloudflare's global infrastructure
|
||||||
|
- **Automatic Failover**: Built-in redundancy and failover
|
||||||
|
- **DDoS Protection**: Included DDoS protection
|
||||||
|
|
||||||
|
### 4. Developer Experience
|
||||||
|
- **Simple Deployment**: Single command deployment
|
||||||
|
- **Preview Deployments**: Automatic preview deployments for PRs
|
||||||
|
- **Built-in Analytics**: Traffic and performance analytics
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
- [x] Convert Express API routes to Cloudflare Functions
|
||||||
|
- [x] Replace session auth with JWT tokens
|
||||||
|
- [x] Update client-side authentication
|
||||||
|
- [x] Migrate from in-memory to KV storage
|
||||||
|
- [x] Update build configuration
|
||||||
|
- [x] Create deployment configuration
|
||||||
|
- [x] Add CORS middleware
|
||||||
|
- [x] Create deployment documentation
|
||||||
|
- [x] Add testing script
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Set up Cloudflare account** and install Wrangler CLI
|
||||||
|
2. **Create KV namespaces** for data storage
|
||||||
|
3. **Configure environment variables** in Cloudflare dashboard
|
||||||
|
4. **Deploy the application** using `npm run deploy`
|
||||||
|
5. **Test the deployment** using `npm run test:deployment`
|
||||||
|
6. **Configure custom domain** (optional)
|
||||||
|
|
||||||
|
## Environment Variables Required
|
||||||
|
|
||||||
|
- `JWT_SECRET`: Secure random string for JWT signing
|
||||||
|
- `MAILCHIMP_API_KEY`: Your Mailchimp API key
|
||||||
|
- `MAILCHIMP_SERVER_PREFIX`: Your Mailchimp server prefix
|
||||||
|
- `MAILCHIMP_LIST_ID`: Your Mailchimp list ID
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints maintain the same interface as before:
|
||||||
|
|
||||||
|
- `GET /api/classes` - Get all classes
|
||||||
|
- `GET /api/classes/:id` - Get specific class
|
||||||
|
- `POST /api/newsletter` - Subscribe to newsletter
|
||||||
|
- `POST /api/contact` - Send contact message
|
||||||
|
- `POST /api/auth/register` - User registration
|
||||||
|
- `POST /api/auth/login` - User login
|
||||||
|
- `GET /api/auth/me` - Get current user (requires JWT)
|
||||||
|
- `GET /api/bookings` - Get user bookings (requires JWT)
|
||||||
|
- `POST /api/bookings` - Create booking (requires JWT)
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
1. **Authentication**: Users will need to log in again after deployment
|
||||||
|
2. **Data**: All existing data (users, bookings, etc.) will be lost (this is expected for the migration)
|
||||||
|
3. **Sessions**: No more session-based authentication
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you encounter any issues during deployment:
|
||||||
|
|
||||||
|
1. Check the deployment logs in Cloudflare Pages dashboard
|
||||||
|
2. Verify environment variables are set correctly
|
||||||
|
3. Ensure KV namespaces are created and configured
|
||||||
|
4. Run the test script to verify functionality
|
||||||
|
5. Check the DEPLOYMENT.md file for detailed instructions
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
# Pilates with Fadia
|
||||||
|
|
||||||
|
A modern Pilates studio website built with React, TypeScript, and Express.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🧘♀️ Class information and details
|
||||||
|
- 📧 Contact form and newsletter signup
|
||||||
|
- 📱 Responsive design
|
||||||
|
- 🎨 Modern UI with Tailwind CSS
|
||||||
|
- 📸 Instagram feed integration (via Curator.io)
|
||||||
|
- 🔐 User authentication (in-memory storage)
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: React, TypeScript, Vite, Tailwind CSS
|
||||||
|
- **Backend**: Express.js, Node.js
|
||||||
|
- **Storage**: In-memory storage (no database required)
|
||||||
|
- **Deployment**: Vercel
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd pwf-website-new
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Set up environment variables**
|
||||||
|
|
||||||
|
**For Local Development:**
|
||||||
|
|
||||||
|
Create a `.env` file in the root directory:
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Then edit `.env` with your actual values:
|
||||||
|
```env
|
||||||
|
NODE_ENV=development
|
||||||
|
SESSION_SECRET=your_secure_session_secret
|
||||||
|
MAILCHIMP_API_KEY=your_mailchimp_key
|
||||||
|
MAILCHIMP_SERVER_PREFIX=your_mailchimp_server_prefix
|
||||||
|
MAILCHIMP_LIST_ID=your_mailchimp_list_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Getting the values:**
|
||||||
|
|
||||||
|
- **SESSION_SECRET**: Generate a random string (e.g., `openssl rand -base64 32`)
|
||||||
|
- **MAILCHIMP_API_KEY**: Get from Mailchimp Account → Extras → API Keys
|
||||||
|
- **MAILCHIMP_SERVER_PREFIX**: The part after the dash in your API key (e.g., if key is `abc123-us1`, server prefix is `us1`)
|
||||||
|
- **MAILCHIMP_LIST_ID**: Get from Mailchimp Audience → Settings → Audience name and defaults
|
||||||
|
|
||||||
|
4. **Run the development server**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at `http://localhost:5000`
|
||||||
|
|
||||||
|
## Deployment to Vercel
|
||||||
|
|
||||||
|
### Option 1: Automatic Deployment (Recommended)
|
||||||
|
|
||||||
|
1. **Connect your GitHub repository to Vercel**
|
||||||
|
- Go to [vercel.com](https://vercel.com)
|
||||||
|
- Sign up/login with GitHub
|
||||||
|
- Click "New Project"
|
||||||
|
- Import your repository
|
||||||
|
|
||||||
|
2. **Configure environment variables in Vercel**
|
||||||
|
- Go to your project settings in Vercel
|
||||||
|
- Navigate to **Settings** → **Environment Variables**
|
||||||
|
- Add each variable:
|
||||||
|
```
|
||||||
|
NODE_ENV = production
|
||||||
|
SESSION_SECRET = your_secure_session_secret
|
||||||
|
MAILCHIMP_API_KEY = your_mailchimp_key
|
||||||
|
MAILCHIMP_SERVER_PREFIX = your_mailchimp_server_prefix
|
||||||
|
MAILCHIMP_LIST_ID = your_mailchimp_list_id
|
||||||
|
```
|
||||||
|
- Select **Production**, **Preview**, and **Development** environments
|
||||||
|
- Click **Save**
|
||||||
|
|
||||||
|
3. **Deploy**
|
||||||
|
- Vercel will automatically deploy on every push to main branch
|
||||||
|
|
||||||
|
### Option 2: Manual Deployment with GitHub Actions
|
||||||
|
|
||||||
|
1. **Get Vercel tokens**
|
||||||
|
- Install Vercel CLI: `npm i -g vercel`
|
||||||
|
- Run `vercel login`
|
||||||
|
- Get your tokens from Vercel dashboard
|
||||||
|
|
||||||
|
2. **Add GitHub secrets**
|
||||||
|
- Go to your GitHub repository settings
|
||||||
|
- Add these secrets:
|
||||||
|
- `VERCEL_TOKEN`: Your Vercel token
|
||||||
|
- `ORG_ID`: Your Vercel organization ID
|
||||||
|
- `PROJECT_ID`: Your Vercel project ID
|
||||||
|
|
||||||
|
3. **Push to main branch**
|
||||||
|
- The GitHub Action will automatically deploy to Vercel
|
||||||
|
|
||||||
|
### Environment Variables for Production
|
||||||
|
|
||||||
|
Make sure to set these in your Vercel project settings:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NODE_ENV=production
|
||||||
|
SESSION_SECRET=your_secure_session_secret
|
||||||
|
MAILCHIMP_API_KEY=your_mailchimp_key
|
||||||
|
MAILCHIMP_SERVER_PREFIX=your_mailchimp_server_prefix
|
||||||
|
MAILCHIMP_LIST_ID=your_mailchimp_list_id
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── client/ # React frontend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # React components
|
||||||
|
│ │ ├── pages/ # Page components
|
||||||
|
│ │ ├── hooks/ # Custom hooks
|
||||||
|
│ │ └── lib/ # Utilities and config
|
||||||
|
│ └── dist/ # Built frontend (generated)
|
||||||
|
├── server/ # Express backend
|
||||||
|
│ ├── routes.ts # API routes
|
||||||
|
│ ├── auth.ts # Authentication
|
||||||
|
│ ├── storage.ts # In-memory storage
|
||||||
|
│ └── index.ts # Server entry point
|
||||||
|
├── vercel.json # Vercel configuration
|
||||||
|
├── env.example # Environment variables template
|
||||||
|
└── package.json # Dependencies and scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/classes` - Get all classes
|
||||||
|
- `POST /api/newsletter` - Subscribe to newsletter
|
||||||
|
- `POST /api/contact` - Send contact form
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import express from 'express';
|
||||||
|
import { storage } from '../server/storage';
|
||||||
|
import { setupAuth } from '../server/auth';
|
||||||
|
import { subscribeToMailchimp } from '../server/mailchimp';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
|
||||||
|
// Add logging middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const path = req.path;
|
||||||
|
let capturedJsonResponse: any;
|
||||||
|
|
||||||
|
const originalResJson = res.json;
|
||||||
|
res.json = function(bodyJson: any, ...args: any[]) {
|
||||||
|
capturedJsonResponse = bodyJson;
|
||||||
|
return originalResJson.apply(res, [bodyJson, ...args]);
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
if (path.startsWith('/api')) {
|
||||||
|
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
|
||||||
|
if (capturedJsonResponse) {
|
||||||
|
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
|
||||||
|
}
|
||||||
|
if (logLine.length > 80) {
|
||||||
|
logLine = logLine.slice(0, 79) + '…';
|
||||||
|
}
|
||||||
|
console.log(logLine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up authentication routes
|
||||||
|
setupAuth(app);
|
||||||
|
|
||||||
|
// Simple validation schemas
|
||||||
|
const newsletterSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
agreedToTerms: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactMessageSchema = z.object({
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
subject: z.string().min(2).max(200).optional(),
|
||||||
|
message: z.string().min(10).max(2000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingSchema = z.object({
|
||||||
|
classId: z.number(),
|
||||||
|
date: z.string(),
|
||||||
|
paid: z.boolean().default(false),
|
||||||
|
status: z.string().default("pending")
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
// ----------
|
||||||
|
|
||||||
|
// Classes
|
||||||
|
app.get("/api/classes", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const classes = await storage.getClasses();
|
||||||
|
res.json(classes);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: "Failed to fetch classes" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/classes/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({ message: "Invalid class ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const classData = await storage.getClass(id);
|
||||||
|
if (!classData) {
|
||||||
|
return res.status(404).json({ message: "Class not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(classData);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: "Failed to fetch class" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bookings
|
||||||
|
app.get("/api/bookings", async (req: any, res: any) => {
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = req.user as any;
|
||||||
|
const bookings = await storage.getBookings(user.id);
|
||||||
|
res.json(bookings);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: "Failed to fetch bookings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/bookings", async (req: any, res: any) => {
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = req.user as any;
|
||||||
|
const bookingData = bookingSchema.parse(req.body);
|
||||||
|
|
||||||
|
const booking = await storage.createBooking({
|
||||||
|
...bookingData,
|
||||||
|
userId: user.id,
|
||||||
|
date: new Date(bookingData.date)
|
||||||
|
});
|
||||||
|
res.status(201).json(booking);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ message: "Invalid booking data", errors: error.errors });
|
||||||
|
}
|
||||||
|
res.status(500).json({ message: "Failed to create booking" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Newsletter signup
|
||||||
|
app.post("/api/newsletter", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const newsletterData = newsletterSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Check if email already exists in our local database
|
||||||
|
const existingNewsletter = await storage.getNewsletterByEmail(newsletterData.email);
|
||||||
|
|
||||||
|
// Store in our local database
|
||||||
|
if (!existingNewsletter) {
|
||||||
|
await storage.createNewsletter(newsletterData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to Mailchimp
|
||||||
|
try {
|
||||||
|
const mailchimpResponse = await subscribeToMailchimp(newsletterData.email);
|
||||||
|
|
||||||
|
if (mailchimpResponse && mailchimpResponse.status === 'already_subscribed') {
|
||||||
|
return res.status(200).json({ message: mailchimpResponse.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ message: "Successfully subscribed to the newsletter" });
|
||||||
|
} catch (mailchimpError: any) {
|
||||||
|
console.error('Mailchimp error:', mailchimpError.message);
|
||||||
|
// Still return success if we stored in our database but Mailchimp failed
|
||||||
|
res.status(201).json({ message: "Successfully subscribed to the newsletter" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ message: "Invalid newsletter data", errors: error.errors });
|
||||||
|
}
|
||||||
|
console.error('Newsletter subscription error:', error);
|
||||||
|
res.status(500).json({ message: "Failed to subscribe to newsletter" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contact form
|
||||||
|
app.post("/api/contact", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const contactData = contactMessageSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Store in database
|
||||||
|
const message = await storage.createContactMessage(contactData);
|
||||||
|
|
||||||
|
// Log the contact request
|
||||||
|
console.log(`Contact form submission from ${contactData.name} (${contactData.email})`);
|
||||||
|
console.log(`Subject: ${contactData.subject || "No subject"}`);
|
||||||
|
console.log(`Message: ${contactData.message}`);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Message sent successfully",
|
||||||
|
info: "Your message has been received and will be reviewed shortly."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ message: "Invalid contact data", errors: error.errors });
|
||||||
|
}
|
||||||
|
res.status(500).json({ message: "Failed to send message" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
const status = err.status || err.statusCode || 500;
|
||||||
|
const message = err.message || 'Internal Server Error';
|
||||||
|
res.status(status).json({ message });
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export the Express app for Vercel
|
||||||
|
export default app;
|
||||||
|
Before Width: | Height: | Size: 8.3 MiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 230 KiB |
|
|
@ -1,152 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Privacy Policy - Pilates with Fadia</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
padding: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #2c5282;
|
|
||||||
border-bottom: 3px solid #4299e1;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
color: #2d3748;
|
|
||||||
margin-top: 30px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
.last-updated {
|
|
||||||
background: #e2e8f0;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.contact-info {
|
|
||||||
background: #f7fafc;
|
|
||||||
padding: 15px;
|
|
||||||
border-left: 4px solid #4299e1;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Privacy Policy</h1>
|
|
||||||
|
|
||||||
<div class="last-updated">
|
|
||||||
<strong>Last Updated:</strong> [INSERT DATE]
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>At Pilates with Fadia ("we," "our," or "us"), we are committed to protecting your privacy and personal information. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you visit our website pilateswithfadia.com and use our services.</p>
|
|
||||||
|
|
||||||
<h2>1. Information We Collect</h2>
|
|
||||||
|
|
||||||
<h3>Personal Information</h3>
|
|
||||||
<p>We may collect personal information that you voluntarily provide to us, including:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Name and contact information (email address, phone number, mailing address)</li>
|
|
||||||
<li>Account credentials (username and password)</li>
|
|
||||||
<li>Payment information (processed securely through third-party payment processors)</li>
|
|
||||||
<li>Health and fitness information relevant to your Pilates practice</li>
|
|
||||||
<li>Class preferences and booking history</li>
|
|
||||||
<li>Communication preferences</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Automatically Collected Information</h3>
|
|
||||||
<p>When you visit our website, we may automatically collect:</p>
|
|
||||||
<ul>
|
|
||||||
<li>IP address and location data</li>
|
|
||||||
<li>Browser type and version</li>
|
|
||||||
<li>Device information</li>
|
|
||||||
<li>Pages visited and time spent on our site</li>
|
|
||||||
<li>Referring website information</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>2. How We Use Your Information</h2>
|
|
||||||
<p>We use your information to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Provide and improve our Pilates services and classes</li>
|
|
||||||
<li>Process bookings and payments</li>
|
|
||||||
<li>Send class schedules, updates, and important notifications</li>
|
|
||||||
<li>Respond to your inquiries and provide customer support</li>
|
|
||||||
<li>Personalize your experience and class recommendations</li>
|
|
||||||
<li>Send marketing communications (with your consent)</li>
|
|
||||||
<li>Ensure the safety and security of our services</li>
|
|
||||||
<li>Comply with legal obligations</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>3. Information Sharing and Disclosure</h2>
|
|
||||||
<p>We do not sell, trade, or rent your personal information to third parties. We may share your information in the following circumstances:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Service Providers:</strong> With trusted third-party service providers who assist us in operating our website and providing services</li>
|
|
||||||
<li><strong>Legal Requirements:</strong> When required by law or to protect our rights and safety</li>
|
|
||||||
<li><strong>Business Transfers:</strong> In connection with a merger, acquisition, or sale of assets</li>
|
|
||||||
<li><strong>Consent:</strong> With your explicit consent for specific purposes</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>4. Data Security</h2>
|
|
||||||
<p>We implement appropriate technical and organizational security measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the internet is 100% secure.</p>
|
|
||||||
|
|
||||||
<h2>5. Your Rights and Choices</h2>
|
|
||||||
<p>You have the right to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Access, update, or delete your personal information</li>
|
|
||||||
<li>Opt-out of marketing communications</li>
|
|
||||||
<li>Request data portability</li>
|
|
||||||
<li>Object to certain data processing activities</li>
|
|
||||||
<li>Withdraw consent at any time</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>6. Cookies and Tracking Technologies</h2>
|
|
||||||
<p>We use cookies and similar tracking technologies to enhance your browsing experience. Please refer to our Cookie Policy for detailed information about our use of cookies.</p>
|
|
||||||
|
|
||||||
<h2>7. Third-Party Links</h2>
|
|
||||||
<p>Our website may contain links to third-party websites. We are not responsible for the privacy practices of these external sites. We encourage you to review their privacy policies.</p>
|
|
||||||
|
|
||||||
<h2>8. Children's Privacy</h2>
|
|
||||||
<p>Our services are not intended for children under 13. We do not knowingly collect personal information from children under 13. If you are a parent or guardian and believe your child has provided us with personal information, please contact us.</p>
|
|
||||||
|
|
||||||
<h2>9. International Data Transfers</h2>
|
|
||||||
<p>Your information may be transferred to and processed in countries other than your own. We ensure appropriate safeguards are in place for such transfers.</p>
|
|
||||||
|
|
||||||
<h2>10. Data Retention</h2>
|
|
||||||
<p>We retain your personal information only as long as necessary to fulfill the purposes outlined in this Privacy Policy or as required by law.</p>
|
|
||||||
|
|
||||||
<h2>11. Changes to This Privacy Policy</h2>
|
|
||||||
<p>We may update this Privacy Policy from time to time. We will notify you of any material changes by posting the new policy on our website and updating the "Last Updated" date.</p>
|
|
||||||
|
|
||||||
<h2>12. Contact Us</h2>
|
|
||||||
<div class="contact-info">
|
|
||||||
<p>If you have any questions about this Privacy Policy or our privacy practices, please contact us:</p>
|
|
||||||
<p><strong>Pilates with Fadia</strong><br>
|
|
||||||
Email: [INSERT EMAIL]<br>
|
|
||||||
Phone: [INSERT PHONE]<br>
|
|
||||||
Address: [INSERT ADDRESS]<br>
|
|
||||||
Website: pilateswithfadia.com</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Terms of Service - Pilates with Fadia</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
padding: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #2c5282;
|
|
||||||
border-bottom: 3px solid #4299e1;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
color: #2d3748;
|
|
||||||
margin-top: 30px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
.last-updated {
|
|
||||||
background: #e2e8f0;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.contact-info {
|
|
||||||
background: #f7fafc;
|
|
||||||
padding: 15px;
|
|
||||||
border-left: 4px solid #4299e1;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.warning {
|
|
||||||
background: #fed7d7;
|
|
||||||
border: 1px solid #fc8181;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
strong {
|
|
||||||
color: #2d3748;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Terms of Service</h1>
|
|
||||||
|
|
||||||
<div class="last-updated">
|
|
||||||
<strong>Last Updated:</strong> [INSERT DATE]
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Welcome to Pilates with Fadia. These Terms of Service ("Terms") govern your use of our website pilateswithfadia.com and our services. By accessing or using our services, you agree to be bound by these Terms.</p>
|
|
||||||
|
|
||||||
<h2>1. Acceptance of Terms</h2>
|
|
||||||
<p>By accessing and using our website and services, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.</p>
|
|
||||||
|
|
||||||
<h2>2. Description of Services</h2>
|
|
||||||
<p>Pilates with Fadia provides:</p>
|
|
||||||
<ul>
|
|
||||||
<li>In-person and virtual Pilates classes</li>
|
|
||||||
<li>Private and group instruction</li>
|
|
||||||
<li>Wellness coaching and fitness guidance</li>
|
|
||||||
<li>Online class booking and scheduling</li>
|
|
||||||
<li>Educational content and resources</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>3. User Registration and Accounts</h2>
|
|
||||||
<p>To access certain services, you may be required to create an account. You agree to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Provide accurate, current, and complete information</li>
|
|
||||||
<li>Maintain and update your account information</li>
|
|
||||||
<li>Keep your login credentials secure and confidential</li>
|
|
||||||
<li>Notify us immediately of any unauthorized use of your account</li>
|
|
||||||
<li>Accept responsibility for all activities under your account</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>4. Health and Safety Considerations</h2>
|
|
||||||
<div class="warning">
|
|
||||||
<strong>Important Health Notice:</strong> Pilates and physical exercise involve inherent risks. By participating in our classes and services, you acknowledge and assume these risks.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Before participating in any classes, you agree to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Consult with a healthcare provider if you have any medical conditions</li>
|
|
||||||
<li>Inform us of any injuries, limitations, or health concerns</li>
|
|
||||||
<li>Listen to your body and exercise within your limits</li>
|
|
||||||
<li>Follow all safety instructions provided by instructors</li>
|
|
||||||
<li>Understand that you participate at your own risk</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>5. Class Policies</h2>
|
|
||||||
|
|
||||||
<h3>Booking and Cancellation</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Classes must be booked in advance through our booking system</li>
|
|
||||||
<li>Cancellations must be made at least [INSERT TIME] hours before class</li>
|
|
||||||
<li>Late cancellations may result in forfeiture of class credit</li>
|
|
||||||
<li>No-shows will be charged the full class fee</li>
|
|
||||||
<li>We reserve the right to cancel classes due to low enrollment or unforeseen circumstances</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Class Conduct</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Arrive on time for classes</li>
|
|
||||||
<li>Turn off or silence mobile devices</li>
|
|
||||||
<li>Respect other participants and instructors</li>
|
|
||||||
<li>Follow studio rules and guidelines</li>
|
|
||||||
<li>Maintain appropriate attire and hygiene</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>6. Payment Terms</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Payment is due at the time of booking unless otherwise arranged</li>
|
|
||||||
<li>We accept major credit cards and other approved payment methods</li>
|
|
||||||
<li>Package deals and memberships are non-refundable unless required by law</li>
|
|
||||||
<li>Prices are subject to change with reasonable notice</li>
|
|
||||||
<li>Outstanding balances may result in suspension of services</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>7. Refund and Cancellation Policy</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Refunds are considered on a case-by-case basis</li>
|
|
||||||
<li>Medical exemptions may qualify for partial refunds with documentation</li>
|
|
||||||
<li>Credits may be offered in lieu of refunds when appropriate</li>
|
|
||||||
<li>Membership cancellations require [INSERT NOTICE PERIOD] written notice</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>8. Intellectual Property Rights</h2>
|
|
||||||
<p>All content on our website and in our classes, including but not limited to text, graphics, logos, images, video content, and software, is the property of Pilates with Fadia and is protected by copyright and other intellectual property laws.</p>
|
|
||||||
|
|
||||||
<p>You may not:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Copy, distribute, or reproduce our content without permission</li>
|
|
||||||
<li>Record classes without explicit written consent</li>
|
|
||||||
<li>Use our materials for commercial purposes</li>
|
|
||||||
<li>Modify or create derivative works from our content</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>9. Privacy and Data Protection</h2>
|
|
||||||
<p>Your privacy is important to us. Please review our Privacy Policy, which governs how we collect, use, and protect your personal information.</p>
|
|
||||||
|
|
||||||
<h2>10. Limitation of Liability</h2>
|
|
||||||
<p>To the fullest extent permitted by law, Pilates with Fadia shall not be liable for any direct, indirect, incidental, special, consequential, or punitive damages arising out of your use of our services, even if we have been advised of the possibility of such damages.</p>
|
|
||||||
|
|
||||||
<h2>11. Indemnification</h2>
|
|
||||||
<p>You agree to indemnify and hold harmless Pilates with Fadia, its instructors, and affiliates from any claims, damages, or expenses arising from your use of our services or violation of these Terms.</p>
|
|
||||||
|
|
||||||
<h2>12. Prohibited Uses</h2>
|
|
||||||
<p>You may not use our services to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Violate any applicable laws or regulations</li>
|
|
||||||
<li>Harass, abuse, or harm other users or staff</li>
|
|
||||||
<li>Transmit malicious code or attempt to gain unauthorized access</li>
|
|
||||||
<li>Impersonate others or provide false information</li>
|
|
||||||
<li>Engage in any activity that interferes with our services</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>13. Termination</h2>
|
|
||||||
<p>We reserve the right to terminate or suspend your account and access to our services at our sole discretion, without prior notice, for conduct that we believe violates these Terms or is harmful to other users or our business.</p>
|
|
||||||
|
|
||||||
<h2>14. Modifications to Terms</h2>
|
|
||||||
<p>We reserve the right to modify these Terms at any time. We will notify users of any material changes by posting the updated Terms on our website. Your continued use of our services after such modifications constitutes acceptance of the updated Terms.</p>
|
|
||||||
|
|
||||||
<h2>15. Governing Law</h2>
|
|
||||||
<p>These Terms shall be governed by and construed in accordance with the laws of [INSERT JURISDICTION], without regard to its conflict of law principles.</p>
|
|
||||||
|
|
||||||
<h2>16. Dispute Resolution</h2>
|
|
||||||
<p>Any disputes arising under these Terms shall be resolved through binding arbitration in accordance with the rules of [INSERT ARBITRATION RULES], except where prohibited by law.</p>
|
|
||||||
|
|
||||||
<h2>17. Severability</h2>
|
|
||||||
<p>If any provision of these Terms is found to be unenforceable or invalid, the remaining provisions will continue to be valid and enforceable.</p>
|
|
||||||
|
|
||||||
<h2>18. Contact Information</h2>
|
|
||||||
<div class="contact-info">
|
|
||||||
<p>If you have any questions about these Terms of Service, please contact us:</p>
|
|
||||||
<p><strong>Pilates with Fadia</strong><br>
|
|
||||||
Email: [INSERT EMAIL]<br>
|
|
||||||
Phone: [INSERT PHONE]<br>
|
|
||||||
Address: [INSERT ADDRESS]<br>
|
|
||||||
Website: pilateswithfadia.com</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><em>By using our services, you acknowledge that you have read, understood, and agree to be bound by these Terms of Service.</em></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Cookie Policy - Pilates with Fadia</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
padding: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #2c5282;
|
|
||||||
border-bottom: 3px solid #4299e1;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
color: #2d3748;
|
|
||||||
margin-top: 30px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
color: #4a5568;
|
|
||||||
margin-top: 25px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.last-updated {
|
|
||||||
background: #e2e8f0;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.contact-info {
|
|
||||||
background: #f7fafc;
|
|
||||||
padding: 15px;
|
|
||||||
border-left: 4px solid #4299e1;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.cookie-type {
|
|
||||||
background: #f7fafc;
|
|
||||||
border: 1px solid #cbd5e0;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
.essential {
|
|
||||||
border-left: 4px solid #48bb78;
|
|
||||||
}
|
|
||||||
.functional {
|
|
||||||
border-left: 4px solid #4299e1;
|
|
||||||
}
|
|
||||||
.analytics {
|
|
||||||
border-left: 4px solid #ed8936;
|
|
||||||
}
|
|
||||||
.marketing {
|
|
||||||
border-left: 4px solid #9f7aea;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
th, td {
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background-color: #f7fafc;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.toggle-info {
|
|
||||||
background: #e6fffa;
|
|
||||||
border: 1px solid #81e6d9;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Cookie Policy</h1>
|
|
||||||
|
|
||||||
<div class="last-updated">
|
|
||||||
<strong>Last Updated:</strong> [INSERT DATE]
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>This Cookie Policy explains how Pilates with Fadia ("we," "our," or "us") uses cookies and similar tracking technologies on our website pilateswithfadia.com ("Site"). This policy should be read alongside our Privacy Policy.</p>
|
|
||||||
|
|
||||||
<h2>1. What Are Cookies?</h2>
|
|
||||||
<p>Cookies are small text files that are placed on your device (computer, smartphone, tablet) when you visit a website. They are widely used to make websites work more efficiently and to provide information to website owners about how users interact with their sites.</p>
|
|
||||||
|
|
||||||
<h2>2. How We Use Cookies</h2>
|
|
||||||
<p>We use cookies to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Remember your preferences and settings</li>
|
|
||||||
<li>Keep you logged into your account</li>
|
|
||||||
<li>Analyze how our website is used and improve its performance</li>
|
|
||||||
<li>Provide personalized content and recommendations</li>
|
|
||||||
<li>Enable social media features</li>
|
|
||||||
<li>Measure the effectiveness of our marketing campaigns</li>
|
|
||||||
<li>Ensure the security of our website</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>3. Types of Cookies We Use</h2>
|
|
||||||
|
|
||||||
<div class="cookie-type essential">
|
|
||||||
<h3>Essential Cookies</h3>
|
|
||||||
<p><strong>Purpose:</strong> These cookies are necessary for the website to function properly. They enable core functionality such as security, network management, and accessibility.</p>
|
|
||||||
<p><strong>Examples:</strong> Login authentication, shopping cart functionality, security features</p>
|
|
||||||
<p><strong>Can be disabled:</strong> No - the website cannot function without these cookies</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cookie-type functional">
|
|
||||||
<h3>Functional Cookies</h3>
|
|
||||||
<p><strong>Purpose:</strong> These cookies enable the website to provide enhanced functionality and personalization, such as remembering your preferences.</p>
|
|
||||||
<p><strong>Examples:</strong> Language preferences, region selection, accessibility settings</p>
|
|
||||||
<p><strong>Can be disabled:</strong> Yes - but may affect website functionality</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cookie-type analytics">
|
|
||||||
<h3>Analytics Cookies</h3>
|
|
||||||
<p><strong>Purpose:</strong> These cookies help us understand how visitors interact with our website by collecting and reporting information anonymously.</p>
|
|
||||||
<p><strong>Examples:</strong> Google Analytics, page visit tracking, user behavior analysis</p>
|
|
||||||
<p><strong>Can be disabled:</strong> Yes</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cookie-type marketing">
|
|
||||||
<h3>Marketing Cookies</h3>
|
|
||||||
<p><strong>Purpose:</strong> These cookies are used to track visitors across websites to display relevant and engaging advertisements.</p>
|
|
||||||
<p><strong>Examples:</strong> Social media pixels, advertising network cookies, retargeting cookies</p>
|
|
||||||
<p><strong>Can be disabled:</strong> Yes</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>4. Specific Cookies We Use</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Cookie Name</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Purpose</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>session_id</td>
|
|
||||||
<td>Essential</td>
|
|
||||||
<td>Maintains user session and login state</td>
|
|
||||||
<td>Session</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>user_preferences</td>
|
|
||||||
<td>Functional</td>
|
|
||||||
<td>Stores user preferences and settings</td>
|
|
||||||
<td>1 year</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>_ga</td>
|
|
||||||
<td>Analytics</td>
|
|
||||||
<td>Google Analytics - distinguishes unique users</td>
|
|
||||||
<td>2 years</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>_gat</td>
|
|
||||||
<td>Analytics</td>
|
|
||||||
<td>Google Analytics - throttles request rate</td>
|
|
||||||
<td>1 minute</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>_gid</td>
|
|
||||||
<td>Analytics</td>
|
|
||||||
<td>Google Analytics - distinguishes unique users</td>
|
|
||||||
<td>24 hours</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>fb_pixel</td>
|
|
||||||
<td>Marketing</td>
|
|
||||||
<td>Facebook advertising and retargeting</td>
|
|
||||||
<td>3 months</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>5. Third-Party Cookies</h2>
|
|
||||||
<p>We may also use third-party services that set their own cookies on our website, including:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Google Analytics:</strong> For website analytics and performance monitoring</li>
|
|
||||||
<li><strong>Social Media Platforms:</strong> For social sharing and engagement features</li>
|
|
||||||
<li><strong>Payment Processors:</strong> For secure payment processing</li>
|
|
||||||
<li><strong>Booking Systems:</strong> For class scheduling and appointment booking</li>
|
|
||||||
<li><strong>Live Chat Services:</strong> For customer support features</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>6. Cookie Duration</h2>
|
|
||||||
<p>Cookies may be either:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Session Cookies:</strong> Temporary cookies that are deleted when you close your browser</li>
|
|
||||||
<li><strong>Persistent Cookies:</strong> Cookies that remain on your device for a set period or until you delete them</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>7. Managing Your Cookie Preferences</h2>
|
|
||||||
|
|
||||||
<div class="toggle-info">
|
|
||||||
<h3>Browser Settings</h3>
|
|
||||||
<p>You can control and manage cookies through your browser settings. Most browsers allow you to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>View what cookies have been set and delete them individually</li>
|
|
||||||
<li>Block third-party cookies</li>
|
|
||||||
<li>Block cookies from particular sites</li>
|
|
||||||
<li>Block all cookies from being set</li>
|
|
||||||
<li>Delete all cookies when you close your browser</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Browser-Specific Instructions:</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Chrome:</strong> Settings > Privacy and Security > Cookies and other site data</li>
|
|
||||||
<li><strong>Firefox:</strong> Settings > Privacy & Security > Cookies and Site Data</li>
|
|
||||||
<li><strong>Safari:</strong> Preferences > Privacy > Manage Website Data</li>
|
|
||||||
<li><strong>Edge:</strong> Settings > Cookies and site permissions</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Opt-Out Tools</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Google Analytics:</strong> <a href="https://tools.google.com/dlpage/gaoptout" target="_blank">Google Analytics Opt-out Browser Add-on</a></li>
|
|
||||||
<li><strong>Network Advertising Initiative:</strong> <a href="http://www.networkadvertising.org/choices/" target="_blank">NAI Opt-out Tool</a></li>
|
|
||||||
<li><strong>Digital Advertising Alliance:</strong> <a href="http://www.aboutads.info/choices/" target="_blank">DAA WebChoices Tool</a></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>8. Mobile Device Settings</h2>
|
|
||||||
<p>For mobile devices, you can manage cookies and tracking through your device settings:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>iOS:</strong> Settings > Privacy > Tracking / Settings > Safari > Privacy & Security</li>
|
|
||||||
<li><strong>Android:</strong> Settings > Privacy > Advanced > Site Settings > Cookies</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>9. Impact of Disabling Cookies</h2>
|
|
||||||
<p>Please note that disabling certain cookies may impact your experience on our website:</p>
|
|
||||||
<ul>
|
|
||||||
<li>You may need to re-enter information more frequently</li>
|
|
||||||
<li>Some website features may not work properly</li>
|
|
||||||
<li>Personalized content and recommendations may not be available</li>
|
|
||||||
<li>You may still see advertisements, but they will be less relevant</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>10. Do Not Track Signals</h2>
|
|
||||||
<p>Some browsers include a "Do Not Track" feature that lets you tell websites you do not want to have your online activities tracked. Our website does not currently respond to Do Not Track browser signals or mechanisms.</p>
|
|
||||||
|
|
||||||
<h2>11. Updates to This Cookie Policy</h2>
|
|
||||||
<p>We may update this Cookie Policy from time to time to reflect changes in technology, legislation, or our business practices. We will notify you of any significant changes by posting the updated policy on our website.</p>
|
|
||||||
|
|
||||||
<h2>12. Contact Us</h2>
|
|
||||||
<div class="contact-info">
|
|
||||||
<p>If you have any questions about our use of cookies or this Cookie Policy, please contact us:</p>
|
|
||||||
<p><strong>Pilates with Fadia</strong><br>
|
|
||||||
Email: [INSERT EMAIL]<br>
|
|
||||||
Phone: [INSERT PHONE]<br>
|
|
||||||
Address: [INSERT ADDRESS]<br>
|
|
||||||
Website: pilateswithfadia.com</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><em>By continuing to use our website, you consent to our use of cookies in accordance with this Cookie Policy.</em></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 774 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
|
@ -2,21 +2,36 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<title>Pilates with Fadia | Feel at Home in Your Body</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Online pilates classes to help you feel stronger and more connected to your body and breath." />
|
<meta name="description" content="Pilates with Fadia - Transform your body and mind with professional Pilates instruction in a welcoming environment." />
|
||||||
<!-- Open Graph tags -->
|
<meta name="keywords" content="pilates, fitness, exercise, wellness, studio, classes, reformer, mat pilates" />
|
||||||
<meta property="og:title" content="Pilates with Fadia | Feel at Home in Your Body" />
|
<meta name="author" content="Fadia" />
|
||||||
<meta property="og:description" content="Online pilates classes to help you feel stronger and more connected to your body and breath." />
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://pilateswithfadia.com" />
|
<meta property="og:url" content="https://pilateswithfadia.com/" />
|
||||||
<!-- Icons -->
|
<meta property="og:title" content="Pilates with Fadia" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" />
|
<meta property="og:description" content="Transform your body and mind with professional Pilates instruction in a welcoming environment." />
|
||||||
|
<meta property="og:image" content="https://pilateswithfadia.com/og-image.jpg" />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:url" content="https://pilateswithfadia.com/" />
|
||||||
|
<meta property="twitter:title" content="Pilates with Fadia" />
|
||||||
|
<meta property="twitter:description" content="Transform your body and mind with professional Pilates instruction in a welcoming environment." />
|
||||||
|
<meta property="twitter:image" content="https://pilateswithfadia.com/og-image.jpg" />
|
||||||
|
|
||||||
|
<!-- Preconnect to external domains for performance -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="preconnect" href="https://www.google-analytics.com">
|
||||||
|
<link rel="preconnect" href="https://www.googletagmanager.com">
|
||||||
|
|
||||||
|
<title>Pilates with Fadia</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
|
|
||||||
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"name": "pilates-with-fadia-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.4",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.4",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.5",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.4",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.7",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.7",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.7",
|
||||||
|
"@radix-ui/react-label": "^2.1.3",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.7",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.6",
|
||||||
|
"@radix-ui/react-popover": "^1.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.3",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.4",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||||
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.3",
|
||||||
|
"@radix-ui/react-slider": "^1.2.4",
|
||||||
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
|
"@radix-ui/react-switch": "^1.1.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.4",
|
||||||
|
"@radix-ui/react-toast": "^1.2.7",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.3",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^11.13.1",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.453.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.55.0",
|
||||||
|
"react-icons": "^5.4.0",
|
||||||
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
"recharts": "^2.15.2",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.2.5",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"wouter": "^3.3.5",
|
||||||
|
"zod": "^3.24.2",
|
||||||
|
"zod-validation-error": "^3.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "5.6.3",
|
||||||
|
"vite": "^5.4.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React from "react";
|
||||||
import { Switch, Route } from "wouter";
|
import { Switch, Route } from "wouter";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import { Link } from "wouter";
|
|
||||||
import FadiaImage from "../../assets/Fadia-167-crop.jpg";
|
|
||||||
import FadiaBridgeImage from "@assets/fadia-bridge3_1749866400701.jpg";
|
|
||||||
import FadiaStretchImage from "@assets/fadia-stretch_1749866078708.jpg";
|
import FadiaStretchImage from "@assets/fadia-stretch_1749866078708.jpg";
|
||||||
|
|
||||||
export function AboutSection() {
|
export function AboutSection() {
|
||||||
|
|
@ -9,17 +6,6 @@ export function AboutSection() {
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex flex-col md:flex-row items-center">
|
<div className="flex flex-col md:flex-row items-center">
|
||||||
<div className="md:w-1/2 md:pr-12 mb-8 md:mb-0">
|
<div className="md:w-1/2 md:pr-12 mb-8 md:mb-0">
|
||||||
|
|
||||||
|
|
||||||
{/* Main center image */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<img
|
|
||||||
src={FadiaImage}
|
|
||||||
alt="Fadia smiling"
|
|
||||||
className="w-full h-auto rounded-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom image */}
|
{/* Bottom image */}
|
||||||
<div>
|
<div>
|
||||||
<img
|
<img
|
||||||
|
|
@ -32,7 +18,7 @@ export function AboutSection() {
|
||||||
|
|
||||||
<div className="md:w-1/2">
|
<div className="md:w-1/2">
|
||||||
<div className="prose max-w-none">
|
<div className="prose max-w-none">
|
||||||
<h3 className="text-2xl font-playfair font-semibold text-purple mb-4">My Pilates Story</h3>
|
<h3 className="text-3xl font-playfair font-semibold text-purple mb-4 text-center">My Pilates Story</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
I came to movement as a way to feel good in my body and begin healing emotions I hadn't yet processed. It started simply—with home yoga classes—and slowly turned into something much deeper.
|
I came to movement as a way to feel good in my body and begin healing emotions I hadn't yet processed. It started simply—with home yoga classes—and slowly turned into something much deeper.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
import React from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import FadiaImage from "@assets/Fadia-167-crop_1749865267638.jpg";
|
import FadiaImage from "@assets/Fadia-167-crop.jpg";
|
||||||
|
|
||||||
export function HomeAboutSection() {
|
export function HomeAboutSection() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -16,7 +17,7 @@ export function HomeAboutSection() {
|
||||||
|
|
||||||
<div className="md:w-1/2">
|
<div className="md:w-1/2">
|
||||||
<div className="prose max-w-none">
|
<div className="prose max-w-none">
|
||||||
<h3 className="text-2xl font-playfair font-semibold text-purple mb-4">Meet Fadia</h3>
|
<h3 className="text-3xl font-playfair font-semibold text-purple mb-6 text-center">Meet Fadia</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Fadia is a certified Pilates instructor, former lawyer, and community builder with a passion for helping people connect with their bodies through mindful movement.
|
Fadia is a certified Pilates instructor, former lawyer, and community builder with a passion for helping people connect with their bodies through mindful movement.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Class, insertBookingSchema } from "@shared/schema";
|
import { Class, insertBookingSchema } from "@/lib/schema";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { Class } from "@shared/schema";
|
import React from "react";
|
||||||
|
import { StaticClass } from "@/lib/static-data";
|
||||||
|
import { Link } from "wouter";
|
||||||
import FadiaGardenImage from "@assets/fadia-garden_1749836720986.jpg";
|
import FadiaGardenImage from "@assets/fadia-garden_1749836720986.jpg";
|
||||||
import PilatesClassImage from "@assets/pilates_class_1749837680834.jpeg";
|
import PilatesClassImage from "@assets/pilates_class_1749837680834.jpeg";
|
||||||
import FadiaPrivateImage from "@assets/Fadia-7_1749842141071.jpg";
|
import FadiaPrivateImage from "@assets/Fadia-7_1749842141071.jpg";
|
||||||
import FadiaBallImage from "@assets/fadia-ball_1749842241591.jpg";
|
import FadiaBallImage from "@assets/fadia-ball_1749842241591.jpg";
|
||||||
|
|
||||||
interface ClassCardProps {
|
interface ClassCardProps {
|
||||||
classData: Class;
|
classData: StaticClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClassCard({ classData }: ClassCardProps) {
|
export function ClassCard({ classData }: ClassCardProps) {
|
||||||
|
|
@ -46,24 +48,46 @@ export function ClassCard({ classData }: ClassCardProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get link based on class type
|
||||||
|
const getClassLink = () => {
|
||||||
|
switch (classData.classType) {
|
||||||
|
case "group": return "https://nuncenter.com/";
|
||||||
|
case "small-group": return "https://nuncenter.com/";
|
||||||
|
case "private": return "/contact";
|
||||||
|
case "online": return "https://www.momoyoga.com/pilates-with-fadia/schedule";
|
||||||
|
default: return "#";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if link should open in new tab
|
||||||
|
const shouldOpenNewTab = (classType: string) => {
|
||||||
|
return classType === "group" || classType === "small-group" || classType === "online";
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkProps = shouldOpenNewTab(classData.classType)
|
||||||
|
? { href: getClassLink(), target: "_blank", rel: "noopener noreferrer" }
|
||||||
|
: { href: getClassLink() };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:transform hover:scale-105">
|
<a {...linkProps} className="block">
|
||||||
<img
|
<div className="bg-white rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:transform hover:scale-105">
|
||||||
src={getClassImage()}
|
<img
|
||||||
alt={classData.name}
|
src={getClassImage()}
|
||||||
className="w-full h-50 object-cover"
|
alt={classData.name}
|
||||||
/>
|
className="w-full h-48 object-cover"
|
||||||
<div className="p-4">
|
/>
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="p-4">
|
||||||
<h3 className="font-playfair font-bold text-lg leading-tight">{classData.name}</h3>
|
<div className="flex justify-between items-start mb-2">
|
||||||
<span className={`${badgeColor()} text-xs px-2 py-1 rounded-full font-semibold ml-2 flex-shrink-0`}>
|
<h3 className="font-playfair font-bold text-lg leading-tight">{classData.name}</h3>
|
||||||
{formatClassType(classData.classType)}
|
<span className={`${badgeColor()} text-xs px-2 py-1 rounded-full font-semibold ml-2 flex-shrink-0`}>
|
||||||
</span>
|
{formatClassType(classData.classType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm leading-snug">
|
||||||
|
{classData.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 text-sm leading-snug">
|
|
||||||
{classData.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,8 @@
|
||||||
|
import React from "react";
|
||||||
import { ClassCard } from "./class-card";
|
import { ClassCard } from "./class-card";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { STATIC_CLASSES } from "@/lib/static-data";
|
||||||
import { Class } from "@shared/schema";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import FadiaClassImage from "../../assets/Fadia-156.jpg";
|
|
||||||
|
|
||||||
|
|
||||||
export function ClassesSection() {
|
export function ClassesSection() {
|
||||||
const { data: classes, isLoading, error } = useQuery<Class[]>({
|
|
||||||
queryKey: ["/api/classes"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20 bg-white">
|
<section className="py-20 bg-white">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
|
@ -20,39 +13,14 @@ export function ClassesSection() {
|
||||||
<p className="max-w-3xl mx-auto text-gray-600">Join personalized pilates classes where you'll discover strength, flexibility, and mindfulness.</p>
|
<p className="max-w-3xl mx-auto text-gray-600">Join personalized pilates classes where you'll discover strength, flexibility, and mindfulness.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Class description boxes removed */}
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{STATIC_CLASSES.map((classData) => (
|
||||||
{isLoading ? (
|
<ClassCard
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
key={classData.id}
|
||||||
{[1, 2, 3, 4].map((_, i) => (
|
classData={classData}
|
||||||
<div key={i} className="bg-white rounded-lg overflow-hidden shadow-lg">
|
/>
|
||||||
<Skeleton className="w-full h-36" />
|
))}
|
||||||
<div className="p-4">
|
</div>
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<Skeleton className="h-5 w-24" />
|
|
||||||
<Skeleton className="h-5 w-16" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-4 w-full mb-1" />
|
|
||||||
<Skeleton className="h-4 w-full mb-1" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-center text-red-500">
|
|
||||||
<p>Error loading classes. Please try again later.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{classes?.map((classData) => (
|
|
||||||
<ClassCard
|
|
||||||
key={classData.id}
|
|
||||||
classData={classData}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center mt-12">
|
<div className="text-center mt-12">
|
||||||
<a href="https://www.momoyoga.com/pilates-with-fadia/schedule" target="_blank" rel="noopener noreferrer" className="inline-block">
|
<a href="https://www.momoyoga.com/pilates-with-fadia/schedule" target="_blank" rel="noopener noreferrer" className="inline-block">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
interface TestimonialProps {
|
interface TestimonialProps {
|
||||||
quote: string;
|
quote: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|
@ -8,12 +10,14 @@ interface TestimonialProps {
|
||||||
|
|
||||||
export function Testimonial({ quote, author, memberSince, initials, color }: TestimonialProps) {
|
export function Testimonial({ quote, author, memberSince, initials, color }: TestimonialProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-transparent text-left">
|
<div className="bg-transparent text-left flex flex-col h-full">
|
||||||
<p className="text-gray-700 mb-6 italic">
|
<div className="flex-grow">
|
||||||
"{quote}"
|
<p className="text-gray-700 mb-6 italic">
|
||||||
</p>
|
"{quote}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center mt-auto">
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center mr-3 bg-${color} bg-opacity-20 text-${color}`}>
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center mr-3 bg-${color} bg-opacity-20 text-${color}`}>
|
||||||
<span>{initials}</span>
|
<span>{initials}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
return (
|
return (
|
||||||
<section className="pt-6 pb-12 bg-white">
|
<section className="pt-6 pb-12 bg-white">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Link } from "wouter";
|
||||||
|
|
||||||
export function CTASection() {
|
export function CTASection() {
|
||||||
return (
|
return (
|
||||||
<section className="py-16 pb-8 text-white bg-[#b550767d]" style={{ backgroundColor: '#B55076' }}>
|
<section className="py-16 pb-8 text-white bg-[#b5507680]">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
<h3 className="text-2xl md:text-3xl font-playfair font-semibold mb-8">
|
<h3 className="text-2xl md:text-3xl font-playfair font-semibold mb-8">
|
||||||
Ready to feel stronger, more connected, and at home in your body?
|
Ready to feel stronger, more connected, and at home in your body?
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface FeatureCardProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: string;
|
|
||||||
color: "teal" | "purple" | "rose";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeatureCard({ title, description, icon, color }: FeatureCardProps) {
|
|
||||||
const colorMap = {
|
|
||||||
teal: "#0c8991",
|
|
||||||
purple: "#9D5E9B",
|
|
||||||
rose: "#B55076"
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center p-8 transition duration-300 hover:shadow-md">
|
|
||||||
<i className={`fas ${icon} text-2xl`} style={{ color: colorMap[color] }}></i>
|
|
||||||
<h3 className="text-xl font-playfair font-semibold mt-4 mb-3">{title}</h3>
|
|
||||||
<p className="text-gray-600">{description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeaturesSection() {
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
title: "Balance",
|
|
||||||
description: "Find harmony between body and mind through mindful movement.",
|
|
||||||
icon: "fa-align-center",
|
|
||||||
color: "teal" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Strength",
|
|
||||||
description: "Build core power and muscular endurance through controlled exercises.",
|
|
||||||
icon: "fa-dumbbell",
|
|
||||||
color: "purple" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Flexibility",
|
|
||||||
description: "Enhance your range of motion and release tension throughout your body.",
|
|
||||||
icon: "fa-wind",
|
|
||||||
color: "rose" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="py-16 bg-purple">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h2 className="text-3xl font-playfair font-semibold text-white mb-4">
|
|
||||||
Core Benefits
|
|
||||||
</h2>
|
|
||||||
<p className="text-white text-opacity-90 max-w-2xl mx-auto">
|
|
||||||
Experience the transformative power of Pilates through these foundational principles
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 bg-white p-6">
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<FeatureCard key={index} {...feature} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import FadiaHeroImage from "../../assets/Fadia-15.jpg";
|
import FadiaHeroImage from "../../assets/Fadia-15.jpg";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
import { Logo } from "@/components/ui/logo";
|
import { Logo } from "@/components/ui/logo";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { useState } from "react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
@ -126,7 +126,9 @@ export default function Footer() {
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<i className="fas fa-map-marker-alt mt-1 mr-2 text-white"></i>
|
<i className="fas fa-map-marker-alt mt-1 mr-2 text-white"></i>
|
||||||
<span className="text-white text-opacity-70">Nun Center<br/>Zamalek, Cairo, Egypt</span>
|
<a href="https://nuncenter.com/" target="_blank" rel="noopener noreferrer" className="text-white text-opacity-70 hover:text-white hover:text-opacity-100 transition duration-300">
|
||||||
|
Nun Center<br/>Zamalek, Cairo, Egypt
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center">
|
<li className="flex items-center">
|
||||||
<i className="fas fa-envelope mr-2 text-white"></i>
|
<i className="fas fa-envelope mr-2 text-white"></i>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Logo } from "@/components/ui/logo";
|
import { Logo } from "@/components/ui/logo";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { insertNewsletterSchema } from "@shared/schema";
|
import { insertNewsletterSchema } from "@/lib/schema";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -168,7 +168,7 @@ export function NewsletterSection() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<form className="bg-white p-8 shadow-sm" onSubmit={handleSubmit}>
|
<form className="bg-white rounded-lg shadow-lg p-8" style={{ backgroundColor: '#fff' }} onSubmit={handleSubmit}>
|
||||||
<div className="flex flex-col mb-6">
|
<div className="flex flex-col mb-6">
|
||||||
<label htmlFor="email" className="mb-2 text-gray-700 font-medium">Email Address</label>
|
<label htmlFor="email" className="mb-2 text-gray-700 font-medium">Email Address</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useEffect } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface InstagramPost {
|
|
||||||
id: string;
|
|
||||||
media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
|
|
||||||
media_url: string;
|
|
||||||
permalink: string;
|
|
||||||
caption?: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PhotoGallery() {
|
export function PhotoGallery() {
|
||||||
const [showAllPosts, setShowAllPosts] = useState(false);
|
useEffect(() => {
|
||||||
|
// Load Curator.io script
|
||||||
const { data: posts, isLoading, error } = useQuery<InstagramPost[]>({
|
const script = document.createElement("script");
|
||||||
queryKey: ['/api/instagram-feed'],
|
script.type = "text/javascript";
|
||||||
staleTime: 1000 * 60 * 30, // Cache for 30 minutes
|
script.async = true;
|
||||||
});
|
script.charset = "UTF-8";
|
||||||
|
script.src = "https://cdn.curator.io/published/1964cded-8962-41c1-b7c1-d36f02707c7a.js";
|
||||||
const displayPosts = showAllPosts ? posts : posts?.slice(0, 6);
|
|
||||||
|
// Insert script before the closing body tag
|
||||||
|
document.body.appendChild(script);
|
||||||
|
|
||||||
|
// Cleanup function to remove script when component unmounts
|
||||||
|
return () => {
|
||||||
|
if (document.body.contains(script)) {
|
||||||
|
document.body.removeChild(script);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-12 bg-white">
|
<section className="py-12 bg-white">
|
||||||
|
|
@ -31,94 +31,10 @@ export function PhotoGallery() {
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-full max-w-4xl">
|
<div className="w-full max-w-4xl">
|
||||||
{isLoading && (
|
{/* Curator.io Instagram Feed */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div id="curator-feed-default-feed-layout">
|
||||||
{[1, 2, 3, 4, 5, 6].map((index) => (
|
<a href="https://curator.io" target="_blank" className="crt-logo crt-tag">Powered by Curator.io</a>
|
||||||
<div key={index} className="aspect-square bg-gray-200 rounded-lg animate-pulse"></div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-gray-50 rounded-lg p-8 text-center">
|
|
||||||
<div className="mb-6">
|
|
||||||
<i className="fab fa-instagram text-6xl text-pink-500 mb-4"></i>
|
|
||||||
<p className="text-gray-600 mb-6">Follow me on Instagram for daily inspiration, movement tips, and behind-the-scenes content from my classes.</p>
|
|
||||||
<a
|
|
||||||
href="https://www.instagram.com/pilateswithfadia/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold rounded-full hover:from-purple-600 hover:to-pink-600 transition duration-300"
|
|
||||||
>
|
|
||||||
<i className="fab fa-instagram mr-2"></i>
|
|
||||||
Visit Instagram
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{posts && posts.length > 0 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{displayPosts?.map((post: InstagramPost) => (
|
|
||||||
<a
|
|
||||||
key={post.id}
|
|
||||||
href={post.permalink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group block aspect-square bg-gray-100 rounded-lg overflow-hidden hover:shadow-lg transition-shadow duration-300"
|
|
||||||
>
|
|
||||||
{post.media_type === 'VIDEO' ? (
|
|
||||||
<div className="relative w-full h-full">
|
|
||||||
<video
|
|
||||||
src={post.media_url}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
playsInline
|
|
||||||
onMouseEnter={(e) => e.currentTarget.play()}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.pause()}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center">
|
|
||||||
<i className="fas fa-play text-white text-2xl opacity-0 group-hover:opacity-80 transition-opacity duration-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={post.media_url}
|
|
||||||
alt={post.caption ? post.caption.substring(0, 100) + '...' : 'Instagram post'}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{posts.length > 6 && (
|
|
||||||
<div className="text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAllPosts(!showAllPosts)}
|
|
||||||
className="px-6 py-3 bg-rose text-white font-semibold rounded-full hover:bg-opacity-90 transition duration-300"
|
|
||||||
>
|
|
||||||
{showAllPosts ? 'Show Less' : `View All ${posts.length} Posts`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center mt-8">
|
|
||||||
<a
|
|
||||||
href="https://www.instagram.com/pilateswithfadia/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold rounded-full hover:from-purple-600 hover:to-pink-600 transition duration-300"
|
|
||||||
>
|
|
||||||
<i className="fab fa-instagram mr-2"></i>
|
|
||||||
Follow on Instagram
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React from "react";
|
||||||
import { Testimonial } from "@/components/community/testimonial";
|
import { Testimonial } from "@/components/community/testimonial";
|
||||||
|
|
||||||
export function TestimonialsSection() {
|
export function TestimonialsSection() {
|
||||||
|
|
@ -10,14 +11,14 @@ export function TestimonialsSection() {
|
||||||
color: "teal",
|
color: "teal",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
quote: "I've seen incredible improvements in my posture and core strength since joining Fadia's classes. She truly understands how to help each individual.",
|
quote: "Fadia's calm and soothing cueing had me go through the flow steadily, challenging my muscles without even realizing and that felt great!",
|
||||||
author: "Ahmed M.",
|
author: "Sara from Cairo",
|
||||||
memberSince: "",
|
memberSince: "",
|
||||||
initials: "AM",
|
initials: "SC",
|
||||||
color: "purple",
|
color: "purple",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
quote: "Fadia's classes are unique and challenging. Having had many pilates trainers throughout the years, I can say that Fadia is the only teacher that involved everyone's needs into one routine and focuses on motivating and engaging everyone like no other. Every time, my body felt renewed and better, even if it was online!",
|
quote: "Having had many pilates trainers throughout the years, I can say that Fadia is the only teacher that involved everyone's needs into one routine and focuses on motivating and engaging everyone like no other. Every time, my body felt renewed and better, even if it was online!",
|
||||||
author: "Leyla from Colombia",
|
author: "Leyla from Colombia",
|
||||||
memberSince: "",
|
memberSince: "",
|
||||||
initials: "LC",
|
initials: "LC",
|
||||||
|
|
@ -31,7 +32,7 @@ export function TestimonialsSection() {
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{testimonials.map((testimonial, index) => (
|
{testimonials.map((testimonial, index) => (
|
||||||
<div className="rounded-lg p-6 shadow-sm transition-transform duration-300 hover:scale-105 bg-[#92bbc45c]" key={index}>
|
<div className="rounded-lg p-6 shadow-sm transition-transform duration-300 hover:scale-105 bg-[#92bbc45c] flex flex-col h-full" key={index}>
|
||||||
<Testimonial
|
<Testimonial
|
||||||
quote={testimonial.quote}
|
quote={testimonial.quote}
|
||||||
author={testimonial.author}
|
author={testimonial.author}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import LogoImage from "../../assets/rectangular-logo.png";
|
import LogoImage from "../../assets/rectangular-logo.png";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React from "react";
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import {
|
import {
|
||||||
Toast,
|
Toast,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,22 @@
|
||||||
import { createContext, ReactNode, useContext } from "react";
|
import React, { createContext, ReactNode, useContext, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
useQuery,
|
useQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
UseMutationResult,
|
UseMutationResult,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { insertUserSchema, User as SelectUser, InsertUser, Login } from "@shared/schema";
|
import { apiRequest, getQueryFn, queryClient } from "@/lib/queryClient";
|
||||||
import { getQueryFn, apiRequest, queryClient } from "../lib/queryClient";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { insertUserSchema, User, InsertUser, Login } from "@/lib/schema";
|
||||||
type User = Omit<SelectUser, "password">;
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
type AuthContextType = {
|
type AuthContextType = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
loginMutation: UseMutationResult<User, Error, Login>;
|
loginMutation: UseMutationResult<{ user: User; token: string }, Error, Login>;
|
||||||
logoutMutation: UseMutationResult<void, Error, void>;
|
logoutMutation: UseMutationResult<void, Error, void>;
|
||||||
registerMutation: UseMutationResult<User, Error, InsertUser>;
|
registerMutation: UseMutationResult<{ user: User; token: string }, Error, InsertUser>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registrationSchema = insertUserSchema.extend({
|
export const registrationSchema = insertUserSchema.extend({
|
||||||
|
|
@ -34,25 +33,37 @@ export const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [token, setToken] = useState<string | null>(localStorage.getItem('auth_token'));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: user,
|
data: user,
|
||||||
error,
|
error,
|
||||||
isLoading,
|
isLoading,
|
||||||
} = useQuery<User | undefined, Error>({
|
} = useQuery<User | undefined, Error>({
|
||||||
queryKey: ["/api/user"],
|
queryKey: ["/api/auth/me"],
|
||||||
queryFn: getQueryFn({ on401: "returnNull" }),
|
queryFn: async () => {
|
||||||
|
if (!token) return null;
|
||||||
|
const res = await apiRequest("GET", "/api/auth/me", undefined, token);
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
enabled: !!token,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: async (credentials: Login) => {
|
mutationFn: async (credentials: Login) => {
|
||||||
const res = await apiRequest("POST", "/api/login", credentials);
|
const res = await apiRequest("POST", "/api/auth/login", credentials);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
},
|
},
|
||||||
onSuccess: (user: User) => {
|
onSuccess: (data: { user: User; token: string }) => {
|
||||||
queryClient.setQueryData(["/api/user"], user);
|
setToken(data.token);
|
||||||
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
queryClient.setQueryData(["/api/auth/me"], data.user);
|
||||||
toast({
|
toast({
|
||||||
title: "Login successful",
|
title: "Login successful",
|
||||||
description: `Welcome back, ${user.fullName || user.username}!`,
|
description: `Welcome back, ${data.user.fullName || data.user.username}!`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|
@ -66,11 +77,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
const registerMutation = useMutation({
|
||||||
mutationFn: async (data: InsertUser) => {
|
mutationFn: async (data: InsertUser) => {
|
||||||
const res = await apiRequest("POST", "/api/register", data);
|
const res = await apiRequest("POST", "/api/auth/register", data);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
},
|
},
|
||||||
onSuccess: (user: User) => {
|
onSuccess: (data: { user: User; token: string }) => {
|
||||||
queryClient.setQueryData(["/api/user"], user);
|
setToken(data.token);
|
||||||
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
queryClient.setQueryData(["/api/auth/me"], data.user);
|
||||||
toast({
|
toast({
|
||||||
title: "Registration successful",
|
title: "Registration successful",
|
||||||
description: "Your account has been created successfully.",
|
description: "Your account has been created successfully.",
|
||||||
|
|
@ -87,10 +100,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await apiRequest("POST", "/api/logout");
|
// For JWT, we just remove the token locally
|
||||||
|
setToken(null);
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.setQueryData(["/api/user"], null);
|
queryClient.setQueryData(["/api/auth/me"], null);
|
||||||
toast({
|
toast({
|
||||||
title: "Logged out",
|
title: "Logged out",
|
||||||
description: "You have been logged out successfully.",
|
description: "You have been logged out successfully.",
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@
|
||||||
--card-foreground: 20 14.3% 4.1%;
|
--card-foreground: 20 14.3% 4.1%;
|
||||||
--border: 20 5.9% 90%;
|
--border: 20 5.9% 90%;
|
||||||
--input: 20 5.9% 90%;
|
--input: 20 5.9% 90%;
|
||||||
--primary: 175 84% 30%; /* teal: #0c8991 */
|
--primary: 175 84% 30%;
|
||||||
--primary-foreground: 211 100% 99%;
|
--primary-foreground: 211 100% 99%;
|
||||||
--secondary: 302 24% 49%; /* purple: #9D5E9B */
|
--secondary: 302 24% 49%;
|
||||||
--secondary-foreground: 24 9.8% 10%;
|
--secondary-foreground: 24 9.8% 10%;
|
||||||
--accent: 335 38% 51%; /* rose: #B55076 */
|
--accent: 335 38% 51%;
|
||||||
--accent-foreground: 24 9.8% 10%;
|
--accent-foreground: 24 9.8% 10%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 60 9.1% 97.8%;
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
|
@ -27,104 +27,25 @@
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 240 10% 3.9%;
|
|
||||||
--foreground: 0 0% 98%;
|
|
||||||
--muted: 240 3.7% 15.9%;
|
|
||||||
--muted-foreground: 240 5% 64.9%;
|
|
||||||
--popover: 240 10% 3.9%;
|
|
||||||
--popover-foreground: 0 0% 98%;
|
|
||||||
--card: 240 10% 3.9%;
|
|
||||||
--card-foreground: 0 0% 98%;
|
|
||||||
--border: 240 3.7% 15.9%;
|
|
||||||
--input: 240 3.7% 15.9%;
|
|
||||||
--primary: 175 84% 30%;
|
|
||||||
--primary-foreground: 211 100% 99%;
|
|
||||||
--secondary: 302 24% 49%;
|
|
||||||
--secondary-foreground: 0 0% 98%;
|
|
||||||
--accent: 335 38% 51%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--ring: 240 4.9% 83.9%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply font-sans antialiased bg-background text-foreground;
|
@apply font-sans antialiased bg-white text-gray-900;
|
||||||
font-family: 'Khula', sans-serif;
|
font-family: 'Khula', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-family: 'Lato', sans-serif;
|
font-family: 'Lato', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle, .card-subtitle, .section-subtitle {
|
.subtitle, .card-subtitle, .section-subtitle {
|
||||||
font-family: 'Catamaran', sans-serif;
|
font-family: 'Catamaran', sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.geometric-pattern {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%230c8991' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.islamic-pattern {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%239d5e9b' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.islamic-pattern-teal {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%230c8991' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.islamic-pattern-rose {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%23b55076' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.arabesque-pattern {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='52' height='26' viewBox='0 0 52 26' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%230c8991' fill-opacity='0.05'%3E%3Cpath d='M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6h-2c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.arabesque-pattern-purple {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='52' height='26' viewBox='0 0 52 26' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239d5e9b' fill-opacity='0.05'%3E%3Cpath d='M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6h-2c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.arabesque-pattern-rose {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='52' height='26' viewBox='0 0 52 26' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23b55076' fill-opacity='0.05'%3E%3Cpath d='M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6h-2c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.islamic-arch-divider {
|
|
||||||
position: relative;
|
|
||||||
height: 60px;
|
|
||||||
margin: -30px 0;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.islamic-arch-top {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.islamic-arch-top svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.islamic-arch-bottom {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.islamic-arch-bottom svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.teal-bg {
|
.teal-bg {
|
||||||
background-color: #0c8991;
|
background-color: #0c8991;
|
||||||
}
|
}
|
||||||
|
|
@ -161,6 +82,46 @@
|
||||||
color: #B55076;
|
color: #B55076;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reset and base styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom colors */
|
||||||
|
:root {
|
||||||
|
--teal: #0c8991;
|
||||||
|
--purple: #9D5E9B;
|
||||||
|
--rose: #B55076;
|
||||||
|
--teal-light: rgba(12, 137, 145, 0.1);
|
||||||
|
--purple-light: #E8D5E6;
|
||||||
|
--rose-light: rgba(181, 80, 118, 0.1);
|
||||||
|
--white: #ffffff;
|
||||||
|
--black: #000000;
|
||||||
|
--gray-50: #f9fafb;
|
||||||
|
--gray-100: #f3f4f6;
|
||||||
|
--gray-200: #e5e7eb;
|
||||||
|
--gray-300: #d1d5db;
|
||||||
|
--gray-500: #6b7280;
|
||||||
|
--gray-600: #4b5563;
|
||||||
|
--gray-700: #374151;
|
||||||
|
--gray-800: #1f2937;
|
||||||
|
--gray-900: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.font-playfair, .title {
|
.font-playfair, .title {
|
||||||
font-family: 'Lato', sans-serif;
|
font-family: 'Lato', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
@ -176,3 +137,400 @@
|
||||||
.font-cairo, .subtitle {
|
.font-cairo, .subtitle {
|
||||||
font-family: 'Catamaran', sans-serif;
|
font-family: 'Catamaran', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Layout utilities */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
.p-0 { padding: 0; }
|
||||||
|
.p-1 { padding: 0.25rem; }
|
||||||
|
.p-2 { padding: 0.5rem; }
|
||||||
|
.p-3 { padding: 0.75rem; }
|
||||||
|
.p-4 { padding: 1rem; }
|
||||||
|
.p-5 { padding: 1.25rem; }
|
||||||
|
.p-6 { padding: 1.5rem; }
|
||||||
|
.p-8 { padding: 2rem; }
|
||||||
|
.p-10 { padding: 2.5rem; }
|
||||||
|
.p-12 { padding: 3rem; }
|
||||||
|
.p-16 { padding: 4rem; }
|
||||||
|
.p-20 { padding: 5rem; }
|
||||||
|
|
||||||
|
.px-0 { padding-left: 0; padding-right: 0; }
|
||||||
|
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
|
||||||
|
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||||
|
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||||
|
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||||
|
.px-5 { padding-left: 1.25rem; padding-right: 1.25rem; }
|
||||||
|
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||||
|
.px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
.px-10 { padding-left: 2.5rem; padding-right: 2.5rem; }
|
||||||
|
.px-12 { padding-left: 3rem; padding-right: 3rem; }
|
||||||
|
.px-16 { padding-left: 4rem; padding-right: 4rem; }
|
||||||
|
.px-20 { padding-left: 5rem; padding-right: 5rem; }
|
||||||
|
|
||||||
|
.py-0 { padding-top: 0; padding-bottom: 0; }
|
||||||
|
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||||
|
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||||
|
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||||
|
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
|
||||||
|
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
|
||||||
|
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||||
|
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||||
|
.py-10 { padding-top: 2.5rem; padding-bottom: 2.5rem; }
|
||||||
|
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
|
||||||
|
.py-16 { padding-top: 4rem; padding-bottom: 4rem; }
|
||||||
|
.py-20 { padding-top: 5rem; padding-bottom: 5rem; }
|
||||||
|
|
||||||
|
.m-0 { margin: 0; }
|
||||||
|
.m-1 { margin: 0.25rem; }
|
||||||
|
.m-2 { margin: 0.5rem; }
|
||||||
|
.m-3 { margin: 0.75rem; }
|
||||||
|
.m-4 { margin: 1rem; }
|
||||||
|
.m-5 { margin: 1.25rem; }
|
||||||
|
.m-6 { margin: 1.5rem; }
|
||||||
|
.m-8 { margin: 2rem; }
|
||||||
|
.m-10 { margin: 2.5rem; }
|
||||||
|
.m-12 { margin: 3rem; }
|
||||||
|
.m-16 { margin: 4rem; }
|
||||||
|
.m-20 { margin: 5rem; }
|
||||||
|
|
||||||
|
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||||
|
.my-auto { margin-top: auto; margin-bottom: auto; }
|
||||||
|
|
||||||
|
/* Colors */
|
||||||
|
.bg-white { background-color: var(--white); }
|
||||||
|
.bg-black { background-color: var(--black); }
|
||||||
|
.bg-gray-50 { background-color: var(--gray-50); }
|
||||||
|
.bg-gray-100 { background-color: var(--gray-100); }
|
||||||
|
.bg-gray-200 { background-color: var(--gray-200); }
|
||||||
|
.bg-gray-300 { background-color: var(--gray-300); }
|
||||||
|
.bg-teal { background-color: var(--teal); }
|
||||||
|
.bg-purple { background-color: var(--purple); }
|
||||||
|
.bg-rose { background-color: var(--rose); }
|
||||||
|
.bg-teal-light { background-color: var(--teal-light); }
|
||||||
|
.bg-purple-light { background-color: var(--purple-light); }
|
||||||
|
.bg-rose-light { background-color: var(--rose-light); }
|
||||||
|
|
||||||
|
.text-white { color: var(--white); }
|
||||||
|
.text-black { color: var(--black); }
|
||||||
|
.text-gray-500 { color: var(--gray-500); }
|
||||||
|
.text-gray-600 { color: var(--gray-600); }
|
||||||
|
.text-gray-700 { color: var(--gray-700); }
|
||||||
|
.text-gray-800 { color: var(--gray-800); }
|
||||||
|
.text-gray-900 { color: var(--gray-900); }
|
||||||
|
.text-teal { color: var(--teal); }
|
||||||
|
.text-purple { color: var(--purple); }
|
||||||
|
.text-rose { color: var(--rose); }
|
||||||
|
|
||||||
|
/* Typography sizes */
|
||||||
|
.text-xs { font-size: 0.75rem; }
|
||||||
|
.text-sm { font-size: 0.875rem; }
|
||||||
|
.text-base { font-size: 1rem; }
|
||||||
|
.text-lg { font-size: 1.125rem; }
|
||||||
|
.text-xl { font-size: 1.25rem; }
|
||||||
|
.text-2xl { font-size: 1.5rem; }
|
||||||
|
.text-3xl { font-size: 1.875rem; }
|
||||||
|
.text-4xl { font-size: 2.25rem; }
|
||||||
|
.text-5xl { font-size: 3rem; }
|
||||||
|
|
||||||
|
.font-normal { font-weight: 400; }
|
||||||
|
.font-medium { font-weight: 500; }
|
||||||
|
.font-semibold { font-weight: 600; }
|
||||||
|
.font-bold { font-weight: 700; }
|
||||||
|
|
||||||
|
/* Width and height */
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.w-auto { width: auto; }
|
||||||
|
.w-1\/2 { width: 50%; }
|
||||||
|
.w-1\/3 { width: 33.333333%; }
|
||||||
|
.w-2\/3 { width: 66.666667%; }
|
||||||
|
.w-1\/4 { width: 25%; }
|
||||||
|
.w-3\/4 { width: 75%; }
|
||||||
|
|
||||||
|
.h-full { height: 100%; }
|
||||||
|
.h-auto { height: auto; }
|
||||||
|
.h-screen { height: 100vh; }
|
||||||
|
|
||||||
|
.max-w-sm { max-width: 24rem; }
|
||||||
|
.max-w-md { max-width: 28rem; }
|
||||||
|
.max-w-lg { max-width: 32rem; }
|
||||||
|
.max-w-xl { max-width: 36rem; }
|
||||||
|
.max-w-2xl { max-width: 42rem; }
|
||||||
|
.max-w-3xl { max-width: 48rem; }
|
||||||
|
.max-w-4xl { max-width: 56rem; }
|
||||||
|
.max-w-5xl { max-width: 64rem; }
|
||||||
|
.max-w-7xl { max-width: 80rem; }
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.grid { display: grid; }
|
||||||
|
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
|
||||||
|
.gap-1 { gap: 0.25rem; }
|
||||||
|
.gap-2 { gap: 0.5rem; }
|
||||||
|
.gap-3 { gap: 0.75rem; }
|
||||||
|
.gap-4 { gap: 1rem; }
|
||||||
|
.gap-5 { gap: 1.25rem; }
|
||||||
|
.gap-6 { gap: 1.5rem; }
|
||||||
|
.gap-8 { gap: 2rem; }
|
||||||
|
.gap-10 { gap: 2.5rem; }
|
||||||
|
.gap-12 { gap: 3rem; }
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
.border { border-width: 1px; }
|
||||||
|
.border-0 { border-width: 0; }
|
||||||
|
.border-2 { border-width: 2px; }
|
||||||
|
.border-4 { border-width: 4px; }
|
||||||
|
|
||||||
|
.rounded { border-radius: 0.25rem; }
|
||||||
|
.rounded-sm { border-radius: 0.125rem; }
|
||||||
|
.rounded-md { border-radius: 0.375rem; }
|
||||||
|
.rounded-lg { border-radius: 0.5rem; }
|
||||||
|
.rounded-xl { border-radius: 0.75rem; }
|
||||||
|
.rounded-full { border-radius: 9999px; }
|
||||||
|
|
||||||
|
.border-gray-200 { border-color: var(--gray-200); }
|
||||||
|
.border-gray-300 { border-color: var(--gray-300); }
|
||||||
|
.border-teal { border-color: var(--teal); }
|
||||||
|
.border-purple { border-color: var(--purple); }
|
||||||
|
.border-rose { border-color: var(--rose); }
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
|
||||||
|
.shadow { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); }
|
||||||
|
.shadow-md { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }
|
||||||
|
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
|
||||||
|
.shadow-xl { box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); }
|
||||||
|
|
||||||
|
/* Position */
|
||||||
|
.relative { position: relative; }
|
||||||
|
.absolute { position: absolute; }
|
||||||
|
.fixed { position: fixed; }
|
||||||
|
.sticky { position: sticky; }
|
||||||
|
|
||||||
|
.top-0 { top: 0; }
|
||||||
|
.top-1\/2 { top: 50%; }
|
||||||
|
.top-full { top: 100%; }
|
||||||
|
.bottom-0 { bottom: 0; }
|
||||||
|
.bottom-1\/2 { bottom: 50%; }
|
||||||
|
.left-0 { left: 0; }
|
||||||
|
.left-1\/2 { left: 50%; }
|
||||||
|
.right-0 { right: 0; }
|
||||||
|
.right-1\/2 { right: 50%; }
|
||||||
|
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
|
||||||
|
.inset-x-0 { left: 0; right: 0; }
|
||||||
|
.inset-y-0 { top: 0; bottom: 0; }
|
||||||
|
|
||||||
|
/* Z-index */
|
||||||
|
.z-10 { z-index: 10; }
|
||||||
|
.z-20 { z-index: 20; }
|
||||||
|
.z-50 { z-index: 50; }
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.block { display: block; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.inline { display: inline; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.inline-flex { display: inline-flex; }
|
||||||
|
.grid { display: grid; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||||
|
.transition-colors { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||||
|
.transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||||
|
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||||
|
|
||||||
|
.duration-200 { transition-duration: 200ms; }
|
||||||
|
.duration-300 { transition-duration: 300ms; }
|
||||||
|
|
||||||
|
/* Hover states */
|
||||||
|
.hover\:bg-opacity-90:hover { background-color: rgba(0, 0, 0, 0.9); }
|
||||||
|
.hover\:text-white:hover { color: var(--white); }
|
||||||
|
.hover\:text-opacity-100:hover { opacity: 1; }
|
||||||
|
.hover\:scale-105:hover { transform: scale(1.05); }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||||
|
.sm\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
.sm\:text-xl { font-size: 1.25rem; }
|
||||||
|
.sm\:text-2xl { font-size: 1.5rem; }
|
||||||
|
.sm\:text-3xl { font-size: 1.875rem; }
|
||||||
|
.sm\:text-4xl { font-size: 2.25rem; }
|
||||||
|
.sm\:text-5xl { font-size: 3rem; }
|
||||||
|
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.sm\:flex { display: flex; }
|
||||||
|
.sm\:hidden { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.md\:flex { display: flex; }
|
||||||
|
.md\:hidden { display: none; }
|
||||||
|
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.md\:flex-row { flex-direction: row; }
|
||||||
|
.md\:flex-col { flex-direction: column; }
|
||||||
|
.md\:w-1\/2 { width: 50%; }
|
||||||
|
.md\:w-1\/3 { width: 33.333333%; }
|
||||||
|
.md\:w-2\/3 { width: 66.666667%; }
|
||||||
|
.md\:text-lg { font-size: 1.125rem; }
|
||||||
|
.md\:text-xl { font-size: 1.25rem; }
|
||||||
|
.md\:text-2xl { font-size: 1.5rem; }
|
||||||
|
.md\:text-3xl { font-size: 1.875rem; }
|
||||||
|
.md\:text-4xl { font-size: 2.25rem; }
|
||||||
|
.md\:text-5xl { font-size: 3rem; }
|
||||||
|
.md\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||||
|
.md\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
.md\:py-20 { padding-top: 5rem; padding-bottom: 5rem; }
|
||||||
|
.md\:mb-0 { margin-bottom: 0; }
|
||||||
|
.md\:mb-4 { margin-bottom: 1rem; }
|
||||||
|
.md\:mb-6 { margin-bottom: 1.5rem; }
|
||||||
|
.md\:mb-8 { margin-bottom: 2rem; }
|
||||||
|
.md\:mb-16 { margin-bottom: 4rem; }
|
||||||
|
.md\:gap-8 { gap: 2rem; }
|
||||||
|
.md\:gap-12 { gap: 3rem; }
|
||||||
|
.md\:space-x-8 > * + * { margin-left: 2rem; }
|
||||||
|
.md\:space-y-1 > * + * { margin-top: 0.25rem; }
|
||||||
|
.md\:space-y-3 > * + * { margin-top: 0.75rem; }
|
||||||
|
.md\:space-y-8 > * + * { margin-top: 2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.lg\:px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||||
|
.lg\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||||
|
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
.lg\:text-4xl { font-size: 2.25rem; }
|
||||||
|
.lg\:text-5xl { font-size: 3rem; }
|
||||||
|
.lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.lg\:flex { display: flex; }
|
||||||
|
.lg\:hidden { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom utility classes */
|
||||||
|
.bg-transparent { background-color: transparent; }
|
||||||
|
.bg-cover { background-size: cover; }
|
||||||
|
.bg-center { background-position: center; }
|
||||||
|
.leading-tight { line-height: 1.25; }
|
||||||
|
.leading-snug { line-height: 1.375; }
|
||||||
|
.antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
|
||||||
|
.object-cover { object-fit: cover; }
|
||||||
|
.object-contain { object-fit: contain; }
|
||||||
|
.object-center { object-position: center; }
|
||||||
|
.overflow-hidden { overflow: hidden; }
|
||||||
|
.overflow-auto { overflow: auto; }
|
||||||
|
.overflow-x-auto { overflow-x: auto; }
|
||||||
|
.overflow-y-auto { overflow-y: auto; }
|
||||||
|
|
||||||
|
/* Specific color values used in components */
|
||||||
|
.bg-916398 { background-color: #916398; }
|
||||||
|
.bg-7ebdc5 { background-color: #7ebdc5; }
|
||||||
|
.text-ffffff { color: #ffffff; }
|
||||||
|
.text-49878f { color: #49878f; }
|
||||||
|
.bg-92bbc45c { background-color: rgba(146, 187, 196, 0.36); }
|
||||||
|
|
||||||
|
/* Opacity utilities */
|
||||||
|
.bg-opacity-20 { background-color: rgba(0, 0, 0, 0.2); }
|
||||||
|
.bg-opacity-30 { background-color: rgba(0, 0, 0, 0.3); }
|
||||||
|
.bg-opacity-90 { background-color: rgba(0, 0, 0, 0.9); }
|
||||||
|
.text-opacity-70 { opacity: 0.7; }
|
||||||
|
.text-opacity-80 { opacity: 0.8; }
|
||||||
|
.text-opacity-100 { opacity: 1; }
|
||||||
|
.border-opacity-20 { border-color: rgba(255, 255, 255, 0.2); }
|
||||||
|
.border-opacity-30 { border-color: rgba(255, 255, 255, 0.3); }
|
||||||
|
|
||||||
|
/* Transform utilities */
|
||||||
|
.transform { transform: translateZ(0); }
|
||||||
|
.scale-105 { transform: scale(1.05); }
|
||||||
|
.translateY-2 { transform: translateY(-2px); }
|
||||||
|
.translateY-1 { transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* Additional spacing utilities */
|
||||||
|
.space-x-8 > * + * { margin-left: 2rem; }
|
||||||
|
.space-y-1 > * + * { margin-top: 0.25rem; }
|
||||||
|
.space-y-3 > * + * { margin-top: 0.75rem; }
|
||||||
|
.space-y-8 > * + * { margin-top: 2rem; }
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
button, .btn {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover, .btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link styles */
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image styles */
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List styles */
|
||||||
|
ul, ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
input, textarea, select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles */
|
||||||
|
button:focus, input:focus, textarea:focus, select:focus {
|
||||||
|
outline: 2px solid var(--teal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,22 @@ export async function apiRequest(
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
data?: unknown | undefined,
|
data?: unknown | undefined,
|
||||||
|
token?: string | null,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: data ? { "Content-Type": "application/json" } : {},
|
headers,
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await throwIfResNotOk(res);
|
await throwIfResNotOk(res);
|
||||||
|
|
@ -26,11 +36,18 @@ export async function apiRequest(
|
||||||
type UnauthorizedBehavior = "returnNull" | "throw";
|
type UnauthorizedBehavior = "returnNull" | "throw";
|
||||||
export const getQueryFn: <T>(options: {
|
export const getQueryFn: <T>(options: {
|
||||||
on401: UnauthorizedBehavior;
|
on401: UnauthorizedBehavior;
|
||||||
|
token?: string | null;
|
||||||
}) => QueryFunction<T> =
|
}) => QueryFunction<T> =
|
||||||
({ on401: unauthorizedBehavior }) =>
|
({ on401: unauthorizedBehavior, token }) =>
|
||||||
async ({ queryKey }) => {
|
async ({ queryKey }) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(queryKey[0] as string, {
|
const res = await fetch(queryKey[0] as string, {
|
||||||
credentials: "include",
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||||
|
|
@ -44,11 +61,11 @@ export const getQueryFn: <T>(options: {
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
queryFn: getQueryFn({ on401: "throw" }),
|
retry: false,
|
||||||
refetchInterval: false,
|
refetchInterval: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
retry: false,
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// User schemas
|
||||||
|
export const insertUserSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
role: z.enum(["user", "admin"]).default("user"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||||
|
|
||||||
|
export const userSchema = insertUserSchema.extend({
|
||||||
|
id: z.number(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type User = z.infer<typeof userSchema>;
|
||||||
|
|
||||||
|
// Login schema
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Login = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
// Class schemas
|
||||||
|
export const classSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
duration: z.number(),
|
||||||
|
price: z.number(),
|
||||||
|
maxCapacity: z.number(),
|
||||||
|
instructor: z.string(),
|
||||||
|
category: z.string(),
|
||||||
|
level: z.enum(["beginner", "intermediate", "advanced"]),
|
||||||
|
classType: z.enum(["group", "small-group", "private", "online"]),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Class = z.infer<typeof classSchema>;
|
||||||
|
|
||||||
|
// Booking schemas
|
||||||
|
export const insertBookingSchema = z.object({
|
||||||
|
classId: z.number(),
|
||||||
|
userId: z.number(),
|
||||||
|
date: z.string(),
|
||||||
|
paid: z.boolean().default(false),
|
||||||
|
status: z.enum(["pending", "confirmed", "cancelled"]).default("pending"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertBooking = z.infer<typeof insertBookingSchema>;
|
||||||
|
|
||||||
|
// Newsletter schemas
|
||||||
|
export const insertNewsletterSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
agreedToTerms: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertNewsletter = z.infer<typeof insertNewsletterSchema>;
|
||||||
|
|
||||||
|
// Contact message schemas
|
||||||
|
export const insertContactMessageSchema = z.object({
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
subject: z.string().min(2).max(200).optional(),
|
||||||
|
message: z.string().min(10).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertContactMessage = z.infer<typeof insertContactMessageSchema>;
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
export interface StaticClass {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
duration: number;
|
||||||
|
price: number;
|
||||||
|
capacity: number;
|
||||||
|
classType: "group" | "small-group" | "private" | "online";
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATIC_CLASSES: StaticClass[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Mat Pilates",
|
||||||
|
description: "A foundational class focusing on core strength, proper alignment, and mindful movement patterns.",
|
||||||
|
duration: 60,
|
||||||
|
price: 2500, // $25.00
|
||||||
|
capacity: 15,
|
||||||
|
classType: "group",
|
||||||
|
imageUrl: "https://images.unsplash.com/photo-1571902943202-507ec2618e8f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Reformer Classes",
|
||||||
|
description: "Equipment-based sessions that enhance resistance training for deeper muscle engagement.",
|
||||||
|
duration: 55,
|
||||||
|
price: 4000, // $40.00
|
||||||
|
capacity: 8,
|
||||||
|
classType: "small-group",
|
||||||
|
imageUrl: "https://images.unsplash.com/photo-1562088287-bde35a1ea917"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Private Sessions",
|
||||||
|
description: "Personalized attention and customized programming to meet your specific goals and needs.",
|
||||||
|
duration: 60,
|
||||||
|
price: 7500, // $75.00
|
||||||
|
capacity: 1,
|
||||||
|
classType: "private",
|
||||||
|
imageUrl: "https://images.unsplash.com/photo-1616279969856-759f316a5ac1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Online Classes",
|
||||||
|
description: "Practice pilates from the comfort of your own home or wherever you happen to be with our convenient online sessions.",
|
||||||
|
duration: 50,
|
||||||
|
price: 2000, // $20.00
|
||||||
|
capacity: 20,
|
||||||
|
classType: "online",
|
||||||
|
imageUrl: "https://images.unsplash.com/photo-1518611012118-696072aa579a"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { insertUserSchema, insertContactMessageSchema, insertNewsletterSchema } from "@shared/schema";
|
import { insertUserSchema, insertContactMessageSchema, insertNewsletterSchema } from "@/lib/schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended registration schema with password confirmation
|
* Extended registration schema with password confirmation
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useAuth, registrationSchema, RegistrationData } from "@/hooks/use-auth";
|
import { useAuth, registrationSchema, RegistrationData } from "@/hooks/use-auth";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { loginSchema, Login } from "@shared/schema";
|
import { loginSchema, Login } from "@/lib/schema";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
|
@ -44,7 +44,7 @@ export default function AuthPage() {
|
||||||
const loginForm = useForm<Login>({
|
const loginForm = useForm<Login>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -53,9 +53,8 @@ export default function AuthPage() {
|
||||||
const registerForm = useForm<RegistrationData>({
|
const registerForm = useForm<RegistrationData>({
|
||||||
resolver: zodResolver(registrationSchema),
|
resolver: zodResolver(registrationSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
|
||||||
email: "",
|
email: "",
|
||||||
fullName: "",
|
name: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
},
|
},
|
||||||
|
|
@ -104,12 +103,12 @@ export default function AuthPage() {
|
||||||
<form onSubmit={loginForm.handleSubmit(onLoginSubmit)} className="space-y-6">
|
<form onSubmit={loginForm.handleSubmit(onLoginSubmit)} className="space-y-6">
|
||||||
<FormField
|
<FormField
|
||||||
control={loginForm.control}
|
control={loginForm.control}
|
||||||
name="username"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Enter your username" {...field} />
|
<Input placeholder="Enter your email" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -164,19 +163,6 @@ export default function AuthPage() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...registerForm}>
|
<Form {...registerForm}>
|
||||||
<form onSubmit={registerForm.handleSubmit(onRegisterSubmit)} className="space-y-6">
|
<form onSubmit={registerForm.handleSubmit(onRegisterSubmit)} className="space-y-6">
|
||||||
<FormField
|
|
||||||
control={registerForm.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Choose a username" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={registerForm.control}
|
control={registerForm.control}
|
||||||
name="email"
|
name="email"
|
||||||
|
|
@ -192,12 +178,12 @@ export default function AuthPage() {
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={registerForm.control}
|
control={registerForm.control}
|
||||||
name="fullName"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Full Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Enter your full name" {...field} value={typeof field.value === 'string' ? field.value : ''} />
|
<Input placeholder="Enter your name" {...field} value={typeof field.value === 'string' ? field.value : ''} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
import { HeroSection } from "@/components/home/hero-section";
|
import { HeroSection } from "@/components/home/hero-section";
|
||||||
|
|
||||||
import { HomeAboutSection } from "@/components/about/home-about-section";
|
import { HomeAboutSection } from "@/components/about/home-about-section";
|
||||||
|
|
@ -5,8 +6,6 @@ import { ClassesSection } from "@/components/classes/classes-section";
|
||||||
import { TestimonialsSection } from "@/components/testimonials/testimonials-section";
|
import { TestimonialsSection } from "@/components/testimonials/testimonials-section";
|
||||||
|
|
||||||
import { ContactSection } from "@/components/contact/contact-section";
|
import { ContactSection } from "@/components/contact/contact-section";
|
||||||
import { CTASection } from "@/components/home/cta-section";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
// Set meta data for SEO
|
// Set meta data for SEO
|
||||||
|
|
@ -47,7 +46,6 @@ export default function HomePage() {
|
||||||
<HomeAboutSection />
|
<HomeAboutSection />
|
||||||
<TestimonialsSection />
|
<TestimonialsSection />
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
<CTASection />
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./src/**/*.{html,js,jsx,ts,tsx}",
|
||||||
|
"./src/components/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./src/pages/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./src/hooks/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./src/lib/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
safelist: [
|
||||||
|
// Layout
|
||||||
|
'container', 'flex', 'grid', 'block', 'inline-block', 'hidden',
|
||||||
|
// Spacing
|
||||||
|
'p-0', 'p-1', 'p-2', 'p-3', 'p-4', 'p-5', 'p-6', 'p-8', 'p-10', 'p-12', 'p-16', 'p-20',
|
||||||
|
'px-0', 'px-1', 'px-2', 'px-3', 'px-4', 'px-5', 'px-6', 'px-8', 'px-10', 'px-12', 'px-16', 'px-20',
|
||||||
|
'py-0', 'py-1', 'py-2', 'py-3', 'py-4', 'py-5', 'py-6', 'py-8', 'py-10', 'py-12', 'py-16', 'py-20',
|
||||||
|
'm-0', 'm-1', 'm-2', 'm-3', 'm-4', 'm-5', 'm-6', 'm-8', 'm-10', 'm-12', 'm-16', 'm-20',
|
||||||
|
'mx-auto', 'my-auto',
|
||||||
|
// Colors
|
||||||
|
'bg-white', 'bg-black', 'bg-gray-50', 'bg-gray-100', 'bg-gray-200', 'bg-gray-300',
|
||||||
|
'text-white', 'text-black', 'text-gray-500', 'text-gray-600', 'text-gray-700', 'text-gray-800', 'text-gray-900',
|
||||||
|
'bg-teal', 'bg-purple', 'bg-rose', 'text-teal', 'text-purple', 'text-rose',
|
||||||
|
'bg-teal-light', 'bg-purple-light', 'bg-rose-light',
|
||||||
|
'bg-opacity-20', 'bg-opacity-30', 'bg-opacity-90',
|
||||||
|
'text-opacity-70', 'text-opacity-80', 'text-opacity-100',
|
||||||
|
'border-opacity-20', 'border-opacity-30',
|
||||||
|
// Typography
|
||||||
|
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl',
|
||||||
|
'font-normal', 'font-medium', 'font-semibold', 'font-bold',
|
||||||
|
'text-left', 'text-center', 'text-right',
|
||||||
|
'font-playfair', 'font-raleway', 'font-aref', 'font-cairo',
|
||||||
|
// Flexbox
|
||||||
|
'flex-row', 'flex-col', 'items-start', 'items-center', 'items-end', 'items-stretch',
|
||||||
|
'justify-start', 'justify-center', 'justify-end', 'justify-between', 'justify-around',
|
||||||
|
'gap-1', 'gap-2', 'gap-3', 'gap-4', 'gap-5', 'gap-6', 'gap-8', 'gap-10', 'gap-12',
|
||||||
|
// Grid
|
||||||
|
'grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4',
|
||||||
|
// Width/Height
|
||||||
|
'w-full', 'w-auto', 'w-1/2', 'w-1/3', 'w-2/3', 'w-1/4', 'w-3/4',
|
||||||
|
'h-full', 'h-auto', 'h-screen', 'h-svh',
|
||||||
|
'max-w-sm', 'max-w-md', 'max-w-lg', 'max-w-xl', 'max-w-2xl', 'max-w-3xl', 'max-w-4xl', 'max-w-5xl', 'max-w-7xl',
|
||||||
|
// Borders
|
||||||
|
'border', 'border-0', 'border-2', 'border-4',
|
||||||
|
'rounded', 'rounded-sm', 'rounded-md', 'rounded-lg', 'rounded-xl', 'rounded-full',
|
||||||
|
'border-gray-200', 'border-gray-300', 'border-teal', 'border-purple', 'border-rose',
|
||||||
|
// Shadows
|
||||||
|
'shadow-sm', 'shadow', 'shadow-md', 'shadow-lg', 'shadow-xl',
|
||||||
|
// Position
|
||||||
|
'relative', 'absolute', 'fixed', 'sticky',
|
||||||
|
'top-0', 'top-1/2', 'top-full',
|
||||||
|
'bottom-0', 'bottom-1/2',
|
||||||
|
'left-0', 'left-1/2',
|
||||||
|
'right-0', 'right-1/2',
|
||||||
|
'inset-0', 'inset-x-0', 'inset-y-0',
|
||||||
|
// Z-index
|
||||||
|
'z-10', 'z-20', 'z-50',
|
||||||
|
// Overflow
|
||||||
|
'overflow-hidden', 'overflow-auto', 'overflow-x-auto', 'overflow-y-auto',
|
||||||
|
// Display
|
||||||
|
'block', 'inline-block', 'inline', 'flex', 'inline-flex', 'grid', 'hidden',
|
||||||
|
// Transitions
|
||||||
|
'transition-all', 'transition-colors', 'transition-transform', 'transition-opacity',
|
||||||
|
'duration-200', 'duration-300',
|
||||||
|
// Hover states
|
||||||
|
'hover:bg-opacity-90', 'hover:text-white', 'hover:text-opacity-100', 'hover:scale-105',
|
||||||
|
// Responsive prefixes
|
||||||
|
'sm:', 'md:', 'lg:', 'xl:', '2xl:',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
teal: "#0c8991",
|
||||||
|
purple: "#9D5E9B",
|
||||||
|
rose: "#B55076",
|
||||||
|
"teal-light": "rgba(12, 137, 145, 0.1)",
|
||||||
|
"purple-light": "#E8D5E6",
|
||||||
|
"rose-light": "rgba(181, 80, 118, 0.1)",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
chart: {
|
||||||
|
"1": "hsl(var(--chart-1))",
|
||||||
|
"2": "hsl(var(--chart-2))",
|
||||||
|
"3": "hsl(var(--chart-3))",
|
||||||
|
"4": "hsl(var(--chart-4))",
|
||||||
|
"5": "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar-background))",
|
||||||
|
foreground: "hsl(var(--sidebar-foreground))",
|
||||||
|
primary: "hsl(var(--sidebar-primary))",
|
||||||
|
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||||
|
accent: "hsl(var(--sidebar-accent))",
|
||||||
|
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||||
|
border: "hsl(var(--sidebar-border))",
|
||||||
|
ring: "hsl(var(--sidebar-ring))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
} satisfies Config;
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
root: path.resolve(__dirname),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
"@shared": path.resolve(__dirname, "../shared"),
|
||||||
|
"@assets": path.resolve(__dirname, "../attached_assets"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
assetsDir: "assets",
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
assetFileNames: "assets/[name]-[hash][extname]",
|
||||||
|
chunkFileNames: "assets/[name]-[hash].js",
|
||||||
|
entryFileNames: "assets/[name]-[hash].js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
force: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { defineConfig } from "drizzle-kit";
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
throw new Error("DATABASE_URL, ensure the database is provisioned");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
out: "./migrations",
|
|
||||||
schema: "./shared/schema.ts",
|
|
||||||
dialect: "postgresql",
|
|
||||||
dbCredentials: {
|
|
||||||
url: process.env.DATABASE_URL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# JWT Secret for token signing (use a strong random string in production)
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-here
|
||||||
|
|
||||||
|
# Mailchimp Configuration
|
||||||
|
MAILCHIMP_API_KEY=your-mailchimp-api-key
|
||||||
|
MAILCHIMP_SERVER_PREFIX=us1
|
||||||
|
MAILCHIMP_LIST_ID=your-mailchimp-list-id
|
||||||
|
|
||||||
|
# Cloudflare KV Namespace IDs (get these from wrangler kv:namespace create)
|
||||||
|
# Update these in wrangler.toml after creating the namespaces
|
||||||
|
KV_NAMESPACE_ID=your-kv-namespace-id
|
||||||
|
KV_PREVIEW_NAMESPACE_ID=your-preview-kv-namespace-id
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Environment Variables for Pilates with Fadia
|
||||||
|
# Copy this file to .env and fill in your actual values
|
||||||
|
|
||||||
|
# Node Environment
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Session Secret (generate a random string for security)
|
||||||
|
SESSION_SECRET=your_secure_session_secret_here
|
||||||
|
|
||||||
|
# Mailchimp Configuration
|
||||||
|
# Get these from your Mailchimp account settings
|
||||||
|
MAILCHIMP_API_KEY=your_mailchimp_api_key_here
|
||||||
|
MAILCHIMP_SERVER_PREFIX=your_mailchimp_server_prefix_here
|
||||||
|
MAILCHIMP_LIST_ID=your_mailchimp_list_id_here
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { createCors } from 'itty-cors';
|
||||||
|
|
||||||
|
const { preflight, corsify } = createCors({
|
||||||
|
origins: ['*'],
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Credentials': 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const onRequest: PagesFunction = async (context) => {
|
||||||
|
const { request, next } = context;
|
||||||
|
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return preflight(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CORS headers to all responses
|
||||||
|
const response = await next();
|
||||||
|
return corsify(response);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// JWT utilities
|
||||||
|
async function signJWT(payload: any, secret: string): Promise<string> {
|
||||||
|
const header = {
|
||||||
|
alg: 'HS256',
|
||||||
|
typ: 'JWT'
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedHeader = btoa(JSON.stringify(header));
|
||||||
|
const encodedPayload = btoa(JSON.stringify(payload));
|
||||||
|
|
||||||
|
const data = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
const signature = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signatureBuffer = await crypto.subtle.sign('HMAC', signature, new TextEncoder().encode(data));
|
||||||
|
const encodedSignature = btoa(String.fromCharCode(...new Uint8Array(signatureBuffer)));
|
||||||
|
|
||||||
|
return `${data}.${encodedSignature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyJWT(token: string, secret: string): Promise<any> {
|
||||||
|
const [header, payload, signature] = token.split('.');
|
||||||
|
|
||||||
|
const data = `${header}.${payload}`;
|
||||||
|
const signatureBuffer = new Uint8Array(
|
||||||
|
atob(signature).split('').map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValid = await crypto.subtle.verify('HMAC', key, signatureBuffer, new TextEncoder().encode(data));
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(atob(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password hashing
|
||||||
|
async function hashPassword(password: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(password);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function comparePasswords(password: string, hashedPassword: string): Promise<boolean> {
|
||||||
|
const hashedInput = await hashPassword(password);
|
||||||
|
return hashedInput === hashedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const registerSchema = z.object({
|
||||||
|
username: z.string().min(3).max(50),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
fullName: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// User interface
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
fullName?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequest: PagesFunction = async (context) => {
|
||||||
|
const { request, env } = context;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = url.pathname.split('/').pop();
|
||||||
|
|
||||||
|
const JWT_SECRET = env.JWT_SECRET || 'pilateswithfadia-secret-key';
|
||||||
|
|
||||||
|
// Helper function to get user from KV
|
||||||
|
async function getUserByUsername(username: string): Promise<User | null> {
|
||||||
|
const userData = await env.STORAGE.get(`user:${username}`);
|
||||||
|
return userData ? JSON.parse(userData) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
const userData = await env.STORAGE.get(`user_email:${email}`);
|
||||||
|
return userData ? JSON.parse(userData) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(userData: Omit<User, 'id' | 'createdAt'>): Promise<User> {
|
||||||
|
const id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const user: User = {
|
||||||
|
...userData,
|
||||||
|
id,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await env.STORAGE.put(`user:${user.username}`, JSON.stringify(user));
|
||||||
|
await env.STORAGE.put(`user_email:${user.email}`, JSON.stringify(user));
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register endpoint
|
||||||
|
if (path === 'register' && request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { username, email, password, fullName } = registerSchema.parse(body);
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
const existingUserByUsername = await getUserByUsername(username);
|
||||||
|
if (existingUserByUsername) {
|
||||||
|
return new Response(JSON.stringify({ message: "Username already exists" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
const existingUserByEmail = await getUserByEmail(email);
|
||||||
|
if (existingUserByEmail) {
|
||||||
|
return new Response(JSON.stringify({ message: "Email already in use" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
const user = await createUser({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
fullName
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await signJWT(
|
||||||
|
{ userId: user.id, username: user.username },
|
||||||
|
JWT_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return user without password
|
||||||
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
user: userWithoutPassword,
|
||||||
|
token
|
||||||
|
}), {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
message: "Invalid registration data",
|
||||||
|
errors: error.errors
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ message: "Registration failed" }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
if (path === 'login' && request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { username, password } = loginSchema.parse(body);
|
||||||
|
|
||||||
|
const user = await getUserByUsername(username);
|
||||||
|
if (!user || !(await comparePasswords(password, user.password))) {
|
||||||
|
return new Response(JSON.stringify({ message: "Invalid username or password" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await signJWT(
|
||||||
|
{ userId: user.id, username: user.username },
|
||||||
|
JWT_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return user without password
|
||||||
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
user: userWithoutPassword,
|
||||||
|
token
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
message: "Invalid login data",
|
||||||
|
errors: error.errors
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ message: "Login failed" }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me endpoint (get current user)
|
||||||
|
if (path === 'me' && request.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return new Response(JSON.stringify({ message: "Not authenticated" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const payload = await verifyJWT(token, JWT_SECRET);
|
||||||
|
|
||||||
|
const user = await getUserByUsername(payload.username);
|
||||||
|
if (!user) {
|
||||||
|
return new Response(JSON.stringify({ message: "User not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return user without password
|
||||||
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(userWithoutPassword), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ message: "Invalid token" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "Not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const bookingSchema = z.object({
|
||||||
|
classId: z.number(),
|
||||||
|
date: z.string(),
|
||||||
|
paid: z.boolean().default(false),
|
||||||
|
status: z.string().default("pending")
|
||||||
|
});
|
||||||
|
|
||||||
|
// JWT verification helper
|
||||||
|
async function verifyJWT(token: string, secret: string): Promise<any> {
|
||||||
|
const [header, payload, signature] = token.split('.');
|
||||||
|
|
||||||
|
const data = `${header}.${payload}`;
|
||||||
|
const signatureBuffer = new Uint8Array(
|
||||||
|
atob(signature).split('').map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValid = await crypto.subtle.verify('HMAC', key, signatureBuffer, new TextEncoder().encode(data));
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(atob(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booking interface
|
||||||
|
interface Booking {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
classId: number;
|
||||||
|
date: string;
|
||||||
|
paid: boolean;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequest: PagesFunction = async (context) => {
|
||||||
|
const { request, env } = context;
|
||||||
|
|
||||||
|
const JWT_SECRET = env.JWT_SECRET || 'pilateswithfadia-secret-key';
|
||||||
|
|
||||||
|
// Helper function to get user from token
|
||||||
|
async function getCurrentUser(): Promise<any> {
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
throw new Error('No valid token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
return await verifyJWT(token, JWT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get user bookings
|
||||||
|
async function getUserBookings(userId: string): Promise<Booking[]> {
|
||||||
|
const bookingsData = await env.STORAGE.get(`bookings:${userId}`);
|
||||||
|
return bookingsData ? JSON.parse(bookingsData) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to save user bookings
|
||||||
|
async function saveUserBookings(userId: string, bookings: Booking[]): Promise<void> {
|
||||||
|
await env.STORAGE.put(`bookings:${userId}`, JSON.stringify(bookings));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/bookings - Get user's bookings
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
const bookings = await getUserBookings(user.userId);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(bookings), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ message: "Unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/bookings - Create new booking
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
const body = await request.json();
|
||||||
|
const bookingData = bookingSchema.parse(body);
|
||||||
|
|
||||||
|
const bookings = await getUserBookings(user.userId);
|
||||||
|
|
||||||
|
const newBooking: Booking = {
|
||||||
|
id: `booking_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
userId: user.userId,
|
||||||
|
classId: bookingData.classId,
|
||||||
|
date: bookingData.date,
|
||||||
|
paid: bookingData.paid,
|
||||||
|
status: bookingData.status,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
bookings.push(newBooking);
|
||||||
|
await saveUserBookings(user.userId, bookings);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(newBooking), {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
message: "Invalid booking data",
|
||||||
|
errors: error.errors
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === 'No valid token provided') {
|
||||||
|
return new Response(JSON.stringify({ message: "Unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "Failed to create booking" }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "Method not allowed" }), {
|
||||||
|
status: 405,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Class {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
duration: number;
|
||||||
|
price: number;
|
||||||
|
capacity: number;
|
||||||
|
classType: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default classes data
|
||||||
|
const defaultClasses: Class[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Mat Pilates",
|
||||||
|
description: "A foundational class focusing on core strength, proper alignment, and mindful movement patterns.",
|
||||||
|
duration: 60,
|
||||||
|
price: 2500, // $25.00
|
||||||
|
capacity: 15,
|
||||||
|
classType: "group",
|
||||||
|
imageUrl: "https://images.unsplash.com/photo-1571902943202-507ec2618e8f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Reformer Classes",
|
||||||
|
description: "Equipment-based sessions that enhance resistance training for deeper muscle engagement.",
|
||||||
|
duration: 55,
|
||||||
|
price: 4000, // $40.00
|
||||||
|
capacity: 8,
|
||||||
|
classType: "small-group",
|
||||||
|
imageUrl: "https://images.unsplash.com/photo-1562088287-bde35a1ea917"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Private Sessions",
|
||||||
|
description: "Personalized attention and customized programming to meet your specific goals and needs.",
|
||||||
|
duration: 60,
|
||||||
|
price: 7500, // $75.00
|
||||||
|
capacity: 1,
|
||||||
|
classType: "private",
|
||||||
|
imageUrl: "https://images.unsplash.com/photo-1616279969856-759f316a5ac1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Online Classes",
|
||||||
|
description: "Practice pilates from the comfort of your own home or wherever you happen to be with our convenient online sessions.",
|
||||||
|
duration: 50,
|
||||||
|
price: 2000, // $20.00
|
||||||
|
capacity: 20,
|
||||||
|
classType: "online",
|
||||||
|
imageUrl: "https://images.unsplash.com/photo-1518611012118-696072aa579a"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const onRequest: PagesFunction = async (context) => {
|
||||||
|
const { request, env } = context;
|
||||||
|
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const classId = url.pathname.split('/').pop();
|
||||||
|
|
||||||
|
if (classId && classId !== 'classes') {
|
||||||
|
// Get specific class
|
||||||
|
const id = parseInt(classId);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return new Response(JSON.stringify({ message: "Invalid class ID" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const classData = defaultClasses.find(c => c.id === id);
|
||||||
|
if (!classData) {
|
||||||
|
return new Response(JSON.stringify({ message: "Class not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(classData), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Get all classes
|
||||||
|
return new Response(JSON.stringify(defaultClasses), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "Method not allowed" }), {
|
||||||
|
status: 405,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const contactMessageSchema = z.object({
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
subject: z.string().min(2).max(200).optional(),
|
||||||
|
message: z.string().min(10).max(2000)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const onRequest: PagesFunction = async (context) => {
|
||||||
|
const { request, env } = context;
|
||||||
|
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const contactData = contactMessageSchema.parse(body);
|
||||||
|
|
||||||
|
// Store in KV storage
|
||||||
|
const messageId = `contact:${Date.now()}:${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const messageRecord = {
|
||||||
|
id: messageId,
|
||||||
|
name: contactData.name,
|
||||||
|
email: contactData.email,
|
||||||
|
subject: contactData.subject,
|
||||||
|
message: contactData.message,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await env.STORAGE.put(messageId, JSON.stringify(messageRecord));
|
||||||
|
|
||||||
|
// Log the contact request
|
||||||
|
console.log(`Contact form submission from ${contactData.name} (${contactData.email})`);
|
||||||
|
console.log(`Subject: ${contactData.subject || "No subject"}`);
|
||||||
|
console.log(`Message: ${contactData.message}`);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
message: "Message sent successfully",
|
||||||
|
info: "Your message has been received and will be reviewed shortly."
|
||||||
|
}), {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
message: "Invalid contact data",
|
||||||
|
errors: error.errors
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ message: "Failed to send message" }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "Method not allowed" }), {
|
||||||
|
status: 405,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const newsletterSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
agreedToTerms: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mailchimp integration
|
||||||
|
async function subscribeToMailchimp(email: string, env: any) {
|
||||||
|
const API_KEY = env.MAILCHIMP_API_KEY;
|
||||||
|
const SERVER_PREFIX = env.MAILCHIMP_SERVER_PREFIX;
|
||||||
|
const LIST_ID = env.MAILCHIMP_LIST_ID;
|
||||||
|
|
||||||
|
if (!API_KEY || !SERVER_PREFIX || !LIST_ID) {
|
||||||
|
throw new Error('Mailchimp configuration missing. Please check your environment variables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create MD5 hash of lowercase email for Mailchimp
|
||||||
|
const emailHash = crypto.createHash('md5').update(email.toLowerCase()).digest('hex');
|
||||||
|
|
||||||
|
// Set up the request
|
||||||
|
const url = `https://${SERVER_PREFIX}.api.mailchimp.com/3.0/lists/${LIST_ID}/members`;
|
||||||
|
const data = {
|
||||||
|
email_address: email,
|
||||||
|
status: 'subscribed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Basic ${btoa(`apikey:${API_KEY}`)}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (response.status === 400 && errorData.title === 'Member Exists') {
|
||||||
|
return { status: 'already_subscribed', message: 'This email is already subscribed to our newsletter.' };
|
||||||
|
}
|
||||||
|
throw new Error(`Mailchimp API error: ${errorData.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to subscribe to newsletter: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequest: PagesFunction = async (context) => {
|
||||||
|
const { request, env } = context;
|
||||||
|
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const newsletterData = newsletterSchema.parse(body);
|
||||||
|
|
||||||
|
// Check if email already exists in KV storage
|
||||||
|
const existingNewsletter = await env.STORAGE.get(`newsletter:${newsletterData.email}`);
|
||||||
|
|
||||||
|
// Store in KV storage
|
||||||
|
if (!existingNewsletter) {
|
||||||
|
const newsletterRecord = {
|
||||||
|
email: newsletterData.email,
|
||||||
|
agreedToTerms: newsletterData.agreedToTerms,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
await env.STORAGE.put(`newsletter:${newsletterData.email}`, JSON.stringify(newsletterRecord));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to Mailchimp
|
||||||
|
try {
|
||||||
|
const mailchimpResponse = await subscribeToMailchimp(newsletterData.email, env);
|
||||||
|
|
||||||
|
if (mailchimpResponse && mailchimpResponse.status === 'already_subscribed') {
|
||||||
|
return new Response(JSON.stringify({ message: mailchimpResponse.message }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "Successfully subscribed to the newsletter" }), {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (mailchimpError: any) {
|
||||||
|
console.error('Mailchimp error:', mailchimpError.message);
|
||||||
|
// Still return success if we stored in KV but Mailchimp failed
|
||||||
|
return new Response(JSON.stringify({ message: "Successfully subscribed to the newsletter" }), {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
message: "Invalid newsletter data",
|
||||||
|
errors: error.errors
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error('Newsletter subscription error:', error);
|
||||||
|
return new Response(JSON.stringify({ message: "Failed to subscribe to newsletter" }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "Method not allowed" }), {
|
||||||
|
status: 405,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
};
|
||||||
103
package.json
|
|
@ -1,109 +1,26 @@
|
||||||
{
|
{
|
||||||
"name": "rest-express",
|
"name": "pilates-with-fadia",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development tsx server/index.ts",
|
"dev": "cd client && vite dev",
|
||||||
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
"build": "cd client && vite build",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"build:client": "cd client && vite build",
|
||||||
|
"preview": "cd client && vite preview",
|
||||||
"check": "tsc",
|
"check": "tsc",
|
||||||
"db:push": "drizzle-kit push"
|
"deploy": "wrangler pages deploy client/dist",
|
||||||
|
"deploy:preview": "wrangler pages deploy client/dist --compatibility-date=2024-01-15",
|
||||||
|
"test:deployment": "node test-deployment.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"itty-cors": "^0.1.0"
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.4",
|
|
||||||
"@radix-ui/react-checkbox": "^1.1.5",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.4",
|
|
||||||
"@radix-ui/react-context-menu": "^2.2.7",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.7",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
|
||||||
"@radix-ui/react-hover-card": "^1.1.7",
|
|
||||||
"@radix-ui/react-label": "^2.1.3",
|
|
||||||
"@radix-ui/react-menubar": "^1.1.7",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
|
||||||
"@radix-ui/react-popover": "^1.1.7",
|
|
||||||
"@radix-ui/react-progress": "^1.1.3",
|
|
||||||
"@radix-ui/react-radio-group": "^1.2.4",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
|
||||||
"@radix-ui/react-select": "^2.1.7",
|
|
||||||
"@radix-ui/react-separator": "^1.1.3",
|
|
||||||
"@radix-ui/react-slider": "^1.2.4",
|
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
|
||||||
"@radix-ui/react-switch": "^1.1.4",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.4",
|
|
||||||
"@radix-ui/react-toast": "^1.2.7",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.3",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
|
||||||
"@sendgrid/mail": "^8.1.5",
|
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
|
||||||
"@tanstack/react-query": "^5.60.5",
|
|
||||||
"@types/nodemailer": "^6.4.17",
|
|
||||||
"axios": "^1.9.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"connect-pg-simple": "^10.0.0",
|
|
||||||
"date-fns": "^3.6.0",
|
|
||||||
"drizzle-orm": "^0.39.1",
|
|
||||||
"drizzle-zod": "^0.7.0",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"express-session": "^1.18.1",
|
|
||||||
"framer-motion": "^11.13.1",
|
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^0.453.0",
|
|
||||||
"memorystore": "^1.6.7",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"nodemailer": "^7.0.3",
|
|
||||||
"passport": "^0.7.0",
|
|
||||||
"passport-local": "^1.0.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-day-picker": "^8.10.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-hook-form": "^7.55.0",
|
|
||||||
"react-icons": "^5.4.0",
|
|
||||||
"react-resizable-panels": "^2.1.7",
|
|
||||||
"recharts": "^2.15.2",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"tw-animate-css": "^1.2.5",
|
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"wouter": "^3.3.5",
|
|
||||||
"ws": "^8.18.0",
|
|
||||||
"zod": "^3.24.2",
|
|
||||||
"zod-validation-error": "^3.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@replit/vite-plugin-cartographer": "^0.2.7",
|
|
||||||
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
|
||||||
"@types/express": "4.17.21",
|
|
||||||
"@types/express-session": "^1.18.0",
|
|
||||||
"@types/node": "20.16.11",
|
|
||||||
"@types/passport": "^1.0.16",
|
|
||||||
"@types/passport-local": "^1.0.38",
|
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/ws": "^8.5.13",
|
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"drizzle-kit": "^0.30.4",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"postcss": "^8.4.47",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"tsx": "^4.19.1",
|
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "^5.4.14"
|
"wrangler": "^3.0.0"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"bufferutil": "^4.0.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ export default {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -4,12 +4,17 @@ import { Express } from "express";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
|
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { storage } from "./storage";
|
import { storage, User } from "./storage";
|
||||||
import { User as SelectUser, insertUserSchema } from "@shared/schema";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface User extends SelectUser {}
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
fullName?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,11 +26,10 @@ async function hashPassword(password: string) {
|
||||||
return `${buf.toString("hex")}.${salt}`;
|
return `${buf.toString("hex")}.${salt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function comparePasswords(supplied: string, stored: string) {
|
async function comparePasswords(password: string, hashedPassword: string) {
|
||||||
const [hashed, salt] = stored.split(".");
|
const [hash, salt] = hashedPassword.split(".");
|
||||||
const hashedBuf = Buffer.from(hashed, "hex");
|
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
|
||||||
const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
|
return timingSafeEqual(buf, Buffer.from(hash, "hex"));
|
||||||
return timingSafeEqual(hashedBuf, suppliedBuf);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupAuth(app: Express) {
|
export function setupAuth(app: Express) {
|
||||||
|
|
@ -66,23 +70,34 @@ export function setupAuth(app: Express) {
|
||||||
|
|
||||||
app.post("/api/register", async (req, res, next) => {
|
app.post("/api/register", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const validatedUser = insertUserSchema.parse(req.body);
|
const { username, email, password, fullName } = req.body;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!username || !email || !password) {
|
||||||
|
return res.status(400).json({ message: "Username, email, and password are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return res.status(400).json({ message: "Password must be at least 8 characters" });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if username already exists
|
// Check if username already exists
|
||||||
const existingUserByUsername = await storage.getUserByUsername(validatedUser.username);
|
const existingUserByUsername = await storage.getUserByUsername(username);
|
||||||
if (existingUserByUsername) {
|
if (existingUserByUsername) {
|
||||||
return res.status(400).json({ message: "Username already exists" });
|
return res.status(400).json({ message: "Username already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if email already exists
|
// Check if email already exists
|
||||||
const existingUserByEmail = await storage.getUserByEmail(validatedUser.email);
|
const existingUserByEmail = await storage.getUserByEmail(email);
|
||||||
if (existingUserByEmail) {
|
if (existingUserByEmail) {
|
||||||
return res.status(400).json({ message: "Email already in use" });
|
return res.status(400).json({ message: "Email already in use" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await storage.createUser({
|
const user = await storage.createUser({
|
||||||
...validatedUser,
|
username,
|
||||||
password: await hashPassword(validatedUser.password),
|
email,
|
||||||
|
password: await hashPassword(password),
|
||||||
|
fullName
|
||||||
});
|
});
|
||||||
|
|
||||||
req.login(user, (err) => {
|
req.login(user, (err) => {
|
||||||
|
|
@ -98,7 +113,7 @@ export function setupAuth(app: Express) {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/login", (req, res, next) => {
|
app.post("/api/login", (req, res, next) => {
|
||||||
passport.authenticate("local", (err: Error, user: SelectUser) => {
|
passport.authenticate("local", (err: Error, user: User) => {
|
||||||
if (err) return next(err);
|
if (err) return next(err);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ message: "Invalid username or password" });
|
return res.status(401).json({ message: "Invalid username or password" });
|
||||||
|
|
@ -121,11 +136,12 @@ export function setupAuth(app: Express) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/user", (req, res) => {
|
app.get("/api/me", (req, res) => {
|
||||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
if (req.isAuthenticated()) {
|
||||||
|
const { password, ...userWithoutPassword } = req.user as User;
|
||||||
// Return user without password
|
res.json(userWithoutPassword);
|
||||||
const { password, ...userWithoutPassword } = req.user;
|
} else {
|
||||||
res.json(userWithoutPassword);
|
res.status(401).json({ message: "Not authenticated" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
/**
|
|
||||||
* This file handles sending contact form submissions to hello@pilateswithfadia.com
|
|
||||||
* We're using the Mailchimp API which is already configured
|
|
||||||
*/
|
|
||||||
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
interface ContactFormData {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
subject?: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendContactEmail(data: ContactFormData): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Log the contact request (without sensitive data)
|
|
||||||
console.log(`Processing contact form submission from ${data.name}`);
|
|
||||||
|
|
||||||
// Create HTML content for the email
|
|
||||||
const htmlContent = `
|
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eaeaea; border-radius: 5px;">
|
|
||||||
<h2 style="color: #0c8991; border-bottom: 1px solid #eaeaea; padding-bottom: 10px;">New Message from Pilates with Fadia Website</h2>
|
|
||||||
<p><strong>From:</strong> ${data.name} (${data.email})</p>
|
|
||||||
<p><strong>Subject:</strong> ${data.subject || "No subject"}</p>
|
|
||||||
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 4px; margin-top: 20px;">
|
|
||||||
<p style="white-space: pre-line;">${data.message}</p>
|
|
||||||
</div>
|
|
||||||
<p style="color: #666; font-size: 12px; margin-top: 30px; border-top: 1px solid #eaeaea; padding-top: 10px;">
|
|
||||||
This message was sent from the contact form on your Pilates with Fadia website.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// For now, we'll just return true to simulate success
|
|
||||||
// In the future, we can integrate with a transactional email API
|
|
||||||
console.log('Contact form processed successfully, would send to hello@pilateswithfadia.com');
|
|
||||||
|
|
||||||
// Store the message in the database, so nothing is lost
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing contact form submission:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import nodemailer from 'nodemailer';
|
|
||||||
|
|
||||||
interface EmailOptions {
|
|
||||||
to: string;
|
|
||||||
from: string;
|
|
||||||
subject: string;
|
|
||||||
text: string;
|
|
||||||
html?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log email configuration (without password)
|
|
||||||
console.log(`Email configuration: Using ${process.env.EMAIL_USER} to send emails`);
|
|
||||||
|
|
||||||
// Create a transporter
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
service: 'gmail', // Use predefined settings for Gmail
|
|
||||||
auth: {
|
|
||||||
user: process.env.EMAIL_USER,
|
|
||||||
pass: process.env.EMAIL_PASSWORD, // This needs to be an app password for Gmail
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Set sender if not specified
|
|
||||||
const mailOptions = {
|
|
||||||
from: options.from || `"Pilates with Fadia" <${process.env.EMAIL_USER}>`,
|
|
||||||
to: options.to,
|
|
||||||
subject: options.subject,
|
|
||||||
text: options.text,
|
|
||||||
html: options.html,
|
|
||||||
replyTo: options.from // Set reply-to as the sender's email
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send the email
|
|
||||||
const info = await transporter.sendMail(mailOptions);
|
|
||||||
console.log('Email sent:', info.messageId);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -36,35 +36,36 @@ app.use((req, res, next) => {
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
const status = err.status || err.statusCode || 500;
|
||||||
|
const message = err.message || "Internal Server Error";
|
||||||
|
|
||||||
|
res.status(status).json({ message });
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize routes
|
||||||
|
let server: any;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const server = await registerRoutes(app);
|
server = await registerRoutes(app);
|
||||||
|
|
||||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
// Setup Vite in development, serve static in production
|
||||||
const status = err.status || err.statusCode || 500;
|
if (process.env.NODE_ENV === "development") {
|
||||||
const message = err.message || "Internal Server Error";
|
|
||||||
|
|
||||||
res.status(status).json({ message });
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
// importantly only setup vite in development and after
|
|
||||||
// setting up all the other routes so the catch-all route
|
|
||||||
// doesn't interfere with the other routes
|
|
||||||
if (app.get("env") === "development") {
|
|
||||||
await setupVite(app, server);
|
await setupVite(app, server);
|
||||||
} else {
|
} else {
|
||||||
serveStatic(app);
|
serveStatic(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALWAYS serve the app on port 5000
|
|
||||||
// this serves both the API and the client.
|
|
||||||
// It is the only port that is not firewalled.
|
|
||||||
const port = 5000;
|
|
||||||
server.listen({
|
|
||||||
port,
|
|
||||||
host: "0.0.0.0",
|
|
||||||
reusePort: true,
|
|
||||||
}, () => {
|
|
||||||
log(`serving on port ${port}`);
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Vercel serverless function export
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
// Development server (only runs in development)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
const port = 5000;
|
||||||
|
app.listen(port, "0.0.0.0", () => {
|
||||||
|
log(`Development server running on port ${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,28 @@ import { createServer, type Server } from "http";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { setupAuth } from "./auth";
|
import { setupAuth } from "./auth";
|
||||||
import { subscribeToMailchimp } from "./mailchimp";
|
import { subscribeToMailchimp } from "./mailchimp";
|
||||||
import { sendEmail } from "./email";
|
|
||||||
import {
|
|
||||||
insertNewsletterSchema,
|
|
||||||
insertContactMessageSchema,
|
|
||||||
insertBookingSchema
|
|
||||||
} from "@shared/schema";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Simple validation schemas (since we removed the shared schema)
|
||||||
|
const newsletterSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
agreedToTerms: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactMessageSchema = z.object({
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
subject: z.string().min(2).max(200).optional(),
|
||||||
|
message: z.string().min(10).max(2000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingSchema = z.object({
|
||||||
|
classId: z.number(),
|
||||||
|
date: z.string(),
|
||||||
|
paid: z.boolean().default(false),
|
||||||
|
status: z.string().default("pending")
|
||||||
|
});
|
||||||
|
|
||||||
export async function registerRoutes(app: Express): Promise<Server> {
|
export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
// Set up authentication routes
|
// Set up authentication routes
|
||||||
setupAuth(app);
|
setupAuth(app);
|
||||||
|
|
@ -53,7 +67,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bookings = await storage.getBookings(req.user.id);
|
const user = req.user as any;
|
||||||
|
const bookings = await storage.getBookings(user.id);
|
||||||
res.json(bookings);
|
res.json(bookings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: "Failed to fetch bookings" });
|
res.status(500).json({ message: "Failed to fetch bookings" });
|
||||||
|
|
@ -66,12 +81,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bookingData = insertBookingSchema.parse({
|
const user = req.user as any;
|
||||||
...req.body,
|
const bookingData = bookingSchema.parse(req.body);
|
||||||
userId: req.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const booking = await storage.createBooking(bookingData);
|
const booking = await storage.createBooking({
|
||||||
|
...bookingData,
|
||||||
|
userId: user.id,
|
||||||
|
date: new Date(bookingData.date)
|
||||||
|
});
|
||||||
res.status(201).json(booking);
|
res.status(201).json(booking);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|
@ -84,7 +101,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
// Newsletter signup
|
// Newsletter signup
|
||||||
app.post("/api/newsletter", async (req, res) => {
|
app.post("/api/newsletter", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const newsletterData = insertNewsletterSchema.parse(req.body);
|
const newsletterData = newsletterSchema.parse(req.body);
|
||||||
|
|
||||||
// Check if email already exists in our local database
|
// Check if email already exists in our local database
|
||||||
const existingNewsletter = await storage.getNewsletterByEmail(newsletterData.email);
|
const existingNewsletter = await storage.getNewsletterByEmail(newsletterData.email);
|
||||||
|
|
@ -120,20 +137,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
// Contact form
|
// Contact form
|
||||||
app.post("/api/contact", async (req, res) => {
|
app.post("/api/contact", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const contactData = insertContactMessageSchema.parse(req.body);
|
const contactData = contactMessageSchema.parse(req.body);
|
||||||
|
|
||||||
// Always store in database first
|
// Store in database
|
||||||
const message = await storage.createContactMessage(contactData);
|
const message = await storage.createContactMessage(contactData);
|
||||||
|
|
||||||
// Add a success message with clear next steps for Fadia
|
// Log the contact request
|
||||||
console.log(`Contact form submission stored in database from ${contactData.name} (${contactData.email})`);
|
console.log(`Contact form submission from ${contactData.name} (${contactData.email})`);
|
||||||
console.log(`Subject: ${contactData.subject || "No subject"}`);
|
console.log(`Subject: ${contactData.subject || "No subject"}`);
|
||||||
console.log(`This message will be forwarded to hello@pilateswithfadia.com`);
|
console.log(`Message: ${contactData.message}`);
|
||||||
|
|
||||||
// Important information for the user in the response
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: "Message sent successfully",
|
message: "Message sent successfully",
|
||||||
info: "Your message has been received and will be sent to hello@pilateswithfadia.com"
|
info: "Your message has been received and will be reviewed shortly."
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|
@ -143,44 +159,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Instagram Feed
|
|
||||||
app.get("/api/instagram-feed", async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const instagramAccessToken = process.env.INSTAGRAM_ACCESS_TOKEN;
|
|
||||||
|
|
||||||
if (!instagramAccessToken) {
|
|
||||||
return res.status(500).json({
|
|
||||||
message: "Instagram access token not configured",
|
|
||||||
error: "INSTAGRAM_ACCESS_TOKEN environment variable is required"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch recent posts from Instagram Basic Display API
|
|
||||||
const response = await fetch(
|
|
||||||
`https://graph.instagram.com/me/media?fields=id,media_type,media_url,permalink,caption,timestamp&access_token=${instagramAccessToken}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Instagram API error: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Filter out only images and videos, exclude carousels for simplicity
|
|
||||||
const posts = data.data?.filter((post: any) =>
|
|
||||||
post.media_type === 'IMAGE' || post.media_type === 'VIDEO'
|
|
||||||
).slice(0, 12) || []; // Limit to 12 most recent posts
|
|
||||||
|
|
||||||
res.json(posts);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Instagram API error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
message: "Failed to fetch Instagram posts",
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,82 @@
|
||||||
import { users, classes, bookings, newsletters, contactMessages } from "@shared/schema";
|
|
||||||
import type {
|
|
||||||
User, InsertUser,
|
|
||||||
Class, InsertClass,
|
|
||||||
Booking, InsertBooking,
|
|
||||||
Newsletter, InsertNewsletter,
|
|
||||||
ContactMessage, InsertContactMessage
|
|
||||||
} from "@shared/schema";
|
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import createMemoryStore from "memorystore";
|
import createMemoryStore from "memorystore";
|
||||||
|
|
||||||
const MemoryStore = createMemoryStore(session);
|
const MemoryStore = createMemoryStore(session);
|
||||||
|
|
||||||
|
// Simple TypeScript interfaces for in-memory data
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
fullName?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Class {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
duration: number;
|
||||||
|
price: number;
|
||||||
|
capacity: number;
|
||||||
|
classType: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Booking {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
classId: number;
|
||||||
|
date: Date;
|
||||||
|
paid: boolean;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Newsletter {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
agreedToTerms: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactMessage {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject?: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
// User Management
|
// User Management
|
||||||
getUser(id: number): Promise<User | undefined>;
|
getUser(id: number): Promise<User | undefined>;
|
||||||
getUserByUsername(username: string): Promise<User | undefined>;
|
getUserByUsername(username: string): Promise<User | undefined>;
|
||||||
getUserByEmail(email: string): Promise<User | undefined>;
|
getUserByEmail(email: string): Promise<User | undefined>;
|
||||||
createUser(user: InsertUser): Promise<User>;
|
createUser(user: Omit<User, 'id' | 'createdAt'>): Promise<User>;
|
||||||
|
|
||||||
// Class Management
|
// Class Management
|
||||||
getClasses(): Promise<Class[]>;
|
getClasses(): Promise<Class[]>;
|
||||||
getClass(id: number): Promise<Class | undefined>;
|
getClass(id: number): Promise<Class | undefined>;
|
||||||
createClass(classData: InsertClass): Promise<Class>;
|
createClass(classData: Omit<Class, 'id'>): Promise<Class>;
|
||||||
|
|
||||||
// Booking Management
|
// Booking Management
|
||||||
getBookings(userId?: number): Promise<Booking[]>;
|
getBookings(userId?: number): Promise<Booking[]>;
|
||||||
getBooking(id: number): Promise<Booking | undefined>;
|
getBooking(id: number): Promise<Booking | undefined>;
|
||||||
createBooking(booking: InsertBooking): Promise<Booking>;
|
createBooking(booking: Omit<Booking, 'id' | 'createdAt'>): Promise<Booking>;
|
||||||
updateBookingStatus(id: number, status: string): Promise<Booking | undefined>;
|
updateBookingStatus(id: number, status: string): Promise<Booking | undefined>;
|
||||||
|
|
||||||
// Newsletter Management
|
// Newsletter Management
|
||||||
getNewsletterByEmail(email: string): Promise<Newsletter | undefined>;
|
getNewsletterByEmail(email: string): Promise<Newsletter | undefined>;
|
||||||
createNewsletter(newsletter: InsertNewsletter): Promise<Newsletter>;
|
createNewsletter(newsletter: Omit<Newsletter, 'id' | 'createdAt'>): Promise<Newsletter>;
|
||||||
|
|
||||||
// Contact Management
|
// Contact Management
|
||||||
createContactMessage(message: InsertContactMessage): Promise<ContactMessage>;
|
createContactMessage(message: Omit<ContactMessage, 'id' | 'createdAt'>): Promise<ContactMessage>;
|
||||||
|
|
||||||
// Session Store
|
// Session Store
|
||||||
sessionStore: session.SessionStore;
|
sessionStore: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemStorage implements IStorage {
|
export class MemStorage implements IStorage {
|
||||||
|
|
@ -52,7 +91,7 @@ export class MemStorage implements IStorage {
|
||||||
currentBookingId: number;
|
currentBookingId: number;
|
||||||
currentNewsletterId: number;
|
currentNewsletterId: number;
|
||||||
currentContactMessageId: number;
|
currentContactMessageId: number;
|
||||||
sessionStore: session.SessionStore;
|
sessionStore: any;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.users = new Map();
|
this.users = new Map();
|
||||||
|
|
@ -92,7 +131,7 @@ export class MemStorage implements IStorage {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(insertUser: InsertUser): Promise<User> {
|
async createUser(insertUser: Omit<User, 'id' | 'createdAt'>): Promise<User> {
|
||||||
const id = this.currentUserId++;
|
const id = this.currentUserId++;
|
||||||
const user: User = {
|
const user: User = {
|
||||||
...insertUser,
|
...insertUser,
|
||||||
|
|
@ -112,7 +151,7 @@ export class MemStorage implements IStorage {
|
||||||
return this.classes.get(id);
|
return this.classes.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createClass(classData: InsertClass): Promise<Class> {
|
async createClass(classData: Omit<Class, 'id'>): Promise<Class> {
|
||||||
const id = this.currentClassId++;
|
const id = this.currentClassId++;
|
||||||
const newClass: Class = { ...classData, id };
|
const newClass: Class = { ...classData, id };
|
||||||
this.classes.set(id, newClass);
|
this.classes.set(id, newClass);
|
||||||
|
|
@ -132,7 +171,7 @@ export class MemStorage implements IStorage {
|
||||||
return this.bookings.get(id);
|
return this.bookings.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBooking(booking: InsertBooking): Promise<Booking> {
|
async createBooking(booking: Omit<Booking, 'id' | 'createdAt'>): Promise<Booking> {
|
||||||
const id = this.currentBookingId++;
|
const id = this.currentBookingId++;
|
||||||
const newBooking: Booking = {
|
const newBooking: Booking = {
|
||||||
...booking,
|
...booking,
|
||||||
|
|
@ -160,7 +199,7 @@ export class MemStorage implements IStorage {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createNewsletter(newsletter: InsertNewsletter): Promise<Newsletter> {
|
async createNewsletter(newsletter: Omit<Newsletter, 'id' | 'createdAt'>): Promise<Newsletter> {
|
||||||
const id = this.currentNewsletterId++;
|
const id = this.currentNewsletterId++;
|
||||||
const newNewsletter: Newsletter = {
|
const newNewsletter: Newsletter = {
|
||||||
...newsletter,
|
...newsletter,
|
||||||
|
|
@ -172,7 +211,7 @@ export class MemStorage implements IStorage {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contact Management
|
// Contact Management
|
||||||
async createContactMessage(message: InsertContactMessage): Promise<ContactMessage> {
|
async createContactMessage(message: Omit<ContactMessage, 'id' | 'createdAt'>): Promise<ContactMessage> {
|
||||||
const id = this.currentContactMessageId++;
|
const id = this.currentContactMessageId++;
|
||||||
const newMessage: ContactMessage = {
|
const newMessage: ContactMessage = {
|
||||||
...message,
|
...message,
|
||||||
|
|
@ -185,7 +224,7 @@ export class MemStorage implements IStorage {
|
||||||
|
|
||||||
// Seed default data
|
// Seed default data
|
||||||
private seedClasses() {
|
private seedClasses() {
|
||||||
const defaultClasses: InsertClass[] = [
|
const defaultClasses: Omit<Class, 'id'>[] = [
|
||||||
{
|
{
|
||||||
name: "Mat Pilates",
|
name: "Mat Pilates",
|
||||||
description: "A foundational class focusing on core strength, proper alignment, and mindful movement patterns.",
|
description: "A foundational class focusing on core strength, proper alignment, and mindful movement patterns.",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { createServer as createViteServer, createLogger } from "vite";
|
import { createServer as createViteServer, createLogger } from "vite";
|
||||||
import { type Server } from "http";
|
import { type Server } from "http";
|
||||||
import viteConfig from "../vite.config";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
const viteLogger = createLogger();
|
const viteLogger = createLogger();
|
||||||
|
|
@ -20,15 +19,23 @@ export function log(message: string, source = "express") {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupVite(app: Express, server: Server) {
|
export async function setupVite(app: Express, server: Server) {
|
||||||
const serverOptions = {
|
|
||||||
middlewareMode: true,
|
|
||||||
hmr: { server },
|
|
||||||
allowedHosts: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const vite = await createViteServer({
|
const vite = await createViteServer({
|
||||||
...viteConfig,
|
|
||||||
configFile: false,
|
configFile: false,
|
||||||
|
root: path.resolve(import.meta.dirname, "..", "client"),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(import.meta.dirname, "..", "client", "src"),
|
||||||
|
"@shared": path.resolve(import.meta.dirname, "..", "shared"),
|
||||||
|
"@assets": path.resolve(import.meta.dirname, "..", "attached_assets"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
hmr: false,
|
||||||
|
middlewareMode: true
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@vite/client']
|
||||||
|
},
|
||||||
customLogger: {
|
customLogger: {
|
||||||
...viteLogger,
|
...viteLogger,
|
||||||
error: (msg, options) => {
|
error: (msg, options) => {
|
||||||
|
|
@ -36,7 +43,6 @@ export async function setupVite(app: Express, server: Server) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: serverOptions,
|
|
||||||
appType: "custom",
|
appType: "custom",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -68,7 +74,7 @@ export async function setupVite(app: Express, server: Server) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serveStatic(app: Express) {
|
export function serveStatic(app: Express) {
|
||||||
const distPath = path.resolve(import.meta.dirname, "public");
|
const distPath = path.resolve(import.meta.dirname, "..", "client", "dist");
|
||||||
|
|
||||||
if (!fs.existsSync(distPath)) {
|
if (!fs.existsSync(distPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { pgTable, text, serial, integer, boolean, timestamp } from "drizzle-orm/pg-core";
|
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
username: text("username").notNull().unique(),
|
|
||||||
email: text("email").notNull().unique(),
|
|
||||||
password: text("password").notNull(),
|
|
||||||
fullName: text("full_name"),
|
|
||||||
createdAt: timestamp("created_at").defaultNow()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const classes = pgTable("classes", {
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
description: text("description").notNull(),
|
|
||||||
duration: integer("duration").notNull(), // in minutes
|
|
||||||
price: integer("price").notNull(), // in cents
|
|
||||||
capacity: integer("capacity").notNull(),
|
|
||||||
classType: text("class_type").notNull(), // "group", "small-group", "private"
|
|
||||||
imageUrl: text("image_url")
|
|
||||||
});
|
|
||||||
|
|
||||||
export const bookings = pgTable("bookings", {
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
userId: integer("user_id").notNull(),
|
|
||||||
classId: integer("class_id").notNull(),
|
|
||||||
date: timestamp("date").notNull(),
|
|
||||||
paid: boolean("paid").default(false),
|
|
||||||
status: text("status").notNull().default("pending"), // pending, confirmed, cancelled
|
|
||||||
createdAt: timestamp("created_at").defaultNow()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const newsletters = pgTable("newsletters", {
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
email: text("email").notNull().unique(),
|
|
||||||
agreedToTerms: boolean("agreed_to_terms").notNull(),
|
|
||||||
createdAt: timestamp("created_at").defaultNow()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const contactMessages = pgTable("contact_messages", {
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
email: text("email").notNull(),
|
|
||||||
subject: text("subject"),
|
|
||||||
message: text("message").notNull(),
|
|
||||||
createdAt: timestamp("created_at").defaultNow()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create insert schemas
|
|
||||||
export const insertUserSchema = createInsertSchema(users)
|
|
||||||
.omit({ id: true, createdAt: true });
|
|
||||||
|
|
||||||
export const insertClassSchema = createInsertSchema(classes)
|
|
||||||
.omit({ id: true });
|
|
||||||
|
|
||||||
export const insertBookingSchema = createInsertSchema(bookings)
|
|
||||||
.omit({ id: true, createdAt: true });
|
|
||||||
|
|
||||||
export const insertNewsletterSchema = createInsertSchema(newsletters)
|
|
||||||
.omit({ id: true, createdAt: true });
|
|
||||||
|
|
||||||
export const insertContactMessageSchema = createInsertSchema(contactMessages)
|
|
||||||
.omit({ id: true, createdAt: true });
|
|
||||||
|
|
||||||
// Auth schemas
|
|
||||||
export const loginSchema = z.object({
|
|
||||||
username: z.string().min(1, "Username is required"),
|
|
||||||
password: z.string().min(1, "Password is required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Type exports
|
|
||||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
|
||||||
export type User = typeof users.$inferSelect;
|
|
||||||
|
|
||||||
export type InsertClass = z.infer<typeof insertClassSchema>;
|
|
||||||
export type Class = typeof classes.$inferSelect;
|
|
||||||
|
|
||||||
export type InsertBooking = z.infer<typeof insertBookingSchema>;
|
|
||||||
export type Booking = typeof bookings.$inferSelect;
|
|
||||||
|
|
||||||
export type InsertNewsletter = z.infer<typeof insertNewsletterSchema>;
|
|
||||||
export type Newsletter = typeof newsletters.$inferSelect;
|
|
||||||
|
|
||||||
export type InsertContactMessage = z.infer<typeof insertContactMessageSchema>;
|
|
||||||
export type ContactMessage = typeof contactMessages.$inferSelect;
|
|
||||||
|
|
||||||
export type Login = z.infer<typeof loginSchema>;
|
|
||||||
|
|
@ -2,7 +2,74 @@ import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
|
content: [
|
||||||
|
"./client/index.html",
|
||||||
|
"./client/src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./client/src/**/*.{html,js,jsx,ts,tsx}",
|
||||||
|
"./client/src/components/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./client/src/pages/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./client/src/hooks/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./client/src/lib/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
safelist: [
|
||||||
|
// Layout
|
||||||
|
'container', 'flex', 'grid', 'block', 'inline-block', 'hidden',
|
||||||
|
// Spacing
|
||||||
|
'p-0', 'p-1', 'p-2', 'p-3', 'p-4', 'p-5', 'p-6', 'p-8', 'p-10', 'p-12', 'p-16', 'p-20',
|
||||||
|
'px-0', 'px-1', 'px-2', 'px-3', 'px-4', 'px-5', 'px-6', 'px-8', 'px-10', 'px-12', 'px-16', 'px-20',
|
||||||
|
'py-0', 'py-1', 'py-2', 'py-3', 'py-4', 'py-5', 'py-6', 'py-8', 'py-10', 'py-12', 'py-16', 'py-20',
|
||||||
|
'm-0', 'm-1', 'm-2', 'm-3', 'm-4', 'm-5', 'm-6', 'm-8', 'm-10', 'm-12', 'm-16', 'm-20',
|
||||||
|
'mx-auto', 'my-auto',
|
||||||
|
// Colors
|
||||||
|
'bg-white', 'bg-black', 'bg-gray-50', 'bg-gray-100', 'bg-gray-200', 'bg-gray-300',
|
||||||
|
'text-white', 'text-black', 'text-gray-500', 'text-gray-600', 'text-gray-700', 'text-gray-800', 'text-gray-900',
|
||||||
|
'bg-teal', 'bg-purple', 'bg-rose', 'text-teal', 'text-purple', 'text-rose',
|
||||||
|
'bg-teal-light', 'bg-purple-light', 'bg-rose-light',
|
||||||
|
'bg-opacity-20', 'bg-opacity-30', 'bg-opacity-90',
|
||||||
|
'text-opacity-70', 'text-opacity-80', 'text-opacity-100',
|
||||||
|
'border-opacity-20', 'border-opacity-30',
|
||||||
|
// Typography
|
||||||
|
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl',
|
||||||
|
'font-normal', 'font-medium', 'font-semibold', 'font-bold',
|
||||||
|
'text-left', 'text-center', 'text-right',
|
||||||
|
'font-playfair', 'font-raleway', 'font-aref', 'font-cairo',
|
||||||
|
// Flexbox
|
||||||
|
'flex-row', 'flex-col', 'items-start', 'items-center', 'items-end', 'items-stretch',
|
||||||
|
'justify-start', 'justify-center', 'justify-end', 'justify-between', 'justify-around',
|
||||||
|
'gap-1', 'gap-2', 'gap-3', 'gap-4', 'gap-5', 'gap-6', 'gap-8', 'gap-10', 'gap-12',
|
||||||
|
// Grid
|
||||||
|
'grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4',
|
||||||
|
// Width/Height
|
||||||
|
'w-full', 'w-auto', 'w-1/2', 'w-1/3', 'w-2/3', 'w-1/4', 'w-3/4',
|
||||||
|
'h-full', 'h-auto', 'h-screen', 'h-svh',
|
||||||
|
'max-w-sm', 'max-w-md', 'max-w-lg', 'max-w-xl', 'max-w-2xl', 'max-w-3xl', 'max-w-4xl', 'max-w-5xl', 'max-w-7xl',
|
||||||
|
// Borders
|
||||||
|
'border', 'border-0', 'border-2', 'border-4',
|
||||||
|
'rounded', 'rounded-sm', 'rounded-md', 'rounded-lg', 'rounded-xl', 'rounded-full',
|
||||||
|
'border-gray-200', 'border-gray-300', 'border-teal', 'border-purple', 'border-rose',
|
||||||
|
// Shadows
|
||||||
|
'shadow-sm', 'shadow', 'shadow-md', 'shadow-lg', 'shadow-xl',
|
||||||
|
// Position
|
||||||
|
'relative', 'absolute', 'fixed', 'sticky',
|
||||||
|
'top-0', 'top-1/2', 'top-full',
|
||||||
|
'bottom-0', 'bottom-1/2',
|
||||||
|
'left-0', 'left-1/2',
|
||||||
|
'right-0', 'right-1/2',
|
||||||
|
'inset-0', 'inset-x-0', 'inset-y-0',
|
||||||
|
// Z-index
|
||||||
|
'z-10', 'z-20', 'z-50',
|
||||||
|
// Overflow
|
||||||
|
'overflow-hidden', 'overflow-auto', 'overflow-x-auto', 'overflow-y-auto',
|
||||||
|
// Display
|
||||||
|
'block', 'inline-block', 'inline', 'flex', 'inline-flex', 'grid', 'hidden',
|
||||||
|
// Transitions
|
||||||
|
'transition-all', 'transition-colors', 'transition-transform', 'transition-opacity',
|
||||||
|
'duration-200', 'duration-300',
|
||||||
|
// Hover states
|
||||||
|
'hover:bg-opacity-90', 'hover:text-white', 'hover:text-opacity-100', 'hover:scale-105',
|
||||||
|
// Responsive prefixes
|
||||||
|
'sm:', 'md:', 'lg:', 'xl:', '2xl:',
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
|
|
@ -70,20 +137,12 @@ export default {
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"accordion-down": {
|
"accordion-down": {
|
||||||
from: {
|
from: { height: "0" },
|
||||||
height: "0",
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
},
|
|
||||||
to: {
|
|
||||||
height: "var(--radix-accordion-content-height)",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"accordion-up": {
|
"accordion-up": {
|
||||||
from: {
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
height: "var(--radix-accordion-content-height)",
|
to: { height: "0" },
|
||||||
},
|
|
||||||
to: {
|
|
||||||
height: "0",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
|
@ -92,5 +151,5 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
plugins: [require("tailwindcss-animate")],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple deployment test script
|
||||||
|
* This script tests the basic functionality of the Cloudflare Pages deployment
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
function makeRequest(url, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = url.startsWith('https') ? https : http;
|
||||||
|
|
||||||
|
const req = client.request(url, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
}, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const jsonData = data ? JSON.parse(data) : null;
|
||||||
|
resolve({ status: res.statusCode, data: jsonData, headers: res.headers });
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ status: res.statusCode, data, headers: res.headers });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
if (options.body) {
|
||||||
|
req.write(JSON.stringify(options.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testDeployment(baseUrl) {
|
||||||
|
console.log(`Testing deployment at: ${baseUrl}`);
|
||||||
|
console.log('='.repeat(50));
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
{
|
||||||
|
name: 'Homepage loads',
|
||||||
|
test: () => makeRequest(`${baseUrl}/`),
|
||||||
|
expected: (result) => result.status === 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Classes API endpoint',
|
||||||
|
test: () => makeRequest(`${baseUrl}/api/classes`),
|
||||||
|
expected: (result) => result.status === 200 && Array.isArray(result.data)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Newsletter API endpoint (POST)',
|
||||||
|
test: () => makeRequest(`${baseUrl}/api/newsletter`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
email: 'test@example.com',
|
||||||
|
agreedToTerms: true
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
expected: (result) => result.status === 201 || result.status === 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Contact API endpoint (POST)',
|
||||||
|
test: () => makeRequest(`${baseUrl}/api/contact`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
message: 'This is a test message'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
expected: (result) => result.status === 201
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
try {
|
||||||
|
console.log(`Testing: ${test.name}...`);
|
||||||
|
const result = await test.test();
|
||||||
|
|
||||||
|
if (test.expected(result)) {
|
||||||
|
console.log(`✅ PASSED: ${test.name}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(`❌ FAILED: ${test.name}`);
|
||||||
|
console.log(` Status: ${result.status}`);
|
||||||
|
console.log(` Response: ${JSON.stringify(result.data, null, 2)}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ ERROR: ${test.name}`);
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('='.repeat(50));
|
||||||
|
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
|
console.log('🎉 All tests passed! Your deployment is working correctly.');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Some tests failed. Please check the deployment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return failed === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get URL from command line argument or use default
|
||||||
|
const baseUrl = process.argv[2] || 'http://localhost:5173';
|
||||||
|
|
||||||
|
testDeployment(baseUrl)
|
||||||
|
.then(success => process.exit(success ? 0 : 1))
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Test script error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"buildCommand": "npm ci && npm run build",
|
||||||
|
"outputDirectory": "client/dist",
|
||||||
|
"installCommand": "npm ci",
|
||||||
|
"functions": {
|
||||||
|
"api/index.ts": {
|
||||||
|
"maxDuration": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/api/(.*)",
|
||||||
|
"dest": "/api/index.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/(.*)",
|
||||||
|
"dest": "/assets/$1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/(.*\\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot))",
|
||||||
|
"dest": "/$1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/(.*)",
|
||||||
|
"dest": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import path from "path";
|
|
||||||
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
runtimeErrorOverlay(),
|
|
||||||
...(process.env.NODE_ENV !== "production" &&
|
|
||||||
process.env.REPL_ID !== undefined
|
|
||||||
? [
|
|
||||||
await import("@replit/vite-plugin-cartographer").then((m) =>
|
|
||||||
m.cartographer(),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(import.meta.dirname, "client", "src"),
|
|
||||||
"@shared": path.resolve(import.meta.dirname, "shared"),
|
|
||||||
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
root: path.resolve(import.meta.dirname, "client"),
|
|
||||||
build: {
|
|
||||||
outDir: path.resolve(import.meta.dirname, "dist/public"),
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
name = "pilates-with-fadia"
|
||||||
|
compatibility_date = "2024-01-15"
|
||||||
|
pages_build_output_dir = "client/dist"
|
||||||
|
|
||||||
|
[env.production]
|
||||||
|
name = "pilates-with-fadia"
|
||||||
|
|
||||||
|
[env.preview]
|
||||||
|
name = "pilates-with-fadia-preview"
|
||||||
|
|
||||||
|
# KV Namespaces for data storage
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "STORAGE"
|
||||||
|
id = "your-kv-namespace-id"
|
||||||
|
preview_id = "your-preview-kv-namespace-id"
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
[vars]
|
||||||
|
NODE_ENV = "production"
|
||||||