4
.gitignore
vendored
|
|
@ -31,3 +31,7 @@ __pycache__/
|
|||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
.turbo
|
||||
dist
|
||||
.wrangler
|
||||
329
DEVELOPMENT.md
Normal 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
|
|
@ -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]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
)}
|
||||
694
apps/external/src/FloatingBookingWidget.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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);
|
||||
15
ui/src/main.css → apps/external/src/main.css
vendored
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
# Generated Wrangler files
|
||||
worker-configuration.d.ts
|
||||
worker/
|
||||
|
||||
300
apps/main/biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -10,4 +10,4 @@
|
|||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
BIN
apps/main/public/logo_white.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
1
apps/main/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
BIN
apps/main/src/assets/icon.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/main/src/assets/icon.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
BIN
apps/main/src/assets/staging_icon.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
|
|
@ -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();
|
||||
|
|
@ -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>} />
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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 = ({
|
||||
|
|
@ -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"
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
||||
45
apps/main/src/components/CustomModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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();
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
@ -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"]),
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
@ -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";
|
||||
12
apps/main/src/components/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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", () => {
|
||||
|
|
@ -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(
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
@ -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";
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
@ -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}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
@ -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"];
|
||||
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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 = () => {
|
||||
|
|
@ -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",
|
||||
});
|
||||
41
apps/main/src/hooks/invite.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"] });
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
import { buildApi } from "@xtablo/shared";
|
||||
|
||||
export const api = buildApi(import.meta.env.VITE_API_URL);
|
||||