Compare commits

...

47 Commits

Author SHA1 Message Date
Jeff Emmett 469e589489 update to run on cloudflare pages 2025-10-13 22:24:14 -04:00
Jeff Emmett 00eec1ce65 update newsletter 2025-06-19 17:41:08 +02:00
Jeff Emmett 9c1c83b51b add css in client 2025-06-19 17:37:45 +02:00
Jeff Emmett f895168357 fix react and css 2025-06-19 17:32:39 +02:00
Jeff Emmett 3195e98438 fix tailwind 2025-06-19 16:46:18 +02:00
Jeff Emmett f1a1fa192b Add static assets to vercel.json 2025-06-19 16:40:26 +02:00
Jeff Emmett ce3d446b36 fix tailwind 2025-06-19 16:33:45 +02:00
Jeff Emmett 3644673990 fix vercel and css 2025-06-19 16:32:15 +02:00
Jeff Emmett 4f8481101a fix vercel 2025-06-19 16:30:42 +02:00
Jeff Emmett beddf93e86 fix vercel 2025-06-19 16:26:49 +02:00
Jeff Emmett e21a352562 Fix: remove client package.json to eliminate dependency confusion 2025-06-19 14:53:44 +02:00
Jeff Emmett 7a9e4ce290
Merge pull request #14 from Jeff-Emmett/main
update branch!
2025-06-19 14:43:37 +02:00
Jeff Emmett 04121a1a85
Merge pull request #13 from Jeff-Emmett/new-website
Fix Vercel build: run Vite from root and use static classes data
2025-06-19 14:42:55 +02:00
Jeff Emmett 7fbc22e430 Fix Vercel build: run Vite from root and use static classes data 2025-06-19 14:42:03 +02:00
Jeff Emmett e81faf384f
Merge pull request #12 from Jeff-Emmett/new-website
New website
2025-06-19 14:39:47 +02:00
Jeff Emmett e676d90d8b update class cards 2025-06-19 14:38:45 +02:00
Jeff Emmett d8688b012a Update vite 2025-06-19 14:33:30 +02:00
Jeff Emmett a7ed7dba30
Merge pull request #11 from Jeff-Emmett/new-website
Fix build: only run npm ci at root, not in client
2025-06-19 14:29:45 +02:00
Jeff Emmett ec267696dc Fix build: only run npm ci at root, not in client 2025-06-19 14:29:09 +02:00
Jeff Emmett 4ea1d1c32f
Merge pull request #10 from Jeff-Emmett/new-website
Monorepo: move all devDependencies to root for Vercel build
2025-06-19 14:27:14 +02:00
Jeff Emmett fcae3e4841 Monorepo: move all devDependencies to root for Vercel build 2025-06-19 14:26:25 +02:00
Jeff Emmett 025c59a9df
Merge pull request #9 from Jeff-Emmett/new-website
Fix Vercel build: install client dependencies explicitly in buildCommand
2025-06-19 14:20:42 +02:00
Jeff Emmett 34c2bf1e54 Fix Vercel build: install client dependencies explicitly in buildCommand 2025-06-19 14:19:55 +02:00
Jeff Emmett 13b1952749
Merge pull request #8 from Jeff-Emmett/new-website
Fix Vercel build configuration and API structure
2025-06-19 14:16:58 +02:00
Jeff Emmett 3aecfd8e96 Fix Vercel build configuration and API structure 2025-06-19 14:16:14 +02:00
Jeff Emmett 862a8ca995
Merge pull request #7 from Jeff-Emmett/new-website
Create Vercel-compatible API structure with api/index.ts
2025-06-19 13:06:59 +02:00
Jeff Emmett 8d48036b32 Create Vercel-compatible API structure with api/index.ts 2025-06-19 13:05:51 +02:00
Jeff Emmett a44ecf888a
Merge pull request #6 from Jeff-Emmett/new-website
Fix Vercel deployment to serve React client instead of server code
2025-06-19 13:02:43 +02:00
Jeff Emmett 35d7a42e32 Fix Vercel configuration: use functions instead of builds to avoid conflict 2025-06-19 13:00:17 +02:00
Jeff Emmett 4b941ff318 Fix Vercel deployment to serve React client instead of server code 2025-06-19 12:57:39 +02:00
Jeff Emmett 0df281f799
Merge pull request #5 from Jeff-Emmett/new-website
update yml
2025-06-19 12:49:04 +02:00
Jeff Emmett 536c1dddc3 update vercel deployment 2025-06-19 12:48:44 +02:00
Jeff Emmett 61992077ec update yml 2025-06-19 12:42:32 +02:00
Jeff Emmett 79a004ea9b
Merge pull request #4 from Jeff-Emmett/new-website
backend vite setup
2025-06-19 12:25:34 +02:00
Jeff Emmett 1ab9bea8de backend vite setup 2025-06-19 12:20:54 +02:00
Jeff Emmett c08c251087
Merge pull request #3 from Jeff-Emmett/new-website
New website updates
2025-06-19 11:25:14 +02:00
Jeff Emmett 8bd1a3f3e8 schema adjustments 2025-06-19 11:23:37 +02:00
Jeff Emmett 83de08ddd9 new website updates 2025-06-19 11:23:10 +02:00
Jeff Emmett 5d6d842a23
Delete CNAME 2025-06-19 11:22:29 +02:00
Jeff Emmett 894ae18b80
Create CNAME 2025-06-18 14:58:07 +02:00
Jeff Emmett b14468e5c0
Delete CNAME 2025-06-18 14:57:46 +02:00
Jeff Emmett c399a12a53
Merge pull request #2 from Jeff-Emmett/new-website
website updates
2025-06-18 12:15:42 +02:00
Jeff Emmett a1d010c71e website updates 2025-06-18 12:14:07 +02:00
Jeff Emmett ba9597b77f
Merge pull request #1 from Jeff-Emmett/new-website
Updates to website
2025-06-15 18:59:32 +02:00
Jeff Emmett dcbfe10534 Updates to website 2025-06-15 18:59:03 +02:00
Jeff Emmett e0012188b7
Create CNAME 2025-06-15 18:10:10 +02:00
Jeff Emmett 0261caf965 update website 2025-06-15 17:52:42 +02:00
77 changed files with 89638 additions and 4625 deletions

