Merge pull request #18 from artslidd/develop

develop
This commit is contained in:
Arthur Belleville 2025-10-23 22:10:50 +02:00 committed by GitHub
commit bbf7f4f553
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
217 changed files with 13544 additions and 7394 deletions

4
.gitignore vendored
View file

@ -31,3 +31,7 @@ __pycache__/
.pytest_cache/
.coverage
htmlcov/
.turbo
dist
.wrangler

329
DEVELOPMENT.md Normal file
View file

@ -0,0 +1,329 @@
# Development Guide
This monorepo uses [Turborepo](https://turbo.build/) to manage builds, development, and testing across multiple packages and applications.
## Repository Structure
```
xtablo-source/
├── apps/
│ ├── main/ # Main application (@xtablo/main)
│ └── external/ # External application (@xtablo/external)
├── packages/
│ ├── shared/ # Shared utilities and logic (@xtablo/shared)
│ └── ui/ # UI component library (@xtablo/ui)
```
## Prerequisites
- Node.js >= 18
- pnpm >= 10.19.0
## Getting Started
```bash
# Install dependencies
pnpm install
# Start development (packages are source-only, no build needed)
pnpm dev
```
## Available Commands
### Building
```bash
# Build all apps (packages are source-only, consumed directly by bundlers)
pnpm build
# Build only apps (main, external)
pnpm build:apps
# Build main app for specific environments
pnpm build:staging # Build for staging
pnpm build:prod # Build for production
```
**Note:** The `@xtablo/shared` and `@xtablo/ui` packages are source-only packages. They export TypeScript source files directly and are consumed by app bundlers (Vite) without a separate build step. This is faster and simpler for development.
**Environment Builds:** The main app supports environment-specific builds (`staging`, `production`) that are properly cached by Turborepo based on environment-specific inputs (`.env.staging`, `.env.production`, etc.).
### Development
```bash
# Run all apps in development mode
pnpm dev
# Run specific app
pnpm dev:main # Main app only
pnpm dev:external # External app only
```
**Note:** Since packages are source-only, there's no need to run them in watch mode. Changes to package files are instantly picked up by the app's bundler (Vite) through hot module replacement.
### Testing
```bash
# Run all tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Run tests for specific package
cd apps/main && pnpm test
cd apps/main && pnpm test:watch
cd apps/main && pnpm test:coverage
```
### Linting & Formatting
```bash
# Check all packages
pnpm lint
# Fix linting issues
pnpm lint:fix
# Format code
pnpm format
# Type checking
pnpm typecheck
```
### Cleaning
```bash
# Clean all build artifacts
pnpm clean
# Clean specific app
cd apps/main && pnpm clean
```
## Turborepo Features
### Smart Caching
Turborepo caches build outputs and skips unnecessary work:
- Build outputs are cached based on input files
- If nothing changed, builds are instant
- Cache is shared across the team (when configured)
### Parallel Execution
Tasks run in parallel when possible:
```bash
# Runs lint on all packages simultaneously
pnpm lint
```
### Task Dependencies
Turborepo automatically handles task dependencies:
- `build` depends on `^build` (builds dependencies first, though packages are source-only)
- Tasks run in topological order based on package dependencies
### Filtering
Run commands for specific packages:
```bash
# Build only main app and its dependencies
turbo build --filter=@xtablo/main
# Build only packages
turbo build --filter='./packages/*'
# Build everything except external
turbo build --filter='!@xtablo/external'
```
### Package-Level Configuration
Packages can have their own `turbo.json` file to define custom tasks or override root configuration:
**Example:** `apps/main/turbo.json` defines environment-specific builds:
- `build:staging` - Builds for staging with `.env.staging`
- `build:prod` - Builds for production with `.env.production`
Each task:
- Extends the root config with `"extends": ["//"]`
- Defines specific inputs (including environment files)
- Configures caching with appropriate outputs
- Can pass environment variables to the build process
## Package Development Workflow
### 1. Working on Packages
Since packages are source-only, just run your app and edit package files directly:
```bash
# Just run your app - changes to packages are instantly reflected
pnpm dev:main
# Or run all apps
pnpm dev
```
The app's bundler (Vite) will automatically detect changes in `packages/shared` and `packages/ui` and hot-reload them.
### 2. Adding a New Package
1. Create the package in `packages/`
2. For source-only packages (like shared/ui), add these scripts to `package.json`:
```json
{
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit"
}
}
```
3. Set up exports in `package.json`:
```json
{
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
```
4. Add to workspace in `pnpm-workspace.yaml` (if not already included by glob)
5. Run `pnpm install` to link the package
### 3. Testing Changes Across Packages
When making changes that affect multiple packages:
```bash
# Type check everything
pnpm typecheck
# Run all tests
pnpm test
# Lint everything
pnpm lint
```
## Performance Tips
1. **Source-Only Packages**: Packages consume TypeScript directly for instant feedback
2. **Selective Builds**: Use filters to build only the apps you need
3. **Clean When Stuck**: Run `pnpm clean` if you encounter weird caching issues
4. **Hot Module Replacement**: Vite provides instant updates when editing package files
## Troubleshooting
### Build Errors
If you encounter build errors:
```bash
# Clean everything and rebuild
pnpm clean
pnpm install
pnpm build
```
### Type Errors in Apps
If apps show type errors for packages:
```bash
# Check TypeScript configuration in packages
cd packages/shared && pnpm typecheck
cd packages/ui && pnpm typecheck
# Restart your IDE's TypeScript server
# In VS Code: Cmd+Shift+P > "TypeScript: Restart TS Server"
```
### Cache Issues
If builds seem stale:
```bash
# Clear Turbo cache
rm -rf node_modules/.cache/turbo
# Clean and rebuild
pnpm clean
pnpm build
```
## CI/CD Considerations
### Basic Pipeline
For CI/CD pipelines:
```bash
# Install dependencies
pnpm install --frozen-lockfile
# Build everything
pnpm build
# Run all checks
pnpm lint
pnpm typecheck
pnpm test
```
### Environment-Specific Deployments
**Staging Pipeline:**
```bash
# Install dependencies
pnpm install --frozen-lockfile
# Build for staging
pnpm build:staging
# Deploy (example with wrangler)
cd apps/main && pnpm deploy:staging
```
**Production Pipeline:**
```bash
# Install dependencies
pnpm install --frozen-lockfile
# Run all checks
pnpm lint
pnpm typecheck
pnpm test
# Build for production
pnpm build:prod
# Deploy (example with wrangler)
cd apps/main && pnpm deploy:prod
```
**Benefits:**
- Turborepo caches builds per environment
- Environment-specific `.env` files are tracked as inputs
- Builds are only re-run when relevant files change
## Additional Resources
- [Turborepo Documentation](https://turbo.build/repo/docs)
- [pnpm Workspaces](https://pnpm.io/workspaces)
- [TypeScript Project References](https://www.typescriptlang.org/docs/handbook/project-references.html)

156
README.md Normal file
View file

@ -0,0 +1,156 @@
# Xtablo Monorepo
This is a Turborepo-based monorepo for the Xtablo project, containing multiple apps and shared packages.
## Project Structure
```
xtablo-source/
├── apps/
│ ├── main/ # Main UI application
│ └── external/ # External booking widget microfrontend
├── packages/
│ ├── ui/ # Shared UI components (buttons, inputs, etc.)
│ └── shared/ # Shared utilities, hooks, contexts, and types
├── api/ # TypeScript/Node.js API
├── backend/ # Python backend
├── go_backend/ # Go backend
└── xtablo-expo/ # React Native Expo app
```
## Getting Started
### Prerequisites
- Node.js 18+ and pnpm 10.19.0+
- For other services: Python 3.11+, Go 1.21+
### Quick Start
```bash
# Install dependencies
pnpm install
# Start development (packages are source-only, no build needed)
pnpm dev
```
For detailed development workflows, see [DEVELOPMENT.md](./DEVELOPMENT.md).
### Common Commands
```bash
# Development
pnpm dev # Run all apps
pnpm dev:main # Run main app only
pnpm dev:external # Run external app only
# Building
pnpm build # Build all apps
pnpm build:apps # Build apps only
pnpm build:staging # Build main app for staging
pnpm build:prod # Build main app for production
# Testing & Quality
pnpm test # Run all tests
pnpm lint # Check all packages
pnpm lint:fix # Fix linting issues
pnpm typecheck # Type check everything
# Cleaning
pnpm clean # Clean all build artifacts
```
## Packages
### @xtablo/ui
Shared UI components library used across the main and external apps. Contains all base UI components like buttons, inputs, dialogs, etc.
**Usage:**
```typescript
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
```
### @xtablo/shared
Shared utilities, hooks, contexts, and types used across apps.
**Usage:**
```typescript
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { api } from "@xtablo/shared/lib/api";
import { Tables } from "@xtablo/shared/types/database.types";
```
## Apps
### Main (@xtablo/main)
The main Xtablo application with full dashboard, planning, chat, and administrative features.
**Local URL:** http://localhost:5173
### External (@xtablo/external)
Embeddable booking widget that can be integrated into external websites. Supports both embedded and floating widget modes.
**Local URL:** http://localhost:5174
**Usage:**
- Embedded mode: `?mode=embed&eventTypeId=...`
- Floating widget: `?mode=widget&eventTypeId=...`
## Turborepo Features
This monorepo uses Turborepo for:
- **Fast builds**: Parallel task execution and intelligent caching
- **Dependency management**: Automatic build ordering based on package dependencies
- **Code sharing**: Easy sharing of components and utilities between apps
## Development Workflow
For comprehensive development documentation including:
- Package development workflow
- Testing strategies
- Troubleshooting guide
- CI/CD setup
See [DEVELOPMENT.md](./DEVELOPMENT.md)
## Adding a New Package
See the "Adding a New Package" section in [DEVELOPMENT.md](./DEVELOPMENT.md) for detailed instructions.
## Migration Notes
This project was migrated from a single UI app to a Turborepo monorepo with the following changes:
- **Before**: Single `ui/` directory with all code
- **After**:
- `apps/main/` - Main application
- `apps/external/` - Separate microfrontend for booking widgets
- `packages/ui/` - Shared UI components
- `packages/shared/` - Shared utilities and logic
All import paths have been updated to use workspace packages (`@xtablo/ui`, `@xtablo/shared`).
## Contributing
When adding new shared code:
1. Add to the appropriate package (`ui` for UI components, `shared` for logic/utils)
2. Export from the package's appropriate entry point
3. Use the workspace import in your apps
For more details, see [DEVELOPMENT.md](./DEVELOPMENT.md)
## License
[Your License Here]

View file

@ -6,7 +6,8 @@ STREAM_CHAT_API_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevm
XTABLO_URL="https://app-staging.xtablo.com"
CORS_ORIGIN="http://localhost:5173"
CORS_ORIGIN="http://localhost:5173,http://localhost:5174"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"

View file

@ -16,7 +16,7 @@ export interface AppConfig {
R2_ACCOUNT_ID: string;
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
CORS_ORIGIN: string[];
CORS_ORIGIN: string;
LOG_LEVEL: "debug" | "info" | "warn" | "error";
SYNC_CALS_SECRET: string;
}
@ -47,20 +47,38 @@ function createConfig(): AppConfig {
process.env.SUPABASE_SERVICE_ROLE_KEY
),
SUPABASE_CONNECTION_STRING: process.env.SUPABASE_CONNECTION_STRING || "",
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
STREAM_CHAT_API_KEY: validateEnvVar(
"STREAM_CHAT_API_KEY",
process.env.STREAM_CHAT_API_KEY
),
STREAM_CHAT_API_SECRET: validateEnvVar(
"STREAM_CHAT_API_SECRET",
process.env.STREAM_CHAT_API_SECRET
),
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
EMAIL_CLIENT_SECRET: validateEnvVar("EMAIL_CLIENT_SECRET", process.env.EMAIL_CLIENT_SECRET),
EMAIL_REFRESH_TOKEN: validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN),
CORS_ORIGIN: [process.env.CORS_ORIGIN || "https://app.xtablo.com"],
EMAIL_CLIENT_ID: validateEnvVar(
"EMAIL_CLIENT_ID",
process.env.EMAIL_CLIENT_ID
),
EMAIL_CLIENT_SECRET: validateEnvVar(
"EMAIL_CLIENT_SECRET",
process.env.EMAIL_CLIENT_SECRET
),
EMAIL_REFRESH_TOKEN: validateEnvVar(
"EMAIL_REFRESH_TOKEN",
process.env.EMAIL_REFRESH_TOKEN
),
CORS_ORIGIN: process.env.CORS_ORIGIN || "https://app.xtablo.com",
XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com",
R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID),
R2_ACCESS_KEY_ID: validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID),
R2_SECRET_ACCESS_KEY: validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY),
R2_ACCESS_KEY_ID: validateEnvVar(
"R2_ACCESS_KEY_ID",
process.env.R2_ACCESS_KEY_ID
),
R2_SECRET_ACCESS_KEY: validateEnvVar(
"R2_SECRET_ACCESS_KEY",
process.env.R2_SECRET_ACCESS_KEY
),
SYNC_CALS_SECRET: process.env.SYNC_CALS_SECRET || "",
LOG_LEVEL: "info",
};

View file

