diff --git a/.gitignore b/.gitignore
index 882155b..2582c5c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,7 @@ __pycache__/
.pytest_cache/
.coverage
htmlcov/
+
+.turbo
+dist
+.wrangler
\ No newline at end of file
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 0000000..2329c93
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -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)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3605441
--- /dev/null
+++ b/README.md
@@ -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]
diff --git a/api/.env.development b/api/.env.development
index 8e2eb2b..20ef2b3 100644
--- a/api/.env.development
+++ b/api/.env.development
@@ -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"
diff --git a/api/src/config.ts b/api/src/config.ts
index b2cf36f..f2d688e 100644
--- a/api/src/config.ts
+++ b/api/src/config.ts
@@ -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",
};
diff --git a/api/src/index.ts b/api/src/index.ts
index 3b46319..3c23040 100644
--- a/api/src/index.ts
+++ b/api/src/index.ts
@@ -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",
diff --git a/apps/external/biome.json b/apps/external/biome.json
new file mode 100644
index 0000000..7d53391
--- /dev/null
+++ b/apps/external/biome.json
@@ -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"
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/apps/external/index.html b/apps/external/index.html
new file mode 100644
index 0000000..f49f514
--- /dev/null
+++ b/apps/external/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Xtablo External
+
+
+
+
+
+
diff --git a/apps/external/package.json b/apps/external/package.json
new file mode 100644
index 0000000..8513522
--- /dev/null
+++ b/apps/external/package.json
@@ -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"
+ }
+}
diff --git a/ui/public/icon.jpg b/apps/external/public/icon.jpg
similarity index 100%
rename from ui/public/icon.jpg
rename to apps/external/public/icon.jpg
diff --git a/ui/public/logo_dark.png b/apps/external/public/logo_dark.png
similarity index 100%
rename from ui/public/logo_dark.png
rename to apps/external/public/logo_dark.png
diff --git a/ui/public/logo_white.png b/apps/external/public/logo_white.png
similarity index 100%
rename from ui/public/logo_white.png
rename to apps/external/public/logo_white.png
diff --git a/ui/public/staging_icon.jpg b/apps/external/public/staging_icon.jpg
similarity index 100%
rename from ui/public/staging_icon.jpg
rename to apps/external/public/staging_icon.jpg
diff --git a/ui/public/vite.svg b/apps/external/public/vite.svg
similarity index 100%
rename from ui/public/vite.svg
rename to apps/external/public/vite.svg
diff --git a/ui/src/components/CustomModal.tsx b/apps/external/src/CustomModal.tsx
similarity index 94%
rename from ui/src/components/CustomModal.tsx
rename to apps/external/src/CustomModal.tsx
index c52c0ea..e19f466 100644
--- a/ui/src/components/CustomModal.tsx
+++ b/apps/external/src/CustomModal.tsx
@@ -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 {
diff --git a/ui/src/pages/EmbeddedBookingPage.tsx b/apps/external/src/EmbeddedBookingPage.tsx
similarity index 94%
rename from ui/src/pages/EmbeddedBookingPage.tsx
rename to apps/external/src/EmbeddedBookingPage.tsx
index e0b0b4f..89ccaf9 100644
--- a/ui/src/pages/EmbeddedBookingPage.tsx
+++ b/apps/external/src/EmbeddedBookingPage.tsx
@@ -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 */}
@@ -512,7 +523,7 @@ export function EmbeddedBookingPage() {
{
+ 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
(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 (
+
+ );
+ }
+
+ return (
+
+ {/* Floating Button */}
+
+
+
+
+ {/* Floating Widget Popup */}
+ {isWidgetOpen && (
+
+ {/* Header */}
+
+
+ {(userProfile as { name: string; avatar_url?: string })?.avatar_url ? (
+

+ ) : (
+
+
+
+ )}
+
+
+ {eventType?.name || "Type d'événement"}
+
+
+ {userProfile?.name || "Professionnel"}
+
+
+
+
+
+
+ {/* Event Info */}
+ {(eventType?.duration || eventType?.location) && (
+
+
+ {eventType?.duration && (
+
+
+ {formatDuration(eventType.duration)}
+
+ )}
+ {eventType?.location && (
+
+
+ {eventType.location}
+
+ )}
+
+
+ )}
+
+ {/* Calendar and Slots */}
+
+ {/* Calendar */}
+
+
+
+ {formatMonthYear(currentDate)}
+
+
+
+
+
+
+
+ {/* Calendar Grid */}
+
+ {["L", "M", "M", "J", "V", "S", "D"].map((day, i) => (
+
+ {day}
+
+ ))}
+
+
+
+ {getDaysInMonth(currentDate).map((date, index) => (
+
+ {date ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ {/* Time Slots */}
+ {selectedDate && (
+
+
+ Créneaux disponibles
+
+
+ {selectedDate.toLocaleDateString("fr-FR", {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+ })}
+
+
+
+ {getAvailableSlots(selectedDate).map((slot, index) => (
+
+ ))}
+
+ {getAvailableSlots(selectedDate).length === 0 && (
+
+
+ Aucun créneau disponible
+
+
+ )}
+
+
+ )}
+
+ {!selectedDate && (
+
+
+
+ Sélectionnez une date pour voir les créneaux disponibles
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+ )}
+
+ {/* Booking Modal */}
+
+ {selectedSlot && (
+
+
+
+
+ {selectedSlot.date.toLocaleDateString("fr-FR", {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+ })}
+
+
+
+
+ {selectedSlot.slot.time}
+
+
+ )}
+
+
+
+
+ setFormData((prev) => ({ ...prev, name: e.target.value }))}
+ disabled={!!user}
+ />
+ {formErrors.name && }
+
+
+
+
+ setFormData((prev) => ({ ...prev, email: e.target.value }))}
+ disabled={!!user}
+ />
+ {formErrors.email && }
+
+
+ {!user && (
+
+
+ Un compte sera créé avec ces informations pour gérer votre réservation.
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/LoadingSpinner.tsx b/apps/external/src/LoadingSpinner.tsx
similarity index 100%
rename from ui/src/components/LoadingSpinner.tsx
rename to apps/external/src/LoadingSpinner.tsx
diff --git a/ui/src/providers/UserStoreProvider.tsx b/apps/external/src/UserStoreProvider.tsx
similarity index 91%
rename from ui/src/providers/UserStoreProvider.tsx
rename to apps/external/src/UserStoreProvider.tsx
index 2d0d573..876625d 100644
--- a/ui/src/providers/UserStoreProvider.tsx
+++ b/apps/external/src/UserStoreProvider.tsx
@@ -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;
diff --git a/apps/external/src/lib/api.ts b/apps/external/src/lib/api.ts
new file mode 100644
index 0000000..e00027f
--- /dev/null
+++ b/apps/external/src/lib/api.ts
@@ -0,0 +1,3 @@
+import { buildApi } from "@xtablo/shared";
+
+export const api = buildApi(import.meta.env.VITE_API_URL);
diff --git a/apps/external/src/lib/supabase.ts b/apps/external/src/lib/supabase.ts
new file mode 100644
index 0000000..99c2e17
--- /dev/null
+++ b/apps/external/src/lib/supabase.ts
@@ -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);
diff --git a/ui/src/main.css b/apps/external/src/main.css
similarity index 99%
rename from ui/src/main.css
rename to apps/external/src/main.css
index 125ddee..a896ff7 100644
--- a/ui/src/main.css
+++ b/apps/external/src/main.css
@@ -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);
}
}
diff --git a/apps/external/src/main.tsx b/apps/external/src/main.tsx
new file mode 100644
index 0000000..c79e82d
--- /dev/null
+++ b/apps/external/src/main.tsx
@@ -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(
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/apps/external/src/routes.tsx b/apps/external/src/routes.tsx
new file mode 100644
index 0000000..311d609
--- /dev/null
+++ b/apps/external/src/routes.tsx
@@ -0,0 +1,12 @@
+import { Route, Routes } from "react-router-dom";
+import { EmbeddedBookingPage } from "./EmbeddedBookingPage";
+import { FloatingBookingWidget } from "./FloatingBookingWidget";
+
+export default function AppRoutes() {
+ return (
+
+ } />
+ } />
+
+ );
+}
diff --git a/ui/src/vite-env.d.ts b/apps/external/src/vite-env.d.ts
similarity index 100%
rename from ui/src/vite-env.d.ts
rename to apps/external/src/vite-env.d.ts
diff --git a/apps/external/tsconfig.json b/apps/external/tsconfig.json
new file mode 100644
index 0000000..d6100fa
--- /dev/null
+++ b/apps/external/tsconfig.json
@@ -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": []
+}
diff --git a/apps/external/tsconfig.tsbuildinfo b/apps/external/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..bf0b063
--- /dev/null
+++ b/apps/external/tsconfig.tsbuildinfo
@@ -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"}
\ No newline at end of file
diff --git a/apps/external/vite.config.ts b/apps/external/vite.config.ts
new file mode 100644
index 0000000..7c8420c
--- /dev/null
+++ b/apps/external/vite.config.ts
@@ -0,0 +1,26 @@
+///
+
+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"),
+ },
+ },
+ },
+});
diff --git a/apps/main/.biomeignore b/apps/main/.biomeignore
new file mode 100644
index 0000000..5669c8d
--- /dev/null
+++ b/apps/main/.biomeignore
@@ -0,0 +1,4 @@
+# Generated Wrangler files
+worker-configuration.d.ts
+worker/
+
diff --git a/ui/.env.production b/apps/main/.env.production
similarity index 100%
rename from ui/.env.production
rename to apps/main/.env.production
diff --git a/ui/.env.staging b/apps/main/.env.staging
similarity index 100%
rename from ui/.env.staging
rename to apps/main/.env.staging
diff --git a/apps/main/biome.json b/apps/main/biome.json
new file mode 100644
index 0000000..a8ec73b
--- /dev/null
+++ b/apps/main/biome.json
@@ -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"
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/ui/index.html b/apps/main/index.html
similarity index 97%
rename from ui/index.html
rename to apps/main/index.html
index e0981a4..598566b 100644
--- a/ui/index.html
+++ b/apps/main/index.html
@@ -10,4 +10,4 @@