View File

@ -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][INFO]: 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][INFO]: Version: 1.2.0
[00.07][INFO]: Parsing rules in /tmp/tmp6vt4ey5_.json
[00.82][INFO]: scan: processing 303 files (skipping 0), with 487 rules (skipping 0 )
[03.08][INFO]: Custom ignore pattern: None
[03.08][INFO]: 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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
has_shown_metrics_notification: true
anonymous_user_id: e3c333de-dabb-4909-a90d-30c3616cc5f8

9
.gitignore vendored
View File

@ -3,4 +3,11 @@ dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
*.tar.gz
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

144
DEPLOYMENT.md Normal file
View File

@ -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

131
MIGRATION_SUMMARY.md Normal file
View File

@ -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

165
README.md Normal file
View File

@ -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

203
api/index.ts Normal file
View File

@ -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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -2,21 +2,36 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Pilates with Fadia | Feel at Home in Your Body</title>
<meta name="description" content="Online pilates classes to help you feel stronger and more connected to your body and breath." />
<!-- Open Graph tags -->
<meta property="og:title" content="Pilates with Fadia | Feel at Home in Your Body" />
<meta property="og:description" content="Online pilates classes to help you feel stronger and more connected to your body and breath." />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Pilates with Fadia - Transform your body and mind with professional Pilates instruction in a welcoming environment." />
<meta name="keywords" content="pilates, fitness, exercise, wellness, studio, classes, reformer, mat pilates" />
<meta name="author" content="Fadia" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://pilateswithfadia.com" />
<!-- Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" />
<meta property="og:url" content="https://pilateswithfadia.com/" />
<meta property="og:title" content="Pilates with Fadia" />
<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>
<body>
<div id="root"></div>
<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>
</html>