@ -17,7 +17,7 @@ app.use(logger());
app.use("*", async (c, next) => {
const corsMiddleware = cors({
origin: config.CORS_ORIGIN,
origin: config.CORS_ORIGIN.split(","),
allowHeaders: [
"Authorization",
"Content-Type",

299
apps/external/biome.json vendored Normal file
View file

@ -0,0 +1,299 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": false },
"files": {
"ignoreUnknown": true,
"includes": ["src/**/*", "*.{ts,tsx,js,jsx,json}"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noBannedTypes": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noChildrenProp": "error",
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useIsNan": "error",
"useJsxKeyInIterable": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"nursery": {},
"security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
"style": {
"noCommonJs": "error",
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCommentText": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateJsxProps": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
},
"globals": [
"onanimationend",
"ongamepadconnected",
"onlostpointercapture",
"onanimationiteration",
"onkeyup",
"onmousedown",
"onanimationstart",
"onslotchange",
"onprogress",
"ontransitionstart",
"onpause",
"onended",
"onpointerover",
"onscrollend",
"onformdata",
"ontransitionrun",
"onanimationcancel",
"ondrag",
"onchange",
"onbeforeinstallprompt",
"onbeforexrselect",
"onmessage",
"ontransitioncancel",
"onpointerdown",
"onabort",
"onpointerout",
"oncuechange",
"ongotpointercapture",
"onscrollsnapchanging",
"onsearch",
"onsubmit",
"onstalled",
"onsuspend",
"onreset",
"onerror",
"onresize",
"onmouseenter",
"ongamepaddisconnected",
"ondragover",
"onbeforetoggle",
"onmouseover",
"onpagehide",
"onmousemove",
"onratechange",
"onmessageerror",
"onwheel",
"ondevicemotion",
"onauxclick",
"ontransitionend",
"onpaste",
"onpageswap",
"ononline",
"ondeviceorientationabsolute",
"onkeydown",
"onclose",
"onselect",
"onpageshow",
"onpointercancel",
"onbeforematch",
"onpointerrawupdate",
"ondragleave",
"onscrollsnapchange",
"onseeked",
"onwaiting",
"onbeforeunload",
"onplaying",
"onvolumechange",
"ondragend",
"onstorage",
"onloadeddata",
"onfocus",
"onoffline",
"onplay",
"onafterprint",
"onclick",
"oncut",
"onmouseout",
"ondblclick",
"oncanplay",
"onloadstart",
"onappinstalled",
"onpointermove",
"ontoggle",
"oncontextmenu",
"onblur",
"oncancel",
"onbeforeprint",
"oncontextrestored",
"onloadedmetadata",
"onpointerup",
"onlanguagechange",
"oncopy",
"onselectstart",
"onscroll",
"onload",
"ondragstart",
"onbeforeinput",
"oncanplaythrough",
"oninput",
"oninvalid",
"ontimeupdate",
"ondurationchange",
"onselectionchange",
"onmouseup",
"location",
"onkeypress",
"onpointerleave",
"oncontextlost",
"ondrop",
"onsecuritypolicyviolation",
"oncontentvisibilityautostatechange",
"ondeviceorientation",
"onseeking",
"onrejectionhandled",
"onunload",
"onmouseleave",
"onhashchange",
"onpointerenter",
"onmousewheel",
"onunhandledrejection",
"ondragenter",
"onpopstate",
"onpagereveal",
"onemptied"
]
},
"json": {
"parser": { "allowComments": true, "allowTrailingCommas": false },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"trailingCommas": "none"
}
},
"overrides": [
{ "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } },
{ "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } },
{
"linter": {
"rules": {
"style": { "useNodejsImportProtocol": "off" },
"suspicious": { "noExplicitAny": "off" }
}
}
},
{
"includes": ["src/**/*.{ts,tsx}", "*.{ts,tsx}"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
}
]
}

12
apps/external/index.html vendored Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xtablo External</title>
</head>
<body>
<div id="external-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

42
apps/external/package.json vendored Normal file
View file

@ -0,0 +1,42 @@
{
"name": "@xtablo/external",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview",
"deploy": "echo 'Configure deployment command for external app (e.g., wrangler pages deploy dist, vercel deploy, etc.)'",
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
"@tailwindcss/vite": "^4.0.14",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.14",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
"vite": "^6.2.2",
"vite-tsconfig-paths": "^5.1.4"
},
"dependencies": {
"@xtablo/ui": "workspace:*",
"@xtablo/shared": "workspace:*",
"@tanstack/react-query": "^5.69.0",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-router-dom": "^7.9.4",
"tailwind-merge": "^3.0.2",
"ts-pattern": "^5.6.2",
"zustand": "^5.0.5"
}
}

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View file

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,5 +1,5 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@ui/components/ui/dialog";
import { cn } from "@ui/lib/utils";
import { cn } from "@xtablo/shared";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@xtablo/ui/components/dialog";
// Custom Modal Component - now using shadcn/ui Dialog
interface CustomModalProps {

View file

@ -1,16 +1,18 @@
import { CustomModal } from "@ui/components/CustomModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { Button } from "@ui/components/ui/button";
import { FieldError } from "@ui/components/ui/field";
import { Input } from "@ui/components/ui/input";
import { Label } from "@ui/components/ui/label";
import { Text, TypographyH3, TypographyH4, TypographyMuted } from "@ui/components/ui/typography";
import { useSession } from "@ui/contexts/SessionContext";
import { useSignUpWithoutPassword } from "@ui/hooks/auth";
import { TimeSlot, usePublicSlots } from "@ui/hooks/public";
import { useCreateTabloWithOwner } from "@ui/hooks/tablos";
import { useMaybeUser } from "@ui/providers/UserStoreProvider";
import { EventInsertInTablo } from "@ui/types/events.types";
import { useCreateTabloWithOwner } from "@xtablo/shared";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useSignUpWithoutPassword } from "@xtablo/shared/hooks/auth";
import { TimeSlot, usePublicSlots } from "@xtablo/shared/hooks/public";
import { EventInsertInTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { FieldError } from "@xtablo/ui/components/field";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import {
Text,
TypographyH3,
TypographyH4,
TypographyMuted,
} from "@xtablo/ui/components/typography";
import {
CalendarIcon,
ChevronLeftIcon,
@ -22,6 +24,11 @@ import {
import { useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { CustomModal } from "./CustomModal";
import { LoadingSpinner } from "./LoadingSpinner";
import { api } from "./lib/api";
import { supabase } from "./lib/supabase";
import { useMaybeUser } from "./UserStoreProvider";
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
@ -187,16 +194,14 @@ const getMutedTextColorFromBackground = (variant: ColorVariant): string => {
};
export function EmbeddedBookingPage() {
const { user_info, event_type_standard_name } = useParams<{
user_info: string;
event_type_standard_name: string;
}>();
const params = useParams();
const [searchParams] = useSearchParams();
const { mutateAsync: signUpWithoutPassword } = useSignUpWithoutPassword();
const { mutateAsync: signUpWithoutPassword } = useSignUpWithoutPassword(supabase, api);
const { session } = useSession();
const user = useMaybeUser();
const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1);
const userInfo = params.userInfo as string;
const eventTypeStandardName = params.eventTypeStandardName as string;
// Get variants from URL params or props, with fallback to purple
const backgroundVariant = (searchParams.get("backgroundVariant") as ColorVariant) || "black";
const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple";
@ -207,12 +212,18 @@ export function EmbeddedBookingPage() {
const txtColor = getTextColorFromBackground(backgroundVariant);
const mutedTxtColor = getMutedTextColorFromBackground(backgroundVariant);
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
console.log({ shortUserId, eventTypeStandardName });
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
api,
shortUserId || "",
event_type_standard_name || ""
eventTypeStandardName || ""
);
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner();
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, () => {
handleCloseModal();
});
const userProfile = publicSlots?.user;
const eventType = publicSlots?.eventType;
@ -460,7 +471,7 @@ export function EmbeddedBookingPage() {
{/* Left Side - Event Details */}
<div
className={twMerge(
"w-[400px] bg-gradient-to-br p-8 flex flex-col relative overflow-hidden",
"w-[400px] bg-linear-to-br p-8 flex flex-col relative overflow-hidden",
bgColors.gradient,
txtColor
)}
@ -468,7 +479,7 @@ export function EmbeddedBookingPage() {
{/* Subtle accent overlay */}
<div
className={twMerge(
"absolute inset-0 bg-gradient-to-br pointer-events-none",
"absolute inset-0 bg-linear-to-br pointer-events-none",
bgColors.overlay
)}
></div>
@ -512,7 +523,7 @@ export function EmbeddedBookingPage() {
<div className="flex items-center gap-3">
<div
className={twMerge(
"w-10 h-10 rounded-lg border flex items-center justify-center flex-shrink-0",
"w-10 h-10 rounded-lg border flex items-center justify-center shrink-0",
bgColors.iconBg,
bgColors.iconBorder
)}
@ -530,7 +541,7 @@ export function EmbeddedBookingPage() {
<div className="flex items-center gap-3">
<div
className={twMerge(
"w-10 h-10 rounded-lg border flex items-center justify-center flex-shrink-0",
"w-10 h-10 rounded-lg border flex items-center justify-center shrink-0",
bgColors.iconBg,
bgColors.iconBorder
)}
@ -548,7 +559,7 @@ export function EmbeddedBookingPage() {
<div className="flex items-center gap-3">
<div
className={twMerge(
"w-10 h-10 rounded-lg border flex items-center justify-center flex-shrink-0",
"w-10 h-10 rounded-lg border flex items-center justify-center shrink-0",
bgColors.iconBg,
bgColors.iconBorder
)}

View file

@ -0,0 +1,694 @@
import { useCreateTabloWithOwner } from "@xtablo/shared";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useSignUpWithoutPassword } from "@xtablo/shared/hooks/auth";
import { TimeSlot, usePublicSlots } from "@xtablo/shared/hooks/public";
import { EventInsertInTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { FieldError } from "@xtablo/ui/components/field";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import { Text, TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography";
import {
CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
MapPinIcon,
UserIcon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { CustomModal } from "./CustomModal";
import { LoadingSpinner } from "./LoadingSpinner";
import { api } from "./lib/api";
import { supabase } from "./lib/supabase";
// import { useCreateTabloWithOwner } from "@xtablo/shared";
import { useMaybeUser } from "./UserStoreProvider";
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
// Color scheme configurations
const buttonColors = {
black: {
floating: "bg-gray-900 hover:bg-gray-800 text-white",
selected: "bg-gray-900 dark:bg-white text-white dark:text-gray-900",
ring: "ring-gray-500/50",
todayBorder: "border-gray-500/30",
hoverBorder: "hover:border-gray-500/50",
slotHover:
"hover:bg-gray-900 dark:hover:bg-white hover:text-white dark:hover:text-gray-900 hover:border-gray-500/50",
modalBorder: "border-gray-500/20",
modalIcon: "text-gray-600 dark:text-gray-400",
},
white: {
floating: "bg-white hover:bg-gray-50 text-gray-900 border border-gray-300",
selected: "bg-white dark:bg-gray-100 text-gray-900 dark:text-gray-900",
ring: "ring-gray-300/50",
todayBorder: "border-gray-300/30",
hoverBorder: "hover:border-gray-300/50",
slotHover:
"hover:bg-white dark:hover:bg-gray-100 hover:text-gray-900 dark:hover:text-gray-900 hover:border-gray-300/50",
modalBorder: "border-gray-300/20",
modalIcon: "text-gray-600 dark:text-gray-500",
},
blue: {
floating: "bg-blue-600 hover:bg-blue-700 text-white",
selected: "bg-blue-600 dark:bg-blue-500 text-white dark:text-white",
ring: "ring-blue-500/50",
todayBorder: "border-blue-500/30",
hoverBorder: "hover:border-blue-500/50",
slotHover:
"hover:bg-blue-600 dark:hover:bg-blue-500 hover:text-white dark:hover:text-white hover:border-blue-500/50",
modalBorder: "border-blue-500/20",
modalIcon: "text-blue-600 dark:text-blue-400",
},
purple: {
floating: "bg-purple-600 hover:bg-purple-700 text-white",
selected: "bg-purple-600 dark:bg-purple-500 text-white dark:text-white",
ring: "ring-purple-500/50",
todayBorder: "border-purple-500/30",
hoverBorder: "hover:border-purple-500/50",
slotHover:
"hover:bg-purple-600 dark:hover:bg-purple-500 hover:text-white dark:hover:text-white hover:border-purple-500/50",
modalBorder: "border-purple-500/20",
modalIcon: "text-purple-600 dark:text-purple-400",
},
green: {
floating: "bg-green-600 hover:bg-green-700 text-white",
selected: "bg-green-600 dark:bg-green-500 text-white dark:text-white",
ring: "ring-green-500/50",
todayBorder: "border-green-500/30",
hoverBorder: "hover:border-green-500/50",
slotHover:
"hover:bg-green-600 dark:hover:bg-green-500 hover:text-white dark:hover:text-white hover:border-green-500/50",
modalBorder: "border-green-500/20",
modalIcon: "text-green-600 dark:text-green-400",
},
orange: {
floating: "bg-orange-600 hover:bg-orange-700 text-white",
selected: "bg-orange-600 dark:bg-orange-500 text-white dark:text-white",
ring: "ring-orange-500/50",
todayBorder: "border-orange-500/30",
hoverBorder: "hover:border-orange-500/50",
slotHover:
"hover:bg-orange-600 dark:hover:bg-orange-500 hover:text-white dark:hover:text-white hover:border-orange-500/50",
modalBorder: "border-orange-500/20",
modalIcon: "text-orange-600 dark:text-orange-400",
},
red: {
floating: "bg-red-600 hover:bg-red-700 text-white",
selected: "bg-red-600 dark:bg-red-500 text-white dark:text-white",
ring: "ring-red-500/50",
todayBorder: "border-red-500/30",
hoverBorder: "hover:border-red-500/50",
slotHover:
"hover:bg-red-600 dark:hover:bg-red-500 hover:text-white dark:hover:text-white hover:border-red-500/50",
modalBorder: "border-red-500/20",
modalIcon: "text-red-600 dark:text-red-400",
},
};
export function FloatingBookingWidget() {
const params = useParams();
const [searchParams] = useSearchParams();
const { mutateAsync: signUpWithoutPassword } = useSignUpWithoutPassword(supabase, api);
const { session } = useSession();
const user = useMaybeUser();
const userInfo = params.userInfo as string;
const eventTypeStandardName = params.eventTypeStandardName as string;
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
// Get variants from URL params with fallback to purple
const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple";
// Get color schemes based on variants
const btnColors = buttonColors[buttonVariant];
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
api,
shortUserId || "",
eventTypeStandardName || ""
);
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, () => {
handleCloseModal();
setIsWidgetOpen(false);
});
const userProfile = publicSlots?.user;
const eventType = publicSlots?.eventType;
const slotsData = publicSlots?.slots || {};
// Widget state
const [isWidgetOpen, setIsWidgetOpen] = useState(false);
// Calendar state
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
// Modal state (for booking confirmation)
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedSlot, setSelectedSlot] = useState<{
date: Date;
slot: TimeSlot;
} | null>(null);
const [formData, setFormData] = useState({
email: "",
name: "",
});
const [formErrors, setFormErrors] = useState({
email: "",
name: "",
});
// Helper function to convert date to CET timezone string (YYYY-MM-DD)
const formatDateToCET = (date: Date): string => {
return date.toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
};
// Helper function to get current date in CET timezone
const getCurrentDateInCET = (): Date => {
const now = new Date();
const cetTime = new Date(now.toLocaleString("en-US", { timeZone: "Europe/Paris" }));
return cetTime;
};
// Get available time slots for a specific date
const getAvailableSlots = (date: Date): TimeSlot[] => {
const dateStr = formatDateToCET(date);
return slotsData[dateStr]?.filter((slot) => slot.available) || [];
};
// Check if a date has any available slots
const hasAvailableSlots = (date: Date): boolean => {
const dateStr = formatDateToCET(date);
return slotsData[dateStr]?.some((slot) => slot.available) || false;
};
// Calendar helper functions
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
// Create first day of month and get its day of week in CET
const firstDayStr = `${year}-${String(month + 1).padStart(2, "0")}-01`;
const firstDay = new Date(`${firstDayStr}T12:00:00`);
const firstDayOfWeekInCET = new Date(
firstDay.toLocaleString("en-US", { timeZone: "Europe/Paris" })
).getDay();
// Adjust for Monday as first day of week
const mondayStartingDay = firstDayOfWeekInCET === 0 ? 6 : firstDayOfWeekInCET - 1;
// Get number of days in month
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const days = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < mondayStartingDay; i++) {
days.push(null);
}
// Add all days of the month
for (let day = 1; day <= daysInMonth; day++) {
const dayStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(
2,
"0"
)}`;
days.push(new Date(`${dayStr}T12:00:00`));
}
return days;
};
const navigateMonth = (direction: "prev" | "next") => {
setCurrentDate((prev) => {
const newDate = new Date(prev);
if (direction === "prev") {
newDate.setMonth(prev.getMonth() - 1);
} else {
newDate.setMonth(prev.getMonth() + 1);
}
return newDate;
});
};
const isToday = (date: Date) => {
const todayInCET = getCurrentDateInCET();
const todayStr = formatDateToCET(todayInCET);
const dateStr = formatDateToCET(date);
return dateStr === todayStr;
};
const isPastDate = (date: Date) => {
const todayInCET = getCurrentDateInCET();
const todayStr = formatDateToCET(todayInCET);
const dateStr = formatDateToCET(date);
return dateStr < todayStr;
};
const formatMonthYear = (date: Date) => {
return date.toLocaleDateString("fr-FR", { month: "long", year: "numeric" });
};
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes} min`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours}h`;
}
return `${hours}h ${remainingMinutes}min`;
};
// Modal and form handlers
const handleSlotClick = (date: Date, slot: TimeSlot) => {
setSelectedSlot({ date, slot });
setIsModalOpen(true);
setFormData({ email: "", name: "" });
setFormErrors({ email: "", name: "" });
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedSlot(null);
setFormData({ email: "", name: "" });
setFormErrors({ email: "", name: "" });
};
const validateForm = () => {
const errors = { email: "", name: "" };
let isValid = true;
if (!formData.email.trim()) {
errors.email = "L'adresse email est requise";
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = "Veuillez entrer une adresse email valide";
isValid = false;
}
if (!formData.name.trim()) {
errors.name = "Le nom est requis";
isValid = false;
}
setFormErrors(errors);
return isValid;
};
// Calculate end time based on start time and duration
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
if (!startTime) return "";
const [hours, minutes] = startTime.split(":").map(Number);
const startDate = new Date();
startDate.setHours(hours, minutes, 0, 0);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
return endDate.toTimeString().slice(0, 5); // Format as HH:MM
};
const handleSubmitIfNotLoggedIn = async () => {
if (validateForm()) {
const { session: sessionFromSignUp } = await signUpWithoutPassword({
email: formData.email,
name: formData.name,
});
const startTime = selectedSlot?.slot.time || "";
const duration = eventType?.duration || 60; // duration in minutes
const endTime = calculateEndTime(startTime, duration);
await createTabloWithOwner({
name: eventType?.name || "",
status: "todo",
owner_short_id: shortUserId || "",
event: {
description: eventType?.description || "",
end_time: endTime || "",
start_date: selectedSlot?.slot.date || "",
start_time: selectedSlot?.slot.time || "",
title: eventType?.name || "",
} as EventInsertInTablo,
access_token: sessionFromSignUp?.access_token || "",
});
handleCloseModal();
setIsWidgetOpen(false);
}
};
const handleSubmitIfLoggedIn = async () => {
if (user) {
const startTime = selectedSlot?.slot.time || "";
const duration = eventType?.duration || 60; // duration in minutes
const endTime = calculateEndTime(startTime, duration);
await createTabloWithOwner({
name: eventType?.name || "",
status: "todo",
owner_short_id: shortUserId || "",
event: {
description: eventType?.description || "",
end_time: endTime || "",
start_date: selectedSlot?.slot.date || "",
start_time: selectedSlot?.slot.time || "",
title: eventType?.name || "",
} as EventInsertInTablo,
access_token: session?.access_token || "",
});
handleCloseModal();
setIsWidgetOpen(false);
}
};
if (isLoadingSlots) {
return (
<div className="fixed inset-0 pointer-events-none">
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
<Button
size="lg"
className={twMerge(
"rounded-full h-14 w-14 shadow-lg transition-all duration-200",
btnColors.floating
)}
disabled
>
<LoadingSpinner />
</Button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 pointer-events-none">
{/* Floating Button */}
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
<Button
size="lg"
className={twMerge(
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl transition-all duration-200",
btnColors.floating,
isWidgetOpen && "scale-0 opacity-0"
)}
onClick={() => setIsWidgetOpen(true)}
>
<CalendarIcon className="w-6 h-6" />
</Button>
</div>
{/* Floating Widget Popup */}
{isWidgetOpen && (
<div className="fixed bottom-6 right-6 z-50 w-[450px] max-h-[650px] bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden animate-in slide-in-from-bottom-4 duration-300 pointer-events-auto">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{(userProfile as { name: string; avatar_url?: string })?.avatar_url ? (
<img
src={(userProfile as { name: string; avatar_url?: string }).avatar_url}
alt={userProfile?.name || "Profile"}
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-600 shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<UserIcon className="w-6 h-6 text-gray-500 dark:text-gray-400" />
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate">
{eventType?.name || "Type d'événement"}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{userProfile?.name || "Professionnel"}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => {
setIsWidgetOpen(false);
setSelectedDate(null);
}}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
{/* Event Info */}
{(eventType?.duration || eventType?.location) && (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div className="flex flex-wrap gap-3 text-xs">
{eventType?.duration && (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<ClockIcon className="w-3.5 h-3.5" />
<span>{formatDuration(eventType.duration)}</span>
</div>
)}
{eventType?.location && (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<MapPinIcon className="w-3.5 h-3.5" />
<span className="truncate max-w-[200px]">{eventType.location}</span>
</div>
)}
</div>
</div>
)}
{/* Calendar and Slots */}
<div className="flex-1 overflow-y-auto p-4">
{/* Calendar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<TypographyH4 className="font-semibold text-gray-900 dark:text-white capitalize text-sm">
{formatMonthYear(currentDate)}
</TypographyH4>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigateMonth("prev")}
className="h-7 w-7 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigateMonth("next")}
className="h-7 w-7 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
</div>
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-1 mb-2">
{["L", "M", "M", "J", "V", "S", "D"].map((day, i) => (
<div
key={i}
className="p-1 text-center text-xs font-medium text-gray-500 dark:text-gray-400"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{getDaysInMonth(currentDate).map((date, index) => (
<div key={index} className="aspect-square">
{date ? (
<button
onClick={() =>
!isPastDate(date) && hasAvailableSlots(date) && setSelectedDate(date)
}
disabled={isPastDate(date) || !hasAvailableSlots(date)}
className={twMerge(
"w-full h-full flex items-center justify-center text-xs rounded-lg transition-colors",
isPastDate(date)
? "text-gray-300 dark:text-gray-600 cursor-not-allowed"
: selectedDate?.toDateString() === date.toDateString()
? `${btnColors.selected} font-semibold shadow-md ring-2 ${btnColors.ring}`
: isToday(date)
? `bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold border ${btnColors.todayBorder}`
: hasAvailableSlots(date)
? `text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 ${btnColors.hoverBorder} border border-gray-200 dark:border-gray-600`
: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
)}
>
{date.getDate()}
</button>
) : (
<div></div>
)}
</div>
))}
</div>
</div>
{/* Time Slots */}
{selectedDate && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<TypographyH4 className="font-semibold text-gray-900 dark:text-white mb-2 text-sm">
Créneaux disponibles
</TypographyH4>
<TypographyMuted className="text-xs font-normal text-gray-500 dark:text-gray-400 mb-3">
{selectedDate.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
})}
</TypographyMuted>
<div className="space-y-2 max-h-[200px] overflow-y-auto pr-1">
{getAvailableSlots(selectedDate).map((slot, index) => (
<Button
key={index}
variant="outline"
size="sm"
className={twMerge(
"w-full justify-center text-sm py-2 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600 transition-all",
btnColors.slotHover
)}
onClick={() => handleSlotClick(selectedDate, slot)}
>
{slot.time}
</Button>
))}
{getAvailableSlots(selectedDate).length === 0 && (
<div className="text-center py-4">
<Text className="text-xs text-gray-500 dark:text-gray-400">
Aucun créneau disponible
</Text>
</div>
)}
</div>
</div>
)}
{!selectedDate && (
<div className="text-center py-8 border-t border-gray-200 dark:border-gray-700">
<CalendarIcon className="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<Text className="text-xs text-gray-500 dark:text-gray-400">
Sélectionnez une date pour voir les créneaux disponibles
</Text>
</div>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<TypographyMuted className="text-xs text-center text-gray-500">
Powered by{" "}
<a
href="https://www.xtablo.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-gray-600 dark:text-gray-400"
>
XTablo
</a>
</TypographyMuted>
</div>
</div>
)}
{/* Booking Modal */}
<CustomModal
isOpen={isModalOpen}
onClose={handleCloseModal}
title={user ? "Confirmer la réservation" : "Créer un compte pour réserver"}
width="md"
>
{selectedSlot && (
<div
className={twMerge(
"mb-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border",
btnColors.modalBorder
)}
>
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
<CalendarIcon className={twMerge("w-4 h-4", btnColors.modalIcon)} />
<Text className="font-medium">
{selectedSlot.date.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
})}
</Text>
</div>
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100 mt-1">
<ClockIcon className={twMerge("w-4 h-4", btnColors.modalIcon)} />
<Text className="font-medium">{selectedSlot.slot.time}</Text>
</div>
</div>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
Nom complet <span className="text-red-500">*</span>
</Label>
<Input
id="name"
type="text"
placeholder="Votre nom complet"
value={user?.name || formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
disabled={!!user}
/>
{formErrors.name && <FieldError errors={[{ message: formErrors.name }]} />}
</div>
<div className="space-y-2">
<Label htmlFor="email">
Adresse email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
placeholder="votre@email.com"
value={user?.email || formData.email}
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
disabled={!!user}
/>
{formErrors.email && <FieldError errors={[{ message: formErrors.email }]} />}
</div>
{!user && (
<div className="pt-2">
<Text className="text-sm text-gray-600 dark:text-gray-400">
Un compte sera créé avec ces informations pour gérer votre réservation.
</Text>
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button variant="outline" onClick={handleCloseModal}>
Annuler
</Button>
<Button
variant="default"
onClick={user ? handleSubmitIfLoggedIn : handleSubmitIfNotLoggedIn}
>
{user ? "Confirmer la réservation" : "Créer le compte et réserver"}
</Button>
</div>
</CustomModal>
</div>
);
}

View file

@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { useSession } from "@ui/contexts/SessionContext";
import { api } from "@ui/lib/api";
import { Tables } from "@ui/types/database.types";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { Tables } from "@xtablo/shared/types/database.types";
import React from "react";
import { createStore, StoreApi, useStore } from "zustand";
import { LoadingSpinner } from "./LoadingSpinner";
import { api } from "./lib/api";
export type User = Tables<"profiles"> & {
streamToken: string | null;

3
apps/external/src/lib/api.ts vendored Normal file
View file

@ -0,0 +1,3 @@
import { buildApi } from "@xtablo/shared";
export const api = buildApi(import.meta.env.VITE_API_URL);

10
apps/external/src/lib/supabase.ts vendored Normal file
View file

@ -0,0 +1,10 @@
import { createSupabaseClient } from "@xtablo/shared";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables");
}
export const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey);

View file

@ -301,8 +301,7 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(150px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg) translateX(150px)
rotate(-360deg);
transform: translate(-50%, -50%) rotate(360deg) translateX(150px) rotate(-360deg);
}
}
@ -311,8 +310,7 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(200px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(-360deg) translateX(200px)
rotate(360deg);
transform: translate(-50%, -50%) rotate(-360deg) translateX(200px) rotate(360deg);
}
}
@ -321,8 +319,7 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(100px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg) translateX(100px)
rotate(-360deg);
transform: translate(-50%, -50%) rotate(360deg) translateX(100px) rotate(-360deg);
}
}
@ -500,8 +497,7 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(250px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg) translateX(250px)
rotate(-360deg);
transform: translate(-50%, -50%) rotate(360deg) translateX(250px) rotate(-360deg);
}
}
@ -510,8 +506,7 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(120px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(-360deg) translateX(120px)
rotate(360deg);
transform: translate(-50%, -50%) rotate(-360deg) translateX(120px) rotate(360deg);
}
}

26
apps/external/src/main.tsx vendored Normal file
View file

@ -0,0 +1,26 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@xtablo/shared";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter as Router } from "react-router-dom";
import AppRoutes from "./routes";
import "@xtablo/ui/styles/globals.css";
import "./main.css";
createRoot(document.getElementById("external-root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<Toaster />
<Router>
<div className="min-h-screen bg-background">
<AppRoutes />
</div>
</Router>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>
);

12
apps/external/src/routes.tsx vendored Normal file
View file

@ -0,0 +1,12 @@
import { Route, Routes } from "react-router-dom";
import { EmbeddedBookingPage } from "./EmbeddedBookingPage";
import { FloatingBookingWidget } from "./FloatingBookingWidget";
export default function AppRoutes() {
return (
<Routes>
<Route path="/embed/:userInfo/:eventTypeStandardName" element={<EmbeddedBookingPage />} />
<Route path="/widget/:userInfo/:eventTypeStandardName" element={<FloatingBookingWidget />} />
</Routes>
);
}

28
apps/external/tsconfig.json vendored Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@xtablo/ui": ["../../packages/ui/src"],
"@xtablo/ui/*": ["../../packages/ui/src/*"],
"@xtablo/shared": ["../../packages/shared/src"],
"@xtablo/shared/*": ["../../packages/shared/src/*"]
}
},
"include": ["src"],
"references": []
}

1
apps/external/tsconfig.tsbuildinfo vendored Normal file
View file

@ -0,0 +1 @@
{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/loadingspinner.tsx","./src/userstoreprovider.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"}

26
apps/external/vite.config.ts vendored Normal file
View file

@ -0,0 +1,26 @@
/// <reference types="vite/client" />
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
const __dirname = dirname(fileURLToPath(import.meta.url));
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), tsconfigPaths()],
server: {
cors: false,
port: 5174,
},
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});

4
apps/main/.biomeignore Normal file
View file

@ -0,0 +1,4 @@
# Generated Wrangler files
worker-configuration.d.ts
worker/

300
apps/main/biome.json Normal file
View file

@ -0,0 +1,300 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": false },
"files": {
"ignoreUnknown": true,
"maxSize": 10485760,
"includes": ["src/**/*"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noBannedTypes": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noChildrenProp": "error",
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useIsNan": "error",
"useJsxKeyInIterable": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"nursery": {},
"security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
"style": {
"noCommonJs": "error",
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCommentText": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateJsxProps": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
},
"globals": [
"onanimationend",
"ongamepadconnected",
"onlostpointercapture",
"onanimationiteration",
"onkeyup",
"onmousedown",
"onanimationstart",
"onslotchange",
"onprogress",
"ontransitionstart",
"onpause",
"onended",
"onpointerover",
"onscrollend",
"onformdata",
"ontransitionrun",
"onanimationcancel",
"ondrag",
"onchange",
"onbeforeinstallprompt",
"onbeforexrselect",
"onmessage",
"ontransitioncancel",
"onpointerdown",
"onabort",
"onpointerout",
"oncuechange",
"ongotpointercapture",
"onscrollsnapchanging",
"onsearch",
"onsubmit",
"onstalled",
"onsuspend",
"onreset",
"onerror",
"onresize",
"onmouseenter",
"ongamepaddisconnected",
"ondragover",
"onbeforetoggle",
"onmouseover",
"onpagehide",
"onmousemove",
"onratechange",
"onmessageerror",
"onwheel",
"ondevicemotion",
"onauxclick",
"ontransitionend",
"onpaste",
"onpageswap",
"ononline",
"ondeviceorientationabsolute",
"onkeydown",
"onclose",
"onselect",
"onpageshow",
"onpointercancel",
"onbeforematch",
"onpointerrawupdate",
"ondragleave",
"onscrollsnapchange",
"onseeked",
"onwaiting",
"onbeforeunload",
"onplaying",
"onvolumechange",
"ondragend",
"onstorage",
"onloadeddata",
"onfocus",
"onoffline",
"onplay",
"onafterprint",
"onclick",
"oncut",
"onmouseout",
"ondblclick",
"oncanplay",
"onloadstart",
"onappinstalled",
"onpointermove",
"ontoggle",
"oncontextmenu",
"onblur",
"oncancel",
"onbeforeprint",
"oncontextrestored",
"onloadedmetadata",
"onpointerup",
"onlanguagechange",
"oncopy",
"onselectstart",
"onscroll",
"onload",
"ondragstart",
"onbeforeinput",
"oncanplaythrough",
"oninput",
"oninvalid",
"ontimeupdate",
"ondurationchange",
"onselectionchange",
"onmouseup",
"location",
"onkeypress",
"onpointerleave",
"oncontextlost",
"ondrop",
"onsecuritypolicyviolation",
"oncontentvisibilityautostatechange",
"ondeviceorientation",
"onseeking",
"onrejectionhandled",
"onunload",
"onmouseleave",
"onhashchange",
"onpointerenter",
"onmousewheel",
"onunhandledrejection",
"ondragenter",
"onpopstate",
"onpagereveal",
"onemptied"
]
},
"json": {
"parser": { "allowComments": true, "allowTrailingCommas": false },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"trailingCommas": "none"
}
},
"overrides": [
{ "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } },
{ "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } },
{
"linter": {
"rules": {
"style": { "useNodejsImportProtocol": "off" },
"suspicious": { "noExplicitAny": "off" }
}
}
},
{
"includes": ["src/**/*.{ts,tsx}", "worker/**/*.{ts,tsx}", "*.{ts,tsx}"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
}
]
}

View file

@ -10,4 +10,4 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View file

@ -1,23 +1,24 @@
{
"name": "ui",
"name": "@xtablo/main",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"typecheck": "npx tsgo --build .",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview",
"build:staging": "tsc -b && vite build --mode staging",
"build:prod": "tsc -b && vite build --mode production",
"deploy:staging": "pnpm run build:staging && wrangler deploy",
"deploy:prod": "pnpm run build:prod && wrangler deploy",
"deploy": "wrangler deploy --env=\"\"",
"cf-typegen": "wrangler types",
"test": "vitest run --mode dev --passWithNoTests",
"test:watch": "vitest watch --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests"
"test:coverage": "vitest run --coverage --passWithNoTests",
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
@ -55,7 +56,7 @@
"react-dom": "19.0.0",
"rollup-plugin-visualizer": "^5.14.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.14",
"tailwindcss": "^4.1.15",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
@ -66,6 +67,8 @@
"wrangler": "^4.24.3"
},
"dependencies": {
"@xtablo/ui": "workspace:*",
"@xtablo/shared": "workspace:*",
"@datadog/browser-rum": "^6.13.0",
"@datadog/browser-rum-react": "^6.13.0",
"@hookform/resolvers": "^5.2.2",
@ -85,7 +88,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@react-stately/calendar": "^3.7.1",
"@supabase/supabase-js": "^2.49.3",
"@tailwindcss/vite": "^4.0.14",
"@tailwindcss/vite": "^4.1.5",
"@tanstack/react-query": "^5.69.0",
"@types/react-router-dom": "^5.3.3",
"@typescript/native-preview": "7.0.0-dev.20251010.1",
@ -109,11 +112,5 @@
"uuid": "^11.1.0",
"zod": "^4.1.12",
"zustand": "^5.0.5"
},
"pnpm": {
"overrides": {
"form-data": "^4.0.4",
"linkifyjs": "^4.3.2"
}
}
}

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,14 +1,11 @@
import { Toaster } from "@ui/components/ui/sonner";
import { SessionProvider } from "@ui/contexts/SessionContext";
import { ThemeProvider } from "@ui/contexts/ThemeContext";
import { routes } from "@ui/lib/routes";
import { DatadogRumProvider } from "@ui/providers/DatadogRumProvider";
import { UserStoreProvider } from "@ui/providers/UserStoreProvider";
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
// Register all Community features
ModuleRegistry.registerModules([AllCommunityModule]);
import { routes } from "./lib/routes";
import { supabase } from "./lib/supabase";
import { DatadogRumProvider } from "./providers/DatadogRumProvider";
import { UserStoreProvider } from "./providers/UserStoreProvider";
const AppRoutes = () => {
const element = useRoutes(routes);
@ -18,7 +15,7 @@ const AppRoutes = () => {
export const App = () => {
return (
<ThemeProvider>
<SessionProvider>
<SessionProvider supabase={supabase}>
<UserStoreProvider>
<Toaster />
<Router>

View file

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { Navigate, Outlet, useSearchParams } from "react-router-dom";
import { match } from "ts-pattern";
import { useMaybeUser } from "../providers/UserStoreProvider";
import { LoadingSpinner } from "./LoadingSpinner";
import { useMaybeUser } from "@ui/providers/UserStoreProvider";
export const AuthenticationGateway = () => {
const user = useMaybeUser();

View file

@ -1,13 +1,28 @@
import { screen, waitFor } from "@testing-library/react";
import { AuthenticationGateway } from "@ui/components/AuthenticationGateway";
import { SessionTestProvider } from "@ui/contexts/SessionContext";
import { renderWithRouter } from "@ui/utils/testHelpers";
import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext";
import { Route, Routes } from "react-router-dom";
import { renderWithRouter } from "../utils/testHelpers";
describe("PublicRoute", () => {
it("shows loading state initially", () => {
renderWithRouter(
<SessionTestProvider>
<SessionTestProvider
testUser={{
id: "123",
app_metadata: {},
user_metadata: {
full_name: "Test User",
email: "test@example.com",
email_verified: true,
first_name: "Test",
last_name: "User",
business_name: "Test Business",
},
aud: "authenticated",
created_at: new Date().toISOString(),
}}
>
<Routes>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />
@ -57,7 +72,7 @@ describe("PublicRoute", () => {
it("renders public content when user is not authenticated", async () => {
renderWithRouter(
<SessionTestProvider>
<SessionTestProvider testUser={undefined}>
<Routes>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />

View file

@ -1,7 +1,7 @@
import { Button } from "@ui/components/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@ui/components/ui/card";
import { Switch } from "@ui/components/ui/switch";
import { TimeInput } from "@ui/components/ui/time-input";
import { Button } from "@xtablo/ui/components/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@xtablo/ui/components/card";
import { Switch } from "@xtablo/ui/components/switch";
import { TimeInput } from "@xtablo/ui/components/time-input";
import { Copy as CopyIcon, Minus as MinusIcon, Plus as PlusIcon } from "lucide-react";
interface TimeRange {

View file

@ -1,5 +1,5 @@
import { Text } from "@ui/components/ui/typography";
import { WeeklyAvailability } from "@ui/hooks/availabilities";
import { Text } from "@xtablo/ui/components/typography";
import { WeeklyAvailability } from "../hooks/availabilities";
// Check if a time slot is available for a given day
const isTimeSlotAvailable = (
@ -65,13 +65,13 @@ export const AvailabilityVisualization = ({
<div className="bg-white dark:bg-gray-700/40 rounded-xl shadow-sm dark:shadow-gray-900/20 border border-gray-200 dark:border-gray-600/50 overflow-hidden">
{/* Weekly Calendar Header */}
<div className="grid grid-cols-8 border-b-2 border-gray-200 dark:border-gray-600">
<div className="p-4 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-700/60 dark:to-slate-800/60 border-r border-gray-200 dark:border-gray-600">
<div className="p-4 bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-700/60 dark:to-slate-800/60 border-r border-gray-200 dark:border-gray-600">
<Text className="font-bold text-sm text-slate-700 dark:text-slate-300">Heure</Text>
</div>
{DAYS_OF_WEEK.map((day) => (
<div
key={day}
className="p-4 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-700/60 dark:to-slate-800/60 border-r border-gray-200 dark:border-gray-600 last:border-r-0 text-center"
className="p-4 bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-700/60 dark:to-slate-800/60 border-r border-gray-200 dark:border-gray-600 last:border-r-0 text-center"
>
<Text className="font-bold text-sm text-slate-700 dark:text-slate-300">
{DAYS_OF_WEEK_DISPLAY[day]}
@ -93,7 +93,7 @@ export const AvailabilityVisualization = ({
key={timeSlot}
className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-700 hover:bg-slate-50/50 dark:hover:bg-slate-800/50 transition-colors duration-150"
>
<div className="p-3 border-r border-gray-200 dark:border-gray-600 bg-gradient-to-r from-slate-50/80 to-slate-100/80 dark:from-slate-800/80 dark:to-slate-900/80">
<div className="p-3 border-r border-gray-200 dark:border-gray-600 bg-linear-to-r from-slate-50/80 to-slate-100/80 dark:from-slate-800/80 dark:to-slate-900/80">
<Text className="text-xs font-semibold text-slate-600 dark:text-slate-400">
{timeSlot}
</Text>
@ -101,10 +101,10 @@ export const AvailabilityVisualization = ({
{DAYS_OF_WEEK.map((day) => (
<div
key={`${day}-${timeSlot}`}
className="p-3 border-r border-gray-200 dark:border-gray-600 last:border-r-0 min-h-[3rem] flex items-center justify-center bg-gradient-to-br from-white to-slate-50/30 dark:from-gray-700/40 dark:to-slate-800/40"
className="p-3 border-r border-gray-200 dark:border-gray-600 last:border-r-0 min-h-[3rem] flex items-center justify-center bg-linear-to-br from-white to-slate-50/30 dark:from-gray-700/40 dark:to-slate-800/40"
>
{isTimeSlotAvailable(day, timeSlot, draftAvailabilities) ? (
<div className="w-full h-8 bg-gradient-to-r from-emerald-400 via-emerald-500 to-emerald-600 dark:from-emerald-500 dark:via-emerald-600 dark:to-emerald-700 rounded-lg shadow-sm border border-emerald-300 dark:border-emerald-600 flex items-center justify-center group hover:shadow-md transition-all duration-200 hover:scale-105">
<div className="w-full h-8 bg-linear-to-r from-emerald-400 via-emerald-500 to-emerald-600 dark:from-emerald-500 dark:via-emerald-600 dark:to-emerald-700 rounded-lg shadow-sm border border-emerald-300 dark:border-emerald-600 flex items-center justify-center group hover:shadow-md transition-all duration-200 hover:scale-105">
<div className="w-3 h-3 bg-white/90 rounded-full shadow-sm group-hover:bg-white transition-colors duration-200"></div>
</div>
) : (

View file

@ -1,7 +1,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { LoginWithGoogle } from "@ui/components/BrandButtons/LoginWithGoogle";
import { useLoginGoogle } from "@ui/hooks/auth";
import { vi } from "vitest";
import { useLoginGoogle } from "../../hooks/auth";
vi.mock("../../hooks/auth", () => ({
useLoginGoogle: vi.fn(),

View file

@ -1,4 +1,4 @@
import { UserTablo } from "@ui/types/tablos.types";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { twMerge } from "tailwind-merge";
export const ChannelBadge = ({

View file

@ -1,6 +1,6 @@
import { ChannelBadge } from "@ui/components/ChannelBadge";
import { Badge } from "@ui/components/ui/badge";
import { UserTablo } from "@ui/types/tablos.types";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Badge } from "@xtablo/ui/components/badge";
import { ReactNode } from "react";
import { Channel } from "stream-chat";
import { twMerge } from "tailwind-merge";
@ -104,7 +104,7 @@ export function ChannelPreview({
{displayTitle}
</h3>
{timestamp && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2 flex-shrink-0">
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2 shrink-0">
{formatTimestamp(timestamp)}
</span>
)}
@ -117,7 +117,7 @@ export function ChannelPreview({
{/* Unread count badge */}
{unreadCount > 0 && (
<div className="ml-2 flex-shrink-0">
<div className="ml-2 shrink-0">
<Badge
color="indigo"
className="text-xs min-w-[20px] h-5 px-2 py-0 flex items-center justify-center"

View file

@ -1,5 +1,5 @@
import { useClickOutside } from "@xtablo/shared/hooks/useClickOutside";
import React from "react";
import { useClickOutside } from "../hooks/useClickOutside";
interface ClickOutsideProps {
children: React.ReactNode;

View file

@ -1,4 +1,4 @@
import { Database } from "@ui/types/database.types";
import { Database } from "@xtablo/shared/types/database.types";
import { useState } from "react";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";

View file

@ -1,4 +1,4 @@
import { UserTablo } from "@ui/types/tablos.types";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { ChannelHeader, useChannelStateContext } from "stream-chat-react";
import { ChannelBadge } from "./ChannelBadge";

View file

@ -0,0 +1,45 @@
import { cn } from "@xtablo/shared";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@xtablo/ui/components/dialog";
// Custom Modal Component - now using shadcn/ui Dialog
interface CustomModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
width?: "sm" | "md" | "lg" | "xl" | "2xl" | "full" | "auto";
}
export function CustomModal({ isOpen, onClose, title, children, width = "md" }: CustomModalProps) {
const getWidthClasses = () => {
switch (width) {
case "sm":
return "max-w-sm";
case "md":
return "max-w-md";
case "lg":
return "max-w-lg";
case "xl":
return "max-w-xl";
case "2xl":
return "max-w-2xl";
case "full":
return "max-w-full mx-4";
case "auto":
return "w-auto min-w-80 max-w-[90vw]";
default:
return "max-w-md";
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={cn("max-h-[90vh] flex flex-col", getWidthClasses())}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto flex-1">{children}</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,4 +1,4 @@
import { UserTablo } from "@ui/types/tablos.types";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { ClickOutside } from "./ClickOutside";
interface DeleteTabloModalProps {

View file

@ -1,26 +1,29 @@
import { Button } from "@ui/components/ui/button";
import { Button } from "@xtablo/ui/components/button";
import { CopyButton } from "@xtablo/ui/components/clipboard";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
import { Label } from "@ui/components/ui/label";
} from "@xtablo/ui/components/dialog";
import { Label } from "@xtablo/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/ui/select";
import { CopyButton } from "@ui/components/ui/clipboard";
} from "@xtablo/ui/components/select";
import { TypographyMuted, TypographyP } from "@xtablo/ui/components/typography";
import { useState } from "react";
import { TypographyMuted } from "@ui/components/ui/typography";
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
export type EmbedType = "embed" | "floating" | "normal";
interface EmbedConfig {
embedType: EmbedType;
backgroundVariant: ColorVariant;
buttonVariant: ColorVariant;
}
@ -28,26 +31,51 @@ interface EmbedConfig {
interface EmbedConfigModalProps {
isOpen: boolean;
onClose: () => void;
baseEmbedUrl: string;
buildPublicLink: (type: "embed" | "floating" | "normal") => string;
}
export function EmbedConfigModal({ isOpen, onClose, baseEmbedUrl }: EmbedConfigModalProps) {
export function EmbedConfigModal({ isOpen, onClose, buildPublicLink }: EmbedConfigModalProps) {
const [embedConfig, setEmbedConfig] = useState<EmbedConfig>({
embedType: "embed",
backgroundVariant: "purple",
buttonVariant: "purple",
});
const getEmbedUrl = () => {
const baseUrl = buildPublicLink(embedConfig.embedType);
const params = new URLSearchParams({
backgroundVariant: embedConfig.backgroundVariant,
mode: "embed",
buttonVariant: embedConfig.buttonVariant,
});
return `${baseEmbedUrl}?${params.toString()}`;
// Only add backgroundVariant for full embed
if (embedConfig.embedType === "embed") {
params.set("backgroundVariant", embedConfig.backgroundVariant);
}
return `${baseUrl}?${params.toString()}`;
};
const generateEmbedCode = () => {
const embedUrl = getEmbedUrl();
if (embedConfig.embedType === "floating") {
return `<!-- Xtablo Floating Widget -->
<div id="xtablo-widget-container"></div>
<script>
(function() {
var container = document.getElementById('xtablo-widget-container');
var iframe = document.createElement('iframe');
iframe.src = '${embedUrl}';
iframe.style.cssText = 'position: fixed; bottom: 0; right: 0; width: 100%; height: 100%; border: none; z-index: 999999; background: transparent;';
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('allow', 'clipboard-write');
container.appendChild(iframe);
})();
</script>`;
}
return `<iframe
src="${embedUrl}"
width="1130"
@ -78,29 +106,57 @@ export function EmbedConfigModal({ isOpen, onClose, baseEmbedUrl }: EmbedConfigM
{/* Configuration Section */}
<div className="space-y-4">
<div className="space-y-2">
<Label>Couleur de fond</Label>
<Label>Type d'intégration</Label>
<Select
value={embedConfig.backgroundVariant}
value={embedConfig.embedType}
onValueChange={(value) =>
setEmbedConfig({ ...embedConfig, backgroundVariant: value as ColorVariant })
setEmbedConfig({ ...embedConfig, embedType: value as EmbedType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${option.color}`}></div>
<span>{option.label}</span>
</div>
</SelectItem>
))}
<SelectItem value="embed">
<div className="flex flex-col items-start">
<TypographyP className="font-medium">Page complète</TypographyP>
</div>
</SelectItem>
<SelectItem value="floating">
<div className="flex flex-col items-start">
<TypographyP className="font-medium">Widget flottant</TypographyP>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{embedConfig.embedType === "embed" && (
<div className="space-y-2">
<Label>Couleur de fond</Label>
<Select
value={embedConfig.backgroundVariant}
onValueChange={(value) =>
setEmbedConfig({ ...embedConfig, backgroundVariant: value as ColorVariant })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${option.color}`}></div>
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Couleur des boutons</Label>
<Select
@ -138,7 +194,7 @@ export function EmbedConfigModal({ isOpen, onClose, baseEmbedUrl }: EmbedConfigM
/>
<Button
variant="outline"
className="flex-shrink-0"
className="shrink-0"
onClick={() => window.open(getEmbedUrl(), "_blank")}
>
Aperçu
@ -150,11 +206,13 @@ export function EmbedConfigModal({ isOpen, onClose, baseEmbedUrl }: EmbedConfigM
<div className="space-y-2 min-w-0">
<Label>Code d'intégration</Label>
<TypographyMuted className="text-xs">
Copiez ce code pour intégrer le formulaire de réservation sur votre site web
{embedConfig.embedType === "floating"
? "Copiez ce code pour ajouter le widget flottant sur votre site web"
: "Copiez ce code pour intégrer le formulaire de réservation sur votre site web"}
</TypographyMuted>
<div className="relative min-w-0">
<div className="overflow-auto max-w-full">
<pre className="p-4 pr-16 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-xs whitespace-pre-wrap break-words w-full">
<pre className="p-4 pr-16 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-xs whitespace-pre-wrap wrap-break-word w-full">
<code className="break-all">{generateEmbedCode()}</code>
</pre>
</div>

View file

@ -1,6 +1,6 @@
import { Button } from "@ui/components/ui/button";
import { Strong, Text } from "@ui/components/ui/typography";
import { EventAndTablo } from "@ui/types/events.types";
import { EventAndTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { Strong, Text } from "@xtablo/ui/components/typography";
import { CalendarIcon, User } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { CustomModal } from "./CustomModal";

View file

@ -1,6 +1,7 @@
import { getLocalTimeZone, parseDate, today } from "@internationalized/date";
import { Button } from "@ui/components/ui/button";
import { DatePicker } from "@ui/components/ui/date-picker";
import { Event, EventInsert } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { DatePicker } from "@xtablo/ui/components/date-picker";
import {
Dialog,
DialogContent,
@ -8,24 +9,23 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
import { Input } from "@ui/components/ui/input";
import { Label } from "@ui/components/ui/label";
} from "@xtablo/ui/components/dialog";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/ui/select";
import { Textarea } from "@ui/components/ui/textarea";
import { TimeInput } from "@ui/components/ui/time-input";
import { useCreateEvents, useEvent, useUpdateEvent } from "@ui/hooks/events";
import { useTablosList } from "@ui/hooks/tablos";
import { useUser } from "@ui/providers/UserStoreProvider";
import { Event, EventInsert } from "@ui/types/events.types";
} from "@xtablo/ui/components/select";
import { Textarea } from "@xtablo/ui/components/textarea";
import { TimeInput } from "@xtablo/ui/components/time-input";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useCreateEvents, useEvent, useUpdateEvent } from "../hooks/events";
import { useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
const { event_id } = useParams();

View file

@ -1,4 +1,5 @@
import { Button } from "@ui/components/ui/button";
import { EmbedConfigModal, EmbedType } from "@ui/components/EmbedConfigModal";
import { Button } from "@xtablo/ui/components/button";
import {
Card,
CardAction,
@ -6,9 +7,7 @@ import {
CardFooter,
CardHeader,
CardTitle,
} from "@ui/components/ui/card";
import { EmbedConfigModal } from "@ui/components/EmbedConfigModal";
import { EventType, EventTypeConfig, useEventTypes } from "@ui/hooks/event-types";
} from "@xtablo/ui/components/card";
import {
CheckIcon,
EditIcon,
@ -18,7 +17,9 @@ import {
XIcon,
} from "lucide-react";
import { useState } from "react";
import { useUser } from "src/providers/UserStoreProvider";
import { match } from "ts-pattern";
import { EventType, EventTypeConfig, useEventTypes } from "../hooks/event-types";
import { useUser } from "../providers/UserStoreProvider";
export function EventTypeCard({
eventType,
@ -31,7 +32,7 @@ export function EventTypeCard({
const user = useUser();
const [isEmbedModalOpen, setIsEmbedModalOpen] = useState(false);
const getPublicLink = (standardName: string | null, isEmbed: boolean = false) => {
const getPublicLink = (standardName: string | null, type: EmbedType) => {
// Sanitize user name for URL (replace spaces with hyphens, lowercase, remove special chars)
const sanitizedUserName = user.name
?.toLowerCase()
@ -41,10 +42,17 @@ export function EventTypeCard({
const shortUserId = user.id.substring(0, 6);
// Construct the public booking URL
const baseUrl = window.location.origin;
if (isEmbed) {
return `${baseUrl}/embed/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
}
return `${baseUrl}/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
return match(type)
.with("embed", () => {
return `${baseUrl}/external/?mode=embed&userInfo=${sanitizedUserName}-${shortUserId}&eventTypeStandardName=${standardName}`;
})
.with("floating", () => {
return `${baseUrl}/external/?mode=widget&userInfo=${sanitizedUserName}-${shortUserId}&eventTypeStandardName=${standardName}`;
})
.with("normal", () => {
return `${baseUrl}/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
})
.exhaustive();
};
return (
@ -67,7 +75,9 @@ export function EventTypeCard({
<Button
variant="ghost"
size="icon"
onClick={() => window.open(getPublicLink(eventType.standardName ?? null), "_blank")}
onClick={() =>
window.open(getPublicLink(eventType.standardName ?? null, "normal"), "_blank")
}
aria-label="Aperçu"
>
<ExternalLinkIcon className="w-4 h-4" />
@ -148,7 +158,9 @@ export function EventTypeCard({
<EmbedConfigModal
isOpen={isEmbedModalOpen}
onClose={() => setIsEmbedModalOpen(false)}
baseEmbedUrl={getPublicLink(eventType.standardName ?? null, true)}
buildPublicLink={(type: "embed" | "floating" | "normal") =>
getPublicLink(eventType.standardName ?? null, type)
}
/>
</Card>
);

View file

@ -1,23 +1,23 @@
import { Button } from "@ui/components/ui/button";
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
import { FieldDescription } from "@ui/components/ui/field";
import { Input } from "@ui/components/ui/input";
import { Label } from "@ui/components/ui/label";
} from "@xtablo/ui/components/dialog";
import { FieldDescription } from "@xtablo/ui/components/field";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/ui/select";
import { Textarea } from "@ui/components/ui/textarea";
import { EventTypeConfig } from "@ui/hooks/event-types";
} from "@xtablo/ui/components/select";
import { Textarea } from "@xtablo/ui/components/textarea";
import { EventTypeConfig } from "../hooks/event-types";
export function EventTypeModal({
isModalOpen,

View file

@ -1,7 +1,8 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@ui/components/ui/button";
import { ButtonGroup } from "@ui/components/ui/button-group";
import { DatePickerV1 } from "@ui/components/ui/date-picker";
import { toast } from "@xtablo/shared";
import { Button } from "@xtablo/ui/components/button";
import { ButtonGroup } from "@xtablo/ui/components/button-group";
import { DatePickerV1 } from "@xtablo/ui/components/date-picker";
import {
Dialog,
DialogContent,
@ -9,14 +10,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
import { Label } from "@ui/components/ui/label";
import { TimeInput } from "@ui/components/ui/time-input";
import { Exception } from "@ui/hooks/availabilities";
import { toast } from "@ui/lib/toast";
} from "@xtablo/ui/components/dialog";
import { Label } from "@xtablo/ui/components/label";
import { TimeInput } from "@xtablo/ui/components/time-input";
import { PlusIcon } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { Exception } from "../hooks/availabilities";
const formSchema = z.object({
exceptionType: z.enum(["day", "hours"]),

View file

@ -1,6 +1,4 @@
import { useState, useCallback } from "react";
import Cropper from "react-easy-crop";
import type { Area } from "react-easy-crop";
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
@ -8,10 +6,12 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
import { Button } from "@ui/components/ui/button";
import { Slider } from "@ui/components/ui/slider";
import { Label } from "@ui/components/ui/label";
} from "@xtablo/ui/components/dialog";
import { Label } from "@xtablo/ui/components/label";
import { Slider } from "@xtablo/ui/components/slider";
import { useCallback, useState } from "react";
import type { Area } from "react-easy-crop";
import Cropper from "react-easy-crop";
interface ImageCropDialogProps {
open: boolean;

View file

@ -1,18 +1,17 @@
import { ParsedICSEvent, parseICSFile, toast } from "@xtablo/shared";
import { EventInsert } from "@xtablo/shared/types/events.types";
import { CreateTablo } from "@xtablo/shared/types/tablos.types";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/ui/select";
import { useCreateEvents } from "@ui/hooks/events";
import { useCreateTablo, useTablosList } from "@ui/hooks/tablos";
import { toast } from "@ui/lib/toast";
import { useUser } from "@ui/providers/UserStoreProvider";
import { EventInsert } from "@ui/types/events.types";
import { CreateTablo } from "@ui/types/tablos.types";
import { ParsedICSEvent, parseICSFile } from "@ui/utils/helpers";
} from "@xtablo/ui/components/select";
import { useRef, useState } from "react";
import { useCreateEvents } from "../hooks/events";
import { useCreateTablo, useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
interface ImportICSModalProps {
onClose: () => void;
@ -175,7 +174,7 @@ export const ImportICSModal = ({ onClose }: ImportICSModalProps) => {
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-green-500 to-green-600 p-6 text-white">
<div className="bg-linear-to-r from-green-500 to-green-600 p-6 text-white">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">Importer un fichier ICS</h2>
<button

View file

@ -1,8 +1,8 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { Layout } from "@ui/components/Layout";
import { SessionProvider } from "@ui/contexts/SessionContext";
import { renderWithProviders } from "@ui/utils/testHelpers";
import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext";
import { BrowserRouter } from "react-router-dom";
import { renderWithProviders } from "../utils/testHelpers";
describe("Layout", () => {
it("renders the layout with children", () => {
@ -19,9 +19,9 @@ describe("Layout", () => {
render(
<BrowserRouter>
<SessionProvider>
<SessionTestProvider testUser={undefined}>
<Layout />
</SessionProvider>
</SessionTestProvider>
</BrowserRouter>
);

View file

@ -1,4 +1,4 @@
import { Button } from "@ui/components/ui/button";
import { Button } from "@xtablo/ui/components/button";
import { MenuIcon } from "lucide-react";
import { useState } from "react";
import { Outlet } from "react-router-dom";

View file

@ -0,0 +1,12 @@
export const LoadingSpinner = () => {
return (
<div className="flex items-center justify-center min-h-screen">
<img
src="/icon.jpg"
alt="Loading..."
role="status"
className="animate-spin rounded-full h-16 w-16 object-cover"
/>
</div>
);
};

View file

@ -1,6 +1,6 @@
import { fireEvent, screen } from "@testing-library/react";
import { MainNavigation, SideNavigation, UserMenuPopover } from "@ui/components/NavigationBar";
import { renderWithProviders } from "@ui/utils/testHelpers";
import { renderWithProviders } from "../utils/testHelpers";
describe("NavigationBar", () => {
describe("SideNavigation", () => {

View file

@ -1,16 +1,17 @@
// shadcn components
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@ui/components/ui/avatar";
import { Button } from "@ui/components/ui/button";
import { cn } from "@xtablo/shared/lib/cn.ts";
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/ui/dropdown-menu";
import { useUser } from "@ui/providers/UserStoreProvider";
import { isProd, isStaging } from "@ui/utils/helpers";
import { getXtabloIcon } from "@ui/utils/iconHelpers";
} from "@xtablo/ui/components/dropdown-menu";
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
import { cva, type VariantProps } from "class-variance-authority";
import {
CalendarCheckIcon,
CalendarIcon,
@ -31,11 +32,11 @@ import { useState } from "react";
import { Separator } from "react-aria-components";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useLogout } from "../hooks/auth";
import { isProd, isStaging } from "../lib/env";
import { useUser } from "../providers/UserStoreProvider";
import { getXtabloIcon } from "../utils/iconHelpers";
import { ThemeSwitcher } from "./ThemeSwitcher";
import { TypographyLarge, TypographyMuted } from "./ui/typography";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "src/lib/utils";
import { useLogout } from "src/hooks/auth";
type NavLinkItem = {
isActive?: boolean;
@ -49,17 +50,17 @@ function NavLink({ isActive, children }: NavLinkProps) {
<div
className={twMerge(
"group w-full gap-x-3 overflow-hidden px-2.5 py-1.5 text-nowrap hover:bg-navbar-darker hover:no-underline focus-visible:outline-offset-0 [&>[data-ui=icon]:not([class*=size-])]:size-4.5",
"[&>[data-ui=notification-badge]]:bg-navbar-darker",
"[&>[data-ui=notification-badge]]:rounded-md",
"[&>[data-ui=notification-badge]]:top-1/2",
"[&>[data-ui=notification-badge]]:right-1",
"[&>[data-ui=notification-badge]]:-translate-y-1/2",
"[&>[data-ui=notification-badge]]:bg-navbar-darker",
"[&>[data-ui=notification-badge]]:p-3",
"[&>[data-ui=nxotification-badge]]:text-xs/6",
"[&>[data-ui=notification-badge]]:font-semibold",
"*:data-[ui=notification-badge]:bg-navbar-darker",
"*:data-[ui=notification-badge]:rounded-md",
"*:data-[ui=notification-badge]:top-1/2",
"*:data-[ui=notification-badge]:right-1",
"*:data-[ui=notification-badge]:-translate-y-1/2",
"*:data-[ui=notification-badge]:bg-navbar-darker",
"*:data-[ui=notification-badge]:p-3",
"*:data-[ui=notification-badge]:text-xs/6",
"*:data-[ui=notification-badge]:font-semibold",
isActive
? "bg-navbar-darker font-semibold text-white [&>[data-ui=notification-badge]]:bg-transparent"
? "bg-navbar-darker font-semibold text-white *:data-[ui=notification-badge]:bg-transparent"
: ["font-medium", "text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"]
)}
>
@ -142,7 +143,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
sideOffset={-8}
>
<div className="flex gap-2 p-1">
<Avatar>
<Avatar className="size-8">
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
<AvatarFallback className="bg-gray-700 text-white">
{user.name?.charAt(0).toUpperCase()}
@ -394,10 +395,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
<NavLink isActive={location.pathname === "/feedback"}>
<RouterLink
to="/feedback"
className={twMerge("w-full", isCollapsed ? "" : "pl-2")}
className="w-full"
aria-label={isCollapsed ? "Feedback" : undefined}
>
<div className="flex items-center gap-x-2">
<div className={twMerge("flex items-center gap-x-2", isCollapsed ? "" : "pl-2")}>
<SendIcon className="w-5 h-5" aria-hidden="true" />
<TypographyLarge
className={twMerge(

View file

@ -1,9 +1,9 @@
import { screen, waitFor } from "@testing-library/react";
import { ProtectedRoute } from "@ui/components/ProtectedRoute";
import { SessionTestProvider } from "@ui/contexts/SessionContext";
import { renderWithRouter } from "@ui/utils/testHelpers";
import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext";
import { Route, Routes } from "react-router-dom";
import { TestUserStoreProvider } from "src/providers/UserStoreProvider";
import { TestUserStoreProvider } from "../providers/UserStoreProvider";
import { renderWithRouter } from "../utils/testHelpers";
describe("ProtectedRoute", () => {
beforeEach(() => {

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { Navigate, Outlet } from "react-router-dom";
import { match } from "ts-pattern";
import { useMaybeUser } from "../providers/UserStoreProvider";
import { LoadingSpinner } from "./LoadingSpinner";
import { useMaybeUser } from "src/providers/UserStoreProvider";
interface ProtectedRouteProps {
fallback?: string;
@ -42,7 +42,7 @@ export const ProtectedRoute = ({ fallback, shouldRedirectToCurrentPage }: Protec
return match(status)
.with("loading", () => <LoadingSpinner />)
.with("should-land-user", () => <Navigate to="/landing" replace />)
.with("should-land-user", () => <Navigate to={redirectUrl} replace />)
.with("should-redirect", () => <Navigate to={redirectUrl} replace />)
.with("should-pass", () => <Outlet />)
.exhaustive();

View file

@ -1,18 +1,18 @@
import { Button } from "@ui/components/ui/button";
import { useInviteUser } from "@ui/hooks/invite";
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { DownloadIcon, Trash2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { FileTrigger } from "react-aria-components";
import { useInviteUser } from "../hooks/invite";
import {
useCreateTabloFile,
useDeleteTabloFile,
useDownloadTabloFile,
useTabloFileNames,
} from "@ui/hooks/tablo_data";
import { useTabloMembers } from "@ui/hooks/tablos";
import { toast } from "@ui/lib/toast";
import { useUser } from "@ui/providers/UserStoreProvider";
import { TabloUpdate, UserTablo } from "@ui/types/tablos.types";
import { FileTrigger } from "react-aria-components";
import { DownloadIcon, Trash2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
} from "../hooks/tablo_data";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
@ -226,10 +226,10 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
<ClickOutside onClickOutside={onClose}>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full min-w-[32rem] max-w-2xl max-h-[95vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<div className="flex items-center space-x-3 flex-1">
{/* Tablo Color/Image Preview */}
<div className="flex-shrink-0">
<div className="shrink-0">
{tablo.image ? (
<img
src={tablo.image}
@ -314,7 +314,7 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
{error && (
<div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center space-x-2">
<svg
className="w-5 h-5 text-red-500 flex-shrink-0"
className="w-5 h-5 text-red-500 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -866,7 +866,7 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 bg-gray-50 dark:bg-gray-900/50">
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0 bg-gray-50 dark:bg-gray-900/50">
<div className="flex space-x-3 ml-auto">
{!isAdmin ? (
<button

View file

@ -1,4 +1,4 @@
import { Button } from "@ui/components/ui/button";
import { Button } from "@xtablo/ui/components/button";
import { ArrowLeft, ArrowRight, HelpCircle, X } from "lucide-react";
import React, { useState } from "react";
import { twMerge } from "tailwind-merge";

View file

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { ThemeSwitcher } from "@ui/components/ThemeSwitcher";
import * as ThemeContext from "@ui/contexts/ThemeContext";
import * as ThemeContext from "@xtablo/shared/contexts/ThemeContext";
import { vi } from "vitest";
// Mock the ThemeProvider and useTheme hook

View file

@ -1,6 +1,6 @@
import { Button } from "@ui/components/ui/button";
import { ButtonGroup } from "@ui/components/ui/button-group";
import { useTheme } from "@ui/contexts/ThemeContext";
import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
import { Button } from "@xtablo/ui/components/button";
import { ButtonGroup } from "@xtablo/ui/components/button-group";
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
const translation = {

View file

@ -1,25 +1,26 @@
import { toast } from "@xtablo/shared";
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
} from "@xtablo/ui/components/dialog";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/ui/select";
import { useTablosList } from "@ui/hooks/tablos";
import { useGenerateWebcalToken } from "@ui/hooks/webcal";
import { toast } from "@ui/lib/toast";
import { useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
} from "@xtablo/ui/components/select";
import { CopyIcon } from "lucide-react";
import { useState } from "react";
import { useTablosList } from "../hooks/tablos";
import { useGenerateWebcalToken } from "../hooks/webcal";
interface WebcalModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -42,8 +43,7 @@ export const WebcalModal = ({ open, onOpenChange }: WebcalModalProps) => {
},
{ timeout: 2000 }
);
} catch (error) {
console.error("Failed to copy:", error);
} catch (_error) {
toast.add(
{
title: "Erreur",
@ -69,7 +69,7 @@ export const WebcalModal = ({ open, onOpenChange }: WebcalModalProps) => {
}}
>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="bg-gradient-to-r from-purple-500 to-purple-600 -m-6 mb-0 p-6 text-white rounded-t-lg">
<DialogHeader className="bg-linear-to-r from-purple-500 to-purple-600 -m-6 mb-0 p-6 text-white rounded-t-lg">
<DialogTitle className="text-xl font-medium text-white">
Synchronisation de calendrier
</DialogTitle>

View file

@ -1,7 +1,7 @@
import { Button } from "@ui/components/ui/button";
import { getXtabloIcon } from "@ui/utils/iconHelpers";
import { Button } from "@xtablo/ui/components/button";
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { getXtabloIcon } from "../utils/iconHelpers";
export function Header() {
const logo = getXtabloIcon();

View file

@ -1,14 +1,13 @@
import {
createClient,
Session,
User as SupabaseUser,
} from "@supabase/supabase-js";
import { Session, User as SupabaseUser } from "@supabase/supabase-js";
import { useMutation } from "@tanstack/react-query";
import { api, queryClient } from "@ui/lib/api";
import { toast } from "@ui/lib/toast";
import { queryClient, toast, useSession } from "@xtablo/shared";
import { useSignUpToStream } from "@xtablo/shared/hooks/auth";
import { AxiosInstance } from "axios";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { match } from "ts-pattern";
import { api } from "../lib/api";
import { supabase } from "../lib/supabase";
export type User = SupabaseUser & {
user_metadata: {
@ -20,15 +19,6 @@ export type User = SupabaseUser & {
};
};
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables");
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
interface SignUpData {
email: string;
password: string;
@ -51,7 +41,7 @@ interface AuthResponse {
export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
const navigate = useNavigate();
const [errors, setErrors] = useState<Record<string, string>>({});
const { signUpToStream } = useSignUpToStream();
const { signUpToStream } = useSignUpToStream(api);
const { mutate, isPending } = useMutation<
AuthResponse,
{ message: string; code: string },
@ -110,100 +100,10 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
return { mutate, isPending, errors };
}
export function useSignUpWithoutPassword() {
const [errors, setErrors] = useState<Record<string, string>>({});
const { signUpToStream } = useSignUpToStream();
const { mutateAsync, isPending } = useMutation<
AuthResponse,
{ message: string; code: string },
{ email: string; name: string }
>({
mutationFn: async (data: { email: string; name: string }) => {
// Generate a temporary password for the user
const tempPassword =
Math.random().toString(36).slice(-8) +
Math.random().toString(36).slice(-8);
const { data: response, error } = await supabase.auth.signUp({
email: data.email.trim(),
password: tempPassword,
options: {
data: {
first_name: data.name.trim().split(" ")[0] || "",
last_name: data.name.trim().split(" ").slice(1).join(" ") || "",
business_name: "",
},
},
});
if (error) throw error;
if (response.session?.access_token) {
await signUpToStream(response.session.access_token);
}
// Mark the user as temporary
if (response.session?.access_token) {
await api.post(
"/api/v1/users/mark-temporary",
{
temporary_password: tempPassword,
},
{
headers: {
Authorization: `Bearer ${response.session.access_token}`,
},
}
);
}
return response;
},
onError: (error) => {
const errMap: Record<string, string> = {};
match(error.code)
.with("user_already_exists", () => {
errMap["email"] = "Cette adresse email est déjà utilisée";
})
.otherwise(() => {
toast.add(
{
title: "Erreur",
description: error.message,
type: "error",
position: "top-left",
},
{
timeout: 5000,
}
);
});
setErrors(errMap);
},
});
return { mutateAsync, isPending, errors };
}
export function useSignUpToStream() {
const { mutate: signUpToStream } = useMutation({
mutationFn: async (accessToken: string) => {
const { data } = await api.post<{ streamToken: string }>(
"/api/v1/users/sign-up-to-stream",
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
return data;
},
});
return { signUpToStream };
}
export function useLoginEmail({ redirectUrl }: { redirectUrl: string | null }) {
const navigate = useNavigate();
const [errors, setErrors] = useState<Record<string, string>>({});
const { signUpToStream } = useSignUpToStream();
const { signUpToStream } = useSignUpToStream(api);
const { mutate, isPending } = useMutation<
AuthResponse,
{ message: string; code: string },
@ -290,3 +190,12 @@ export function useLogout() {
},
});
}
export const useAuthedApi = (): AxiosInstance => {
const { session } = useSession();
return api.create({
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
};

View file

@ -1,10 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { supabase } from "@ui/hooks/auth";
import { queryClient } from "@ui/lib/api";
import { Database } from "@ui/types/database.types";
import { queryClient, toast } from "@xtablo/shared";
import { Database } from "@xtablo/shared/types/database.types";
import { useEffect, useState } from "react";
import { toast } from "src/lib/toast";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
export type TimeRange = {
start: string;
@ -34,27 +33,23 @@ export type Exception = {
const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6];
export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce(
(acc, day) => {
if (day === 5 || day === 6) {
acc[day] = {
enabled: false,
timeRanges: [{ start: "09:00", end: "17:00" }],
};
} else {
acc[day] = {
enabled: true,
timeRanges: [{ start: "09:00", end: "17:00" }],
};
}
return acc;
},
{} as WeeklyAvailability
);
export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce((acc, day) => {
if (day === 5 || day === 6) {
acc[day] = {
enabled: false,
timeRanges: [{ start: "09:00", end: "17:00" }],
};
} else {
acc[day] = {
enabled: true,
timeRanges: [{ start: "09:00", end: "17:00" }],
};
}
return acc;
}, {} as WeeklyAvailability);
export function useAvailabilities() {
const { session } = useSession();
const user = useUser();
const { data: availabilities, isLoading } = useQuery<
Database["public"]["Tables"]["availabilities"]["Row"]
>({
@ -63,12 +58,12 @@ export function useAvailabilities() {
const { data, error } = await supabase
.from("availabilities")
.select("*")
.eq("user_id", session?.user.id)
.eq("user_id", user.id)
.limit(1);
if (error) throw error;
return data?.[0] as Database["public"]["Tables"]["availabilities"]["Row"];
},
enabled: !!session?.user.id,
enabled: !!user.id,
});
const { mutate: updateAvailabilities } = useMutation<
@ -87,8 +82,7 @@ export function useAvailabilities() {
newException?: Exception | null;
}) => {
const newAvailabilities = updatedAvailabilities;
const newExceptions =
(availabilities?.exceptions as Exception[] | null) || [];
const newExceptions = (availabilities?.exceptions as Exception[] | null) || [];
if (newException) {
newExceptions.push(newException);
}
@ -96,7 +90,7 @@ export function useAvailabilities() {
{
availability_data: newAvailabilities,
exceptions: newExceptions,
user_id: session?.user.id,
user_id: user.id,
},
{
onConflict: "user_id",
@ -121,24 +115,16 @@ export function useAvailabilities() {
},
});
const { mutate: deleteException } = useMutation<
void,
Error,
{ exceptionIndex: number }
>({
const { mutate: deleteException } = useMutation<void, Error, { exceptionIndex: number }>({
mutationFn: async ({ exceptionIndex }: { exceptionIndex: number }) => {
const currentExceptions =
(availabilities?.exceptions as Exception[] | null) || [];
const updatedExceptions = currentExceptions.filter(
(_, index) => index !== exceptionIndex
);
const currentExceptions = (availabilities?.exceptions as Exception[] | null) || [];
const updatedExceptions = currentExceptions.filter((_, index) => index !== exceptionIndex);
const { error } = await supabase.from("availabilities").upsert(
{
availability_data:
availabilities?.availability_data || DEFAULT_AVAILABILITIES,
availability_data: availabilities?.availability_data || DEFAULT_AVAILABILITIES,
exceptions: updatedExceptions,
user_id: session?.user.id,
user_id: user.id,
},
{
onConflict: "user_id",
@ -156,14 +142,11 @@ export function useAvailabilities() {
},
});
const [draftAvailabilities, setDraftAvailabilities] =
useState<WeeklyAvailability | null>(null);
const [draftAvailabilities, setDraftAvailabilities] = useState<WeeklyAvailability | null>(null);
useEffect(() => {
if (availabilities?.availability_data) {
setDraftAvailabilities(
availabilities.availability_data as WeeklyAvailability
);
setDraftAvailabilities(availabilities.availability_data as WeeklyAvailability);
}
}, [availabilities?.availability_data]);

View file

@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { Database } from "@ui/types/database.types";
import { supabase } from "./auth";
import { useSession } from "@xtablo/shared";
import { Database } from "@xtablo/shared/types/database.types";
import { supabase } from "../lib/supabase";
type Devis = Database["public"]["Tables"]["devis"];

View file

@ -1,9 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { queryClient } from "@ui/lib/api";
import { Database } from "@ui/types/database.types";
import { queryClient } from "@xtablo/shared";
import { Database } from "@xtablo/shared/types/database.types";
import { useMemo } from "react";
import { supabase } from "./auth";
import { useUser } from "src/providers/UserStoreProvider";
import { supabase } from "../lib/supabase";
export type EventTypeData = {
id: string;
@ -49,7 +49,7 @@ export type EventTypePayload = {
const QUERY_KEY = ["event-types"];
export function useEventTypes() {
const { session } = useSession();
const user = useUser();
const { data: eventTypesData, isLoading } = useQuery<
Database["public"]["Tables"]["event_types"]["Row"][]
@ -59,12 +59,12 @@ export function useEventTypes() {
const { data, error } = await supabase
.from("event_types")
.select("*")
.eq("user_id", session?.user.id)
.eq("user_id", user.id)
.is("deleted_at", null);
if (error) throw error;
return data;
},
enabled: !!session?.user.id,
enabled: !!user.id,
});
const { mutate: addEventType } = useMutation<
@ -78,7 +78,7 @@ export function useEventTypes() {
const { error } = await supabase.from("event_types").insert({
config: eventType,
is_active: true,
user_id: session?.user.id,
user_id: user.id,
});
if (error) throw error;
},

View file

@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "@ui/lib/toast";
import { useUser } from "@ui/providers/UserStoreProvider";
import { Event, EventAndTablo, EventInsert, EventUpdate } from "@ui/types/events.types";
import { supabase } from "./auth";
import { toast } from "@xtablo/shared";
import { Event, EventAndTablo, EventInsert, EventUpdate } from "@xtablo/shared/types/events.types";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
// Fetch events for a specific tablo
export const useEventsByTablo = (tabloId: string | null) => {

View file

@ -1,7 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { FeedbackData } from "@ui/pages/feedback";
import { useUser } from "@ui/providers/UserStoreProvider";
import { supabase } from "./auth";
import { supabase } from "../lib/supabase";
import { FeedbackData } from "../pages/feedback";
import { useUser } from "../providers/UserStoreProvider";
// Create new feedback
export const useCreateFeedback = () => {

View file

@ -1,10 +1,9 @@
import { QueryKey, useMutation, useQuery } from "@tanstack/react-query";
import { supabase } from "@ui/hooks/auth";
import { toast } from "@ui/lib/toast";
import { useUser } from "@ui/providers/UserStoreProvider";
import { queryClient } from "src/lib/api";
import { Tables } from "@ui/types/database.types";
import { queryClient, toast } from "@xtablo/shared";
import { Tables } from "@xtablo/shared/types/database.types";
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
type IntroductionConfig = {
intro_email: string;
@ -24,8 +23,7 @@ export const useIntroduction = (): {
const { mutate: updateIntroduction, isPending: updateIntroductionPending } =
useUpsertIntroduction();
const [draftIntroduction, setDraftIntroduction] =
useState<IntroductionConfig | null>(null);
const [draftIntroduction, setDraftIntroduction] = useState<IntroductionConfig | null>(null);
useEffect(() => {
if (data) {
@ -112,8 +110,7 @@ export function useUpsertIntroduction() {
onError: () => {
toast.add({
title: "Erreur",
description:
"Une erreur est survenue lors de la mise à jour de votre introduction",
description: "Une erreur est survenue lors de la mise à jour de votre introduction",
type: "error",
position: "top-center",
});
@ -130,10 +127,7 @@ export function useDeleteIntroduction() {
const { mutate, isPending } = useMutation({
mutationFn: async () => {
const { error } = await supabase
.from("user_introductions")
.delete()
.eq("user_id", user.id);
const { error } = await supabase.from("user_introductions").delete().eq("user_id", user.id);
if (error) {
throw new Error(error.message);
@ -154,8 +148,7 @@ export function useDeleteIntroduction() {
onError: () => {
toast.add({
title: "Erreur",
description:
"Une erreur est survenue lors de la suppression de votre introduction",
description: "Une erreur est survenue lors de la suppression de votre introduction",
type: "error",
position: "top-center",
});

View file

@ -0,0 +1,41 @@
import { useMutation } from "@tanstack/react-query";
import { toast } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
// Invite user by email
export const useInviteUser = () => {
const api = useAuthedApi();
const { mutate, isPending } = useMutation({
mutationFn: async ({ email, tablo_id }: { email: string; tablo_id: string }) => {
const { data } = await api.post("/api/v1/tablos/invite", {
email,
tablo_id,
});
return data;
},
onSuccess: () => {
toast.add(
{
title: "Invitation envoyée avec succès",
description: "L'utilisateur a été invité avec succès",
type: "success",
},
{
timeout: 2000,
}
);
},
});
return { mutate, isPending };
};
export const useJoinTablo = () => {
const api = useAuthedApi();
const { mutate } = useMutation({
mutationFn: async ({ token }: { token: string }) => {
const { data } = await api.post("/api/v1/tablos/join", { token });
return data;
},
});
return mutate;
};

View file

@ -1,9 +1,8 @@
import { QueryKey, useMutation } from "@tanstack/react-query";
import { supabase } from "@ui/hooks/auth";
import { api, queryClient } from "@ui/lib/api";
import { toast } from "@ui/lib/toast";
import { useUser } from "@ui/providers/UserStoreProvider";
import { useSession } from "@ui/contexts/SessionContext";
import { queryClient, toast } from "@xtablo/shared";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
import { useAuthedApi } from "./auth";
/**
* Hook to update user profile using Supabase client
@ -13,13 +12,7 @@ export function useUpdateProfile() {
const user = useUser();
const { mutate, isPending } = useMutation({
mutationFn: async ({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}) => {
mutationFn: async ({ firstName, lastName }: { firstName: string; lastName: string }) => {
// Build the name from first_name and last_name if provided
const fullName = [firstName, lastName].filter(Boolean).join(" ");
@ -49,8 +42,7 @@ export function useUpdateProfile() {
onError: () => {
toast.add({
title: "Erreur",
description:
"Une erreur est survenue lors de la mise à jour de votre profil",
description: "Une erreur est survenue lors de la mise à jour de votre profil",
type: "error",
position: "top-center",
});
@ -66,7 +58,7 @@ type FileUploadRequest = {
};
export const useUploadAvatar = () => {
const { session } = useSession();
const api = useAuthedApi();
const { mutate, isPending } = useMutation({
mutationFn: async ({ file }: { file: File | null }) => {
if (!file) {
@ -102,16 +94,7 @@ export const useUploadAvatar = () => {
};
// Upload to backend
const response = await api.post(
"/api/v1/users/profile/avatar",
uploadRequest,
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
"Content-Type": "application/json",
},
}
);
const response = await api.post("/api/v1/users/profile/avatar", uploadRequest);
if (response.status !== 200) {
throw new Error("Failed to upload avatar");
@ -132,8 +115,7 @@ export const useUploadAvatar = () => {
onError: (error: Error) => {
toast.add({
title: "Erreur",
description:
error.message || "Une erreur est survenue lors de l'upload",
description: error.message || "Une erreur est survenue lors de l'upload",
type: "error",
position: "top-center",
});
@ -143,14 +125,10 @@ export const useUploadAvatar = () => {
};
export const useRemoveAvatar = () => {
const { session } = useSession();
const api = useAuthedApi();
const { mutateAsync, isPending } = useMutation({
mutationFn: async () => {
await api.delete("/api/v1/users/profile/avatar", {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
await api.delete("/api/v1/users/profile/avatar");
},
onSuccess: () => {
toast.add({

View file

@ -1,7 +1,6 @@
import { QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { api } from "@ui/lib/api";
import { toast } from "@ui/lib/toast";
import { toast } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
// Types for tablo data API responses
export interface TabloFile {
@ -34,15 +33,11 @@ export const toastOptions = {
// Hook to get all file names for a tablo
export function useTabloFileNames(tabloId: string) {
const { session } = useSession();
const api = useAuthedApi();
const { data, isLoading, error } = useQuery<TabloFileList>({
queryKey: ["tablo-files", tabloId],
queryFn: async () => {
const response = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
const response = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`);
if (response.status !== 200) {
throw new Error("Failed to fetch tablo files");
}
@ -55,15 +50,11 @@ export function useTabloFileNames(tabloId: string) {
// Hook to get a specific file from a tablo
export function useTabloFile(tabloId: string, fileName: string) {
const { session } = useSession();
const api = useAuthedApi();
return useQuery<TabloFile>({
queryKey: ["tablo-file", tabloId, fileName],
queryFn: async () => {
const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`);
if (response.status !== 200) {
throw new Error("Failed to fetch file");
}
@ -75,16 +66,12 @@ export function useTabloFile(tabloId: string, fileName: string) {
// Hook to download a file from a tablo
export function useDownloadTabloFile() {
const { session } = useSession();
const api = useAuthedApi();
return useMutation<void, Error, { tabloId: string; fileName: string }>({
mutationFn: async ({ tabloId, fileName }) => {
try {
const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`);
if (response.status !== 200) {
throw new Error("Failed to download file");
@ -144,7 +131,7 @@ export function useDownloadTabloFile() {
// Hook to create a new file in a tablo
export function useCreateTabloFile() {
const { session } = useSession();
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation<
@ -153,11 +140,7 @@ export function useCreateTabloFile() {
{ tabloId: string; fileName: string; data: FileUploadRequest }
>({
mutationFn: async ({ tabloId, fileName, data }) => {
const response = await api.post(`/api/v1/tablo-data/${tabloId}/${fileName}`, data, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
const response = await api.post(`/api/v1/tablo-data/${tabloId}/${fileName}`, data);
if (response.status !== 200) {
throw new Error("Failed to create file");
}
@ -193,7 +176,7 @@ export function useCreateTabloFile() {
// Hook to update an existing file in a tablo
export function useUpdateTabloFile() {
const { session } = useSession();
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation<
@ -202,11 +185,7 @@ export function useUpdateTabloFile() {
{ tabloId: string; fileName: string; data: FileUploadRequest }
>({
mutationFn: async ({ tabloId, fileName, data }) => {
const response = await api.put(`/api/v1/tablo-data/${tabloId}/${fileName}`, data, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
const response = await api.put(`/api/v1/tablo-data/${tabloId}/${fileName}`, data);
if (response.status !== 200) {
throw new Error("Failed to update file");
}
@ -242,16 +221,12 @@ export function useUpdateTabloFile() {
// Hook to delete a file from a tablo
export function useDeleteTabloFile() {
const { session } = useSession();
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation<FileOperationResponse, Error, { tabloId: string; fileName: string }>({
mutationFn: async ({ tabloId, fileName }) => {
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/${fileName}`, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/${fileName}`);
if (response.status !== 200) {
throw new Error("Failed to delete file");
}

View file

@ -1,15 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { invalidatePublicSlots } from "@ui/hooks/public";
import { api } from "@ui/lib/api";
import { toast } from "@ui/lib/toast";
import { useUser } from "@ui/providers/UserStoreProvider";
import { Database } from "@ui/types/database.types";
import { EventInsertInTablo } from "@ui/types/events.types";
import { RemoveNullFromObject } from "@ui/types/removeNull";
import { CreateTablo } from "@ui/types/tablos.types";
import { useNavigate } from "react-router-dom";
import { supabase } from "./auth";
import { toast } from "@xtablo/shared";
import { Database } from "@xtablo/shared/types/database.types";
import { RemoveNullFromObject } from "@xtablo/shared/types/removeNull";
import { CreateTablo } from "@xtablo/shared/types/tablos.types";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
import { useAuthedApi } from "./auth";
type Tablo = Database["public"]["Tables"]["tablos"];
@ -49,17 +45,13 @@ export const useTablo = (id: string) => {
// Fetch tablo members
export const useTabloMembers = (tabloId: string) => {
const { session } = useSession();
const api = useAuthedApi();
const { data, isLoading, error } = useQuery({
queryKey: ["tablo-members", tabloId],
queryFn: async () => {
const { data } = await api.get<{
members: { id: string; name: string; is_admin: boolean }[];
}>(`/api/v1/tablos/members/${tabloId}`, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
}>(`/api/v1/tablos/members/${tabloId}`);
return data.members;
},
});
@ -69,16 +61,12 @@ export const useTabloMembers = (tabloId: string) => {
// Create new tablo
export const useCreateTablo = () => {
const { session } = useSession();
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tablo: CreateTablo) => {
const { data } = await api.post("/api/v1/tablos/create", tablo, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
const { data } = await api.post("/api/v1/tablos/create", tablo);
return data;
},
onSuccess: () => {
@ -100,82 +88,17 @@ export const useCreateTablo = () => {
});
};
// Create tablo with owner
export const useCreateTabloWithOwner = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation<
{ id: string },
unknown,
CreateTablo & {
owner_short_id: string;
event: EventInsertInTablo;
access_token: string;
}
>({
mutationFn: async (
tabloAndToken: CreateTablo & {
owner_short_id: string;
event: EventInsertInTablo;
access_token: string;
}
) => {
const { data } = await api.post(
"/api/v1/tablos/create-and-invite",
{
owner_short_id: tabloAndToken.owner_short_id,
event: tabloAndToken.event,
},
{
headers: {
Authorization: `Bearer ${tabloAndToken.access_token}`,
},
}
);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["tablos"] });
invalidatePublicSlots();
navigate(`/chat/${data.id}`, { replace: true });
navigate(0);
},
onError: (error) => {
console.error(error);
toast.add(
{
title: "Échec de la création du tablo",
description: "Veuillez réessayer",
type: "error",
},
{
timeout: 5000,
}
);
},
});
};
// Update tablo
export const useUpdateTablo = () => {
const queryClient = useQueryClient();
const { session } = useSession();
const api = useAuthedApi();
return useMutation({
mutationFn: async ({ id, ...tablo }: TabloUpdate & { id: string }) => {
const { data } = await api.patch(
`/api/v1/tablos/update`,
{
id,
...tablo,
},
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
const { data } = await api.patch(`/api/v1/tablos/update`, {
id,
...tablo,
});
return data;
},
onSuccess: (_, { id }) => {
@ -203,16 +126,11 @@ export const useUpdateTablo = () => {
// Delete tablo (soft delete)
export const useDeleteTablo = () => {
const queryClient = useQueryClient();
const { session } = useSession();
const api = useAuthedApi();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/api/v1/tablos/delete`, {
data: { id },
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
await api.delete(`/api/v1/tablos/delete`, { data: { id } });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tablos"] });

View file

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@ui/lib/api";
import { api } from "../lib/api";
interface UserMetadata {
email: string;

View file

@ -1,7 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { api } from "@ui/lib/api";
import { toast } from "@ui/lib/toast";
import { toast } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
export interface WebcalToken {
token: string;
@ -18,19 +17,13 @@ export interface WebcalResponse {
// Generate a new webcal token
export const useGenerateWebcalToken = () => {
const { session } = useSession();
const api = useAuthedApi();
const { mutate, isPending, data } = useMutation({
mutationFn: async (tablo_id: string | null): Promise<WebcalResponse> => {
const { data } = await api.post(
"/api/v1/tablos/webcal/generate-url",
{ tablo_id },
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
const { data } = await api.post("/api/v1/tablos/webcal/generate-url", {
tablo_id,
});
return data;
},
onSuccess: () => {

3
apps/main/src/lib/api.ts Normal file
View file

@ -0,0 +1,3 @@
import { buildApi } from "@xtablo/shared";
export const api = buildApi(import.meta.env.VITE_API_URL);

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