76
client/package.json Normal file
View File

@ -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"
}
}

6
client/postcss.config.js Normal file
View File

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

View File

@ -1,3 +1,4 @@
import React from "react";
import { Switch, Route } from "wouter";
import { queryClient } from "./lib/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";

View File

@ -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";
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="flex flex-col md:flex-row items-center">
<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 */}
<div>
<img
@ -32,7 +18,7 @@ export function AboutSection() {
<div className="md:w-1/2">
<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">
I came to movement as a way to feel good in my body and begin healing emotions I hadn't yet processed. It started simplywith home yoga classesand slowly turned into something much deeper.
</p>

View File

@ -1,5 +1,6 @@
import React from "react";
import { Link } from "wouter";
import FadiaImage from "@assets/Fadia-167-crop_1749865267638.jpg";
import FadiaImage from "@assets/Fadia-167-crop.jpg";
export function HomeAboutSection() {
return (
@ -16,7 +17,7 @@ export function HomeAboutSection() {
<div className="md:w-1/2">
<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">
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>

View File

@ -1,6 +1,6 @@
import { useState } from "react";
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 { useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";

View File

@ -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 PilatesClassImage from "@assets/pilates_class_1749837680834.jpeg";
import FadiaPrivateImage from "@assets/Fadia-7_1749842141071.jpg";
import FadiaBallImage from "@assets/fadia-ball_1749842241591.jpg";
interface ClassCardProps {
classData: Class;
classData: StaticClass;
}
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 (
<div className="bg-white rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:transform hover:scale-105">
<img
src={getClassImage()}
alt={classData.name}
className="w-full h-50 object-cover"
/>
<div className="p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-playfair font-bold text-lg leading-tight">{classData.name}</h3>
<span className={`${badgeColor()} text-xs px-2 py-1 rounded-full font-semibold ml-2 flex-shrink-0`}>
{formatClassType(classData.classType)}
</span>
<a {...linkProps} className="block">
<div className="bg-white rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:transform hover:scale-105">
<img
src={getClassImage()}
alt={classData.name}
className="w-full h-48 object-cover"
/>
<div className="p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-playfair font-bold text-lg leading-tight">{classData.name}</h3>
<span className={`${badgeColor()} text-xs px-2 py-1 rounded-full font-semibold ml-2 flex-shrink-0`}>
{formatClassType(classData.classType)}
</span>
</div>
<p className="text-gray-600 text-sm leading-snug">
{classData.description}
</p>
</div>
<p className="text-gray-600 text-sm leading-snug">
{classData.description}
</p>
</div>
</div>
</a>
);
}

View File

@ -1,15 +1,8 @@
import React from "react";
import { ClassCard } from "./class-card";
import { useQuery } from "@tanstack/react-query";
import { Class } from "@shared/schema";
import { Skeleton } from "@/components/ui/skeleton";
import FadiaClassImage from "../../assets/Fadia-156.jpg";
import { STATIC_CLASSES } from "@/lib/static-data";
export function ClassesSection() {
const { data: classes, isLoading, error } = useQuery<Class[]>({
queryKey: ["/api/classes"],
});
return (
<section className="py-20 bg-white">
<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>
</div>
{/* Class description boxes removed */}
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[1, 2, 3, 4].map((_, i) => (
<div key={i} className="bg-white rounded-lg overflow-hidden shadow-lg">
<Skeleton className="w-full h-36" />
<div className="p-4">
<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="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{STATIC_CLASSES.map((classData) => (
<ClassCard
key={classData.id}
classData={classData}
/>
))}
</div>
<div className="text-center mt-12">
<a href="https://www.momoyoga.com/pilates-with-fadia/schedule" target="_blank" rel="noopener noreferrer" className="inline-block">

View File

@ -1,3 +1,5 @@
import React from "react";
interface TestimonialProps {
quote: string;
author: string;
@ -8,12 +10,14 @@ interface TestimonialProps {
export function Testimonial({ quote, author, memberSince, initials, color }: TestimonialProps) {
return (
<div className="bg-transparent text-left">
<p className="text-gray-700 mb-6 italic">
"{quote}"
</p>
<div className="bg-transparent text-left flex flex-col h-full">
<div className="flex-grow">
<p className="text-gray-700 mb-6 italic">
"{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}`}>
<span>{initials}</span>
</div>

View File

@ -1,3 +1,5 @@
import React from "react";
export function ContactSection() {
return (
<section className="pt-6 pb-12 bg-white">

View File

@ -2,7 +2,7 @@ import { Link } from "wouter";
export function CTASection() {
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">
<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?

View File

@ -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>
);
}

View File

@ -1,3 +1,4 @@
import React from "react";
import { Link } from "wouter";
import FadiaHeroImage from "../../assets/Fadia-15.jpg";

View File

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { Logo } from "@/components/ui/logo";
import { Link } from "wouter";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
@ -126,7 +126,9 @@ export default function Footer() {
<ul className="space-y-3">
<li className="flex items-start">
<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 className="flex items-center">
<i className="fas fa-envelope mr-2 text-white"></i>

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import React, { useState } from "react";
import { Link, useLocation } from "wouter";
import { Logo } from "@/components/ui/logo";

View File

@ -1,7 +1,7 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { insertNewsletterSchema } from "@shared/schema";
import { insertNewsletterSchema } from "@/lib/schema";
import { useToast } from "@/hooks/use-toast";
import { Loader2 } from "lucide-react";
@ -168,7 +168,7 @@ export function NewsletterSection() {
</div>
<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">
<label htmlFor="email" className="mb-2 text-gray-700 font-medium">Email Address</label>
<input

View File

@ -1,24 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
interface InstagramPost {
id: string;
media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
media_url: string;
permalink: string;
caption?: string;
timestamp: string;
}
import { useEffect } from "react";
export function PhotoGallery() {
const [showAllPosts, setShowAllPosts] = useState(false);
const { data: posts, isLoading, error } = useQuery<InstagramPost[]>({
queryKey: ['/api/instagram-feed'],
staleTime: 1000 * 60 * 30, // Cache for 30 minutes
});
const displayPosts = showAllPosts ? posts : posts?.slice(0, 6);
useEffect(() => {
// Load Curator.io script
const script = document.createElement("script");
script.type = "text/javascript";
script.async = true;
script.charset = "UTF-8";
script.src = "https://cdn.curator.io/published/1964cded-8962-41c1-b7c1-d36f02707c7a.js";
// 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 (
<section className="py-12 bg-white">
@ -31,94 +31,10 @@ export function PhotoGallery() {
<div className="flex justify-center">
<div className="w-full max-w-4xl">
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((index) => (
<div key={index} className="aspect-square bg-gray-200 rounded-lg animate-pulse"></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>
)}
{/* Curator.io Instagram Feed */}
<div id="curator-feed-default-feed-layout">
<a href="https://curator.io" target="_blank" className="crt-logo crt-tag">Powered by Curator.io</a>
</div>
</div>
</div>
</div>

View File

@ -1,3 +1,4 @@
import React from "react";
import { Testimonial } from "@/components/community/testimonial";
export function TestimonialsSection() {
@ -10,14 +11,14 @@ export function TestimonialsSection() {
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.",
author: "Ahmed M.",
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: "Sara from Cairo",
memberSince: "",
initials: "AM",
initials: "SC",
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",
memberSince: "",
initials: "LC",
@ -31,7 +32,7 @@ export function TestimonialsSection() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{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
quote={testimonial.quote}
author={testimonial.author}

View File

@ -1,3 +1,4 @@
import React from "react";
import { Link } from "wouter";
import LogoImage from "../../assets/rectangular-logo.png";

View File

@ -1,3 +1,4 @@
import React from "react";
import { useToast } from "@/hooks/use-toast"
import {
Toast,

View File

@ -1,23 +1,22 @@
import { createContext, ReactNode, useContext } from "react";
import React, { createContext, ReactNode, useContext, useEffect, useState } from "react";
import {
useQuery,
useMutation,
UseMutationResult,
} from "@tanstack/react-query";
import { insertUserSchema, User as SelectUser, InsertUser, Login } from "@shared/schema";
import { getQueryFn, apiRequest, queryClient } from "../lib/queryClient";
import { apiRequest, getQueryFn, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
type User = Omit<SelectUser, "password">;
import { insertUserSchema, User, InsertUser, Login } from "@/lib/schema";
import { Loader2 } from "lucide-react";
type AuthContextType = {
user: User | null;
isLoading: boolean;
error: Error | null;
loginMutation: UseMutationResult<User, Error, Login>;
loginMutation: UseMutationResult<{ user: User; token: string }, Error, Login>;
logoutMutation: UseMutationResult<void, Error, void>;
registerMutation: UseMutationResult<User, Error, InsertUser>;
registerMutation: UseMutationResult<{ user: User; token: string }, Error, InsertUser>;
};
export const registrationSchema = insertUserSchema.extend({
@ -34,25 +33,37 @@ export const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const { toast } = useToast();
const [token, setToken] = useState<string | null>(localStorage.getItem('auth_token'));
const {
data: user,
error,
isLoading,
} = useQuery<User | undefined, Error>({
queryKey: ["/api/user"],
queryFn: getQueryFn({ on401: "returnNull" }),
queryKey: ["/api/auth/me"],
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({
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();
},
onSuccess: (user: User) => {
queryClient.setQueryData(["/api/user"], user);
onSuccess: (data: { user: User; token: string }) => {
setToken(data.token);
localStorage.setItem('auth_token', data.token);
queryClient.setQueryData(["/api/auth/me"], data.user);
toast({
title: "Login successful",
description: `Welcome back, ${user.fullName || user.username}!`,
description: `Welcome back, ${data.user.fullName || data.user.username}!`,
});
},
onError: (error: Error) => {
@ -66,11 +77,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const registerMutation = useMutation({
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();
},
onSuccess: (user: User) => {
queryClient.setQueryData(["/api/user"], user);
onSuccess: (data: { user: User; token: string }) => {
setToken(data.token);
localStorage.setItem('auth_token', data.token);
queryClient.setQueryData(["/api/auth/me"], data.user);
toast({
title: "Registration successful",
description: "Your account has been created successfully.",
@ -87,10 +100,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logoutMutation = useMutation({
mutationFn: async () => {
await apiRequest("POST", "/api/logout");
// For JWT, we just remove the token locally
setToken(null);
localStorage.removeItem('auth_token');
},
onSuccess: () => {
queryClient.setQueryData(["/api/user"], null);
queryClient.setQueryData(["/api/auth/me"], null);
toast({
title: "Logged out",
description: "You have been logged out successfully.",

View File

@ -15,11 +15,11 @@
--card-foreground: 20 14.3% 4.1%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--primary: 175 84% 30%; /* teal: #0c8991 */
--primary: 175 84% 30%;
--primary-foreground: 211 100% 99%;
--secondary: 302 24% 49%; /* purple: #9D5E9B */
--secondary: 302 24% 49%;
--secondary-foreground: 24 9.8% 10%;
--accent: 335 38% 51%; /* rose: #B55076 */
--accent: 335 38% 51%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
@ -27,104 +27,25 @@
--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 {
* {
@apply border-border;
@apply border-gray-200;
}
body {
@apply font-sans antialiased bg-background text-foreground;
@apply font-sans antialiased bg-white text-gray-900;
font-family: 'Khula', sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Lato', sans-serif;
}
.subtitle, .card-subtitle, .section-subtitle {
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 {
background-color: #0c8991;
}
@ -161,6 +82,46 @@
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-family: 'Lato', sans-serif;
}
@ -176,3 +137,400 @@
.font-cairo, .subtitle {
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;
}

View File

@ -11,12 +11,22 @@ export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined,
token?: string | null,
): 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, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
headers,
body: data ? JSON.stringify(data) : undefined,
credentials: "include",
});
await throwIfResNotOk(res);
@ -26,11 +36,18 @@ export async function apiRequest(
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior;
token?: string | null;
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
({ on401: unauthorizedBehavior, token }) =>
async ({ queryKey }) => {
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(queryKey[0] as string, {
credentials: "include",
headers,
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
@ -44,11 +61,11 @@ export const getQueryFn: <T>(options: {
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
retry: false,
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
gcTime: 5 * 60 * 1000, // 5 minutes
},
mutations: {
retry: false,

75
client/src/lib/schema.ts Normal file
View File

@ -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>;

View File

@ -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"
}
];

View File

@ -1,5 +1,5 @@
import { z } from "zod";
import { insertUserSchema, insertContactMessageSchema, insertNewsletterSchema } from "@shared/schema";
import { insertUserSchema, insertContactMessageSchema, insertNewsletterSchema } from "@/lib/schema";
/**
* Extended registration schema with password confirmation

View File

@ -1,3 +1,4 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";

View File

@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth, registrationSchema, RegistrationData } from "@/hooks/use-auth";
import { useLocation } from "wouter";
import { loginSchema, Login } from "@shared/schema";
import { loginSchema, Login } from "@/lib/schema";
import {
Form,
FormControl,
@ -44,7 +44,7 @@ export default function AuthPage() {
const loginForm = useForm<Login>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
});
@ -53,9 +53,8 @@ export default function AuthPage() {
const registerForm = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
defaultValues: {
username: "",
email: "",
fullName: "",
name: "",
password: "",
confirmPassword: "",
},
@ -104,12 +103,12 @@ export default function AuthPage() {
<form onSubmit={loginForm.handleSubmit(onLoginSubmit)} className="space-y-6">
<FormField
control={loginForm.control}
name="username"
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Enter your username" {...field} />
<Input placeholder="Enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -164,19 +163,6 @@ export default function AuthPage() {
<CardContent>
<Form {...registerForm}>
<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
control={registerForm.control}
name="email"
@ -192,12 +178,12 @@ export default function AuthPage() {
/>
<FormField
control={registerForm.control}
name="fullName"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormLabel>Name</FormLabel>
<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>
<FormMessage />
</FormItem>

View File

@ -1,3 +1,4 @@
import React, { useEffect } from "react";
import { HeroSection } from "@/components/home/hero-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 { ContactSection } from "@/components/contact/contact-section";
import { CTASection } from "@/components/home/cta-section";
import { useEffect } from "react";
export default function HomePage() {
// Set meta data for SEO
@ -47,7 +46,6 @@ export default function HomePage() {
<HomeAboutSection />
<TestimonialsSection />
<ContactSection />
<CTASection />
</main>
);
}

155
client/tailwind.config.ts Normal file
View File

@ -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;

32
client/vite.config.ts Normal file
View File

@ -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,
},
});

View File

@ -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,
},
});

12
env.cloudflare.example Normal file
View File

@ -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

14
env.example Normal file
View File

@ -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

22
functions/_middleware.ts Normal file
View File

@ -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);
};

272
functions/api/auth.ts Normal file
View File

@ -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' }
});
};

146
functions/api/bookings.ts Normal file
View File

@ -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' }
});
};

99
functions/api/classes.ts Normal file
View File

@ -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' }
});
};

64
functions/api/contact.ts Normal file
View File

@ -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' }
});
};

118
functions/api/newsletter.ts Normal file
View File

@ -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' }
});
};

4221
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,109 +1,26 @@
{
"name": "rest-express",
"name": "pilates-with-fadia",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "NODE_ENV=development tsx server/index.ts",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"dev": "cd client && vite dev",
"build": "cd client && vite build",
"build:client": "cd client && vite build",
"preview": "cd client && vite preview",
"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": {
"@hookform/resolvers": "^3.10.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"
"itty-cors": "^0.1.0"
},
"devDependencies": {
"@replit/vite-plugin-cartographer": "^0.2.7",
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
"@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-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",
"vite": "^5.4.14"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
"wrangler": "^3.0.0"
}
}

View File

@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
}
}

View File

@ -4,12 +4,17 @@ import { Express } from "express";
import session from "express-session";
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";
import { storage } from "./storage";
import { User as SelectUser, insertUserSchema } from "@shared/schema";
import { storage, User } from "./storage";
declare global {
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}`;
}
async function comparePasswords(supplied: string, stored: string) {
const [hashed, salt] = stored.split(".");
const hashedBuf = Buffer.from(hashed, "hex");
const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
return timingSafeEqual(hashedBuf, suppliedBuf);
async function comparePasswords(password: string, hashedPassword: string) {
const [hash, salt] = hashedPassword.split(".");
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
return timingSafeEqual(buf, Buffer.from(hash, "hex"));
}
export function setupAuth(app: Express) {
@ -66,23 +70,34 @@ export function setupAuth(app: Express) {
app.post("/api/register", async (req, res, next) => {
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
const existingUserByUsername = await storage.getUserByUsername(validatedUser.username);
const existingUserByUsername = await storage.getUserByUsername(username);
if (existingUserByUsername) {
return res.status(400).json({ message: "Username already exists" });
}
// Check if email already exists
const existingUserByEmail = await storage.getUserByEmail(validatedUser.email);
const existingUserByEmail = await storage.getUserByEmail(email);
if (existingUserByEmail) {
return res.status(400).json({ message: "Email already in use" });
}
const user = await storage.createUser({
...validatedUser,
password: await hashPassword(validatedUser.password),
username,
email,
password: await hashPassword(password),
fullName
});
req.login(user, (err) => {
@ -98,7 +113,7 @@ export function setupAuth(app: Express) {
});
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 (!user) {
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) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
// Return user without password
const { password, ...userWithoutPassword } = req.user;
res.json(userWithoutPassword);
app.get("/api/me", (req, res) => {
if (req.isAuthenticated()) {
const { password, ...userWithoutPassword } = req.user as User;
res.json(userWithoutPassword);
} else {
res.status(401).json({ message: "Not authenticated" });
}
});
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -36,35 +36,36 @@ app.use((req, res, 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 () => {
const server = await registerRoutes(app);
server = await registerRoutes(app);
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;
});
// 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") {
// Setup Vite in development, serve static in production
if (process.env.NODE_ENV === "development") {
await setupVite(app, server);
} else {
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}`);
});
}

View File

@ -3,14 +3,28 @@ import { createServer, type Server } from "http";
import { storage } from "./storage";
import { setupAuth } from "./auth";
import { subscribeToMailchimp } from "./mailchimp";
import { sendEmail } from "./email";
import {
insertNewsletterSchema,
insertContactMessageSchema,
insertBookingSchema
} from "@shared/schema";
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> {
// Set up authentication routes
setupAuth(app);
@ -53,7 +67,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
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);
} catch (error) {
res.status(500).json({ message: "Failed to fetch bookings" });
@ -66,12 +81,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
try {
const bookingData = insertBookingSchema.parse({
...req.body,
userId: req.user.id
});
const user = req.user as any;
const bookingData = bookingSchema.parse(req.body);
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);
} catch (error) {
if (error instanceof z.ZodError) {
@ -84,7 +101,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Newsletter signup
app.post("/api/newsletter", async (req, res) => {
try {
const newsletterData = insertNewsletterSchema.parse(req.body);
const newsletterData = newsletterSchema.parse(req.body);
// Check if email already exists in our local database
const existingNewsletter = await storage.getNewsletterByEmail(newsletterData.email);
@ -120,20 +137,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Contact form
app.post("/api/contact", async (req, res) => {
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);
// Add a success message with clear next steps for Fadia
console.log(`Contact form submission stored in database from ${contactData.name} (${contactData.email})`);
// Log the contact request
console.log(`Contact form submission from ${contactData.name} (${contactData.email})`);
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({
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) {
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);
return httpServer;
}

View File

@ -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 createMemoryStore from "memorystore";
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 {
// User Management
getUser(id: number): Promise<User | undefined>;
getUserByUsername(username: 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
getClasses(): Promise<Class[]>;
getClass(id: number): Promise<Class | undefined>;
createClass(classData: InsertClass): Promise<Class>;
createClass(classData: Omit<Class, 'id'>): Promise<Class>;
// Booking Management
getBookings(userId?: number): Promise<Booking[]>;
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>;
// Newsletter Management
getNewsletterByEmail(email: string): Promise<Newsletter | undefined>;
createNewsletter(newsletter: InsertNewsletter): Promise<Newsletter>;
createNewsletter(newsletter: Omit<Newsletter, 'id' | 'createdAt'>): Promise<Newsletter>;
// Contact Management
createContactMessage(message: InsertContactMessage): Promise<ContactMessage>;
createContactMessage(message: Omit<ContactMessage, 'id' | 'createdAt'>): Promise<ContactMessage>;
// Session Store
sessionStore: session.SessionStore;
sessionStore: any;
}
export class MemStorage implements IStorage {
@ -52,7 +91,7 @@ export class MemStorage implements IStorage {
currentBookingId: number;
currentNewsletterId: number;
currentContactMessageId: number;
sessionStore: session.SessionStore;
sessionStore: any;
constructor() {
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 user: User = {
...insertUser,
@ -112,7 +151,7 @@ export class MemStorage implements IStorage {
return this.classes.get(id);
}
async createClass(classData: InsertClass): Promise<Class> {
async createClass(classData: Omit<Class, 'id'>): Promise<Class> {
const id = this.currentClassId++;
const newClass: Class = { ...classData, id };
this.classes.set(id, newClass);
@ -132,7 +171,7 @@ export class MemStorage implements IStorage {
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 newBooking: 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 newNewsletter: Newsletter = {
...newsletter,
@ -172,7 +211,7 @@ export class MemStorage implements IStorage {
}
// Contact Management
async createContactMessage(message: InsertContactMessage): Promise<ContactMessage> {
async createContactMessage(message: Omit<ContactMessage, 'id' | 'createdAt'>): Promise<ContactMessage> {
const id = this.currentContactMessageId++;
const newMessage: ContactMessage = {
...message,
@ -185,7 +224,7 @@ export class MemStorage implements IStorage {
// Seed default data
private seedClasses() {
const defaultClasses: InsertClass[] = [
const defaultClasses: Omit<Class, 'id'>[] = [
{
name: "Mat Pilates",
description: "A foundational class focusing on core strength, proper alignment, and mindful movement patterns.",

View File

@ -3,7 +3,6 @@ import fs from "fs";
import path from "path";
import { createServer as createViteServer, createLogger } from "vite";
import { type Server } from "http";
import viteConfig from "../vite.config";
import { nanoid } from "nanoid";
const viteLogger = createLogger();
@ -20,15 +19,23 @@ export function log(message: string, source = "express") {
}
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true,
};
const vite = await createViteServer({
...viteConfig,
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: {
...viteLogger,
error: (msg, options) => {
@ -36,7 +43,6 @@ export async function setupVite(app: Express, server: Server) {
process.exit(1);
},
},
server: serverOptions,
appType: "custom",
});
@ -68,7 +74,7 @@ export async function setupVite(app: Express, server: Server) {
}
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)) {
throw new Error(

View File

@ -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>;

View File

@ -2,7 +2,74 @@ import type { Config } from "tailwindcss";
export default {
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: {
extend: {
borderRadius: {
@ -70,20 +137,12 @@ export default {
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
@ -92,5 +151,5 @@ export default {
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config;
plugins: [require("tailwindcss-animate")],
} satisfies Config;

129
test-deployment.js Normal file
View File

@ -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);
});

29
vercel.json Normal file
View File

@ -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"
}
]
}

View File

@ -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,
},
});

19
wrangler.toml Normal file
View File

@ -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"