Huge bump with turborepo

This commit is contained in:
Arthur Belleville 2025-10-23 11:54:45 +02:00
parent 35d9989eaa
commit aa36bc6656
No known key found for this signature in database
206 changed files with 11473 additions and 6818 deletions

4
.gitignore vendored
View file

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

172
README.md Normal file
View file

@ -0,0 +1,172 @@
# 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-components/ # 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
- For other services: Python 3.11+, Go 1.21+
### Installation
Install all dependencies:
```bash
pnpm install
```
This will install dependencies for all apps and packages in the workspace.
### Development
Run all apps in development mode:
```bash
turbo dev
```
Run specific app:
```bash
# Main UI app (http://localhost:5173)
turbo dev --filter @xtablo/main
# External microfrontend (http://localhost:5174)
turbo dev --filter @xtablo/external
```
### Building
Build all apps:
```bash
turbo build
```
Build specific app:
```bash
turbo build --filter @xtablo/main
turbo build --filter @xtablo/external
```
### Linting and Formatting
```bash
# Lint all packages
turbo lint
# Format all packages
turbo format
```
## Packages
### @xtablo/ui-components
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, Input, Dialog } from "@xtablo/ui-components";
```
### @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
## Scripts
- `turbo dev` - Start all apps in development mode
- `turbo build` - Build all apps
- `turbo lint` - Lint all packages
- `turbo format` - Format all packages
- `turbo typecheck` - Type check all packages
- `turbo test` - Run tests for all packages
- `turbo clean` - Clean all build artifacts and node_modules
## Adding a New Package
1. Create a new directory under `packages/`
2. Add a `package.json` with name `@xtablo/package-name`
3. Update `pnpm-workspace.yaml` if needed (already configured for `packages/*`)
4. Install in your app: `pnpm --filter @xtablo/your-app add @xtablo/package-name@workspace:*`
## 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-components/` - Shared UI components
- `packages/shared/` - Shared utilities and logic
All import paths have been updated to use workspace packages (`@xtablo/ui-components`, `@xtablo/shared`).
## Contributing
When adding new shared code:
1. Add to the appropriate package (`ui-components` for UI, `shared` for logic/utils)
2. Export from the package's `index.ts`
3. Use the workspace import in your apps
## License
[Your License Here]

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

@ -0,0 +1,342 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": [
"ui/src/**/*",
"ui/worker/**/*",
"ui/*.{ts,tsx,js,jsx,json}",
"api/src/**/*",
"api/*.{ts,tsx,js,jsx,json}",
"xtablo-expo/app/**/*",
"xtablo-expo/components/**/*",
"xtablo-expo/hooks/**/*",
"xtablo-expo/lib/**/*",
"xtablo-expo/providers/**/*",
"xtablo-expo/stores/**/*",
"xtablo-expo/types/**/*",
"xtablo-expo/*.{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": [
"ui/src/**/*.{ts,tsx}",
"ui/worker/**/*.{ts,tsx}",
"ui/*.{ts,tsx}",
"api/src/**/*.{ts,tsx}",
"api/*.{ts,tsx}",
"xtablo-expo/**/*.{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"
}
}
}
},
{
"includes": ["xtablo-expo/**/*.{js,jsx,ts,tsx}"],
"linter": {
"rules": {
"correctness": {
"noUndeclaredVariables": "off"
},
"suspicious": {
"noExplicitAny": "warn"
}
}
}
},
{
"includes": ["api/src/**/*.{js,ts}"],
"linter": {
"rules": {
"style": {
"noCommonJs": "off"
}
}
}
}
]
}

View file

@ -3,9 +3,10 @@
<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/external/main.tsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

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

@ -0,0 +1,41 @@
{
"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"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
"@tailwindcss/vite": "^4.0.14",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.14",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
"vite": "^6.2.2",
"vite-tsconfig-paths": "^5.1.4"
},
"dependencies": {
"@xtablo/ui": "workspace:*",
"@xtablo/shared": "workspace:*",
"@tanstack/react-query": "^5.69.0",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-router-dom": "^7.9.4",
"tailwind-merge": "^3.0.2",
"ts-pattern": "^5.6.2",
"zustand": "^5.0.5"
}
}

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View file

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

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

View file

@ -1,16 +1,16 @@
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 { CustomModal } from "./CustomModal";
import { LoadingSpinner } from "./LoadingSpinner";
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 { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useSignUpWithoutPassword } from "@xtablo/shared/hooks/auth";
import { TimeSlot, usePublicSlots } from "@xtablo/shared/hooks/public";
import { useCreateTabloWithOwner } from "@xtablo/shared";
import { useMaybeUser } from "./UserStoreProvider";
import { EventInsertInTablo } from "@xtablo/shared/types/events.types";
import {
CalendarIcon,
ChevronLeftIcon,
@ -22,6 +22,8 @@ import {
import { useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { supabase } from "./lib/supabase";
import { api } from "./lib/api";
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
@ -187,16 +189,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 +207,21 @@ export function EmbeddedBookingPage() {
const txtColor = getTextColorFromBackground(backgroundVariant);
const mutedTxtColor = getMutedTextColorFromBackground(backgroundVariant);
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
shortUserId || "",
event_type_standard_name || ""
);
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner();
console.log({shortUserId, eventTypeStandardName})
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
api,
shortUserId || "",
eventTypeStandardName || ""
);
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api,
() => {
handleCloseModal();
}
);
const userProfile = publicSlots?.user;
const eventType = publicSlots?.eventType;
@ -460,7 +469,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 +477,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 +521,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 +539,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 +557,7 @@ export function EmbeddedBookingPage() {
<div className="flex items-center gap-3">
<div
className={twMerge(
"w-10 h-10 rounded-lg border flex items-center justify-center flex-shrink-0",
"w-10 h-10 rounded-lg border flex items-center justify-center shrink-0",
bgColors.iconBg,
bgColors.iconBorder
)}

View file

@ -1,16 +1,16 @@
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, 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 { CustomModal } from "./CustomModal";
import { LoadingSpinner } from "./LoadingSpinner";
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 { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useSignUpWithoutPassword } from "@xtablo/shared/hooks/auth";
import { TimeSlot, usePublicSlots } from "@xtablo/shared/hooks/public";
// import { useCreateTabloWithOwner } from "@xtablo/shared";
import { useMaybeUser } from "./UserStoreProvider";
import { EventInsertInTablo } from "@xtablo/shared/types/events.types";
import {
CalendarIcon,
ChevronLeftIcon,
@ -23,6 +23,9 @@ import {
import { useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { supabase } from "./lib/supabase";
import { api } from "./lib/api";
import { useCreateTabloWithOwner } from "@xtablo/shared";
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
@ -108,15 +111,14 @@ const buttonColors = {
};
export function FloatingBookingWidget() {
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;
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
// Get variants from URL params with fallback to purple
const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple";
@ -125,11 +127,17 @@ export function FloatingBookingWidget() {
const btnColors = buttonColors[buttonVariant];
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
api,
shortUserId || "",
event_type_standard_name || ""
eventTypeStandardName || ""
);
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner();
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api,
() => {
handleCloseModal();
setIsWidgetOpen(false);
}
);
const userProfile = publicSlots?.user;
const eventType = publicSlots?.eventType;
@ -411,10 +419,10 @@ export function FloatingBookingWidget() {
<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 flex-shrink-0"
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 flex-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>
)}
@ -430,7 +438,7 @@ export function FloatingBookingWidget() {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
className="h-8 w-8 shrink-0"
onClick={() => {
setIsWidgetOpen(false);
setSelectedDate(null);

97
apps/external/src/UserStoreProvider.tsx vendored Normal file
View file

@ -0,0 +1,97 @@
import { useQuery } from "@tanstack/react-query";
import { LoadingSpinner } from "./LoadingSpinner";
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 { api } from "./lib/api";
export type User = Tables<"profiles"> & {
streamToken: string | null;
};
const UserStoreContext = React.createContext<StoreApi<User> | null>(null);
export const UserStoreProvider = ({ children }: { children: React.ReactNode }) => {
const { session } = useSession();
const shouldFetchUser = !!session?.access_token;
const { data: user, isPending } = useQuery<User | null>({
queryKey: ["user"],
queryFn: async () => {
try {
const { data: user } = await api.get<User>("/api/v1/users/me", {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
return user;
} catch (error) {
console.error("Failed to get user:", error);
return null;
}
},
enabled: shouldFetchUser,
});
if (isPending && shouldFetchUser) {
return <LoadingSpinner />;
}
if (!user) {
return children;
}
const store = createStore<User>()(() => user);
return (
<UserStoreContext.Provider value={store as StoreApi<User>}>
{children}
</UserStoreContext.Provider>
);
};
export const useUser = () => {
const store = React.useContext(UserStoreContext);
if (!store) {
throw new Error("Missing UserStoreProvider");
}
return useStore(store);
};
export const useMaybeUser = () => {
const store = React.useContext(UserStoreContext);
if (!store) {
return null;
}
return useStore(store);
};
// TestUserStoreProvider component
export const TestUserStoreProvider = ({
children,
user,
}: {
children: React.ReactNode;
user: User | null;
}) => {
if (!user) {
return children;
}
const store = createStore<User>()(() => user);
return (
<UserStoreContext.Provider value={store as StoreApi<User>}>
{children}
</UserStoreContext.Provider>
);
};
// // Test useUser hook
// export const useTestUser = () => {
// const store = React.useContext(TestUserStoreContext);
// if (!store) {
// throw new Error("Missing TestUserStoreProvider");
// }
// return useStore(store);
// };

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

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

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

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

View file

@ -1,18 +1,15 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { queryClient } from "@ui/lib/api";
import { externalRoutes } from "./routes";
import { ThemeProvider } from "src/contexts/ThemeContext";
import { Toaster } from "src/components/ui/sonner";
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
import { queryClient } from "@xtablo/shared";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { BrowserRouter as Router } from "react-router-dom";
import AppRoutes from "./routes";
import "../main.css";
const ExternalAppRoutes = () => {
const element = useRoutes(externalRoutes);
return element;
};
import "@xtablo/ui/styles/globals.css";
import "./main.css";
createRoot(document.getElementById("external-root")!).render(
<StrictMode>
@ -21,7 +18,7 @@ createRoot(document.getElementById("external-root")!).render(
<Toaster />
<Router>
<div className="min-h-screen bg-background">
<ExternalAppRoutes />
<AppRoutes />
</div>
</Router>
</ThemeProvider>

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

@ -0,0 +1,12 @@
import { Routes, Route } 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>
);
}

View file

@ -1,33 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"*": ["./*"],
"@ui/*": ["./src/*"],
"@external/*": ["./src/external/*"]
"@xtablo/ui": ["../../packages/ui/src"],
"@xtablo/ui/*": ["../../packages/ui/src/*"],
"@xtablo/shared": ["../../packages/shared/src"],
"@xtablo/shared/*": ["../../packages/shared/src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/shared" }
]
}

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

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

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

@ -0,0 +1,342 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": [
"ui/src/**/*",
"ui/worker/**/*",
"ui/*.{ts,tsx,js,jsx,json}",
"api/src/**/*",
"api/*.{ts,tsx,js,jsx,json}",
"xtablo-expo/app/**/*",
"xtablo-expo/components/**/*",
"xtablo-expo/hooks/**/*",
"xtablo-expo/lib/**/*",
"xtablo-expo/providers/**/*",
"xtablo-expo/stores/**/*",
"xtablo-expo/types/**/*",
"xtablo-expo/*.{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": [
"ui/src/**/*.{ts,tsx}",
"ui/worker/**/*.{ts,tsx}",
"ui/*.{ts,tsx}",
"api/src/**/*.{ts,tsx}",
"api/*.{ts,tsx}",
"xtablo-expo/**/*.{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"
}
}
}
},
{
"includes": ["xtablo-expo/**/*.{js,jsx,ts,tsx}"],
"linter": {
"rules": {
"correctness": {
"noUndeclaredVariables": "off"
},
"suspicious": {
"noExplicitAny": "warn"
}
}
}
},
{
"includes": ["api/src/**/*.{js,ts}"],
"linter": {
"rules": {
"style": {
"noCommonJs": "off"
}
}
}
}
]
}

13
apps/main/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpg+xml" href="/public/icon.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XTablo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -1,19 +1,20 @@
{
"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:staging": "pnpm run build:staging && wrangler deploy --env staging",
"deploy:prod": "pnpm run build:prod && wrangler deploy --env production",
"cf-typegen": "wrangler types",
"test": "vitest run --mode dev --passWithNoTests",
"test:watch": "vitest watch --passWithNoTests",
@ -55,7 +56,7 @@
"react-dom": "19.0.0",
"rollup-plugin-visualizer": "^5.14.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.14",
"tailwindcss": "^4.1.15",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
@ -66,6 +67,8 @@
"wrangler": "^4.24.3"
},
"dependencies": {
"@xtablo/ui": "workspace:*",
"@xtablo/shared": "workspace:*",
"@datadog/browser-rum": "^6.13.0",
"@datadog/browser-rum-react": "^6.13.0",
"@hookform/resolvers": "^5.2.2",
@ -85,7 +88,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@react-stately/calendar": "^3.7.1",
"@supabase/supabase-js": "^2.49.3",
"@tailwindcss/vite": "^4.0.14",
"@tailwindcss/vite": "^4.1.5",
"@tanstack/react-query": "^5.69.0",
"@types/react-router-dom": "^5.3.3",
"@typescript/native-preview": "7.0.0-dev.20251010.1",
@ -109,11 +112,5 @@
"uuid": "^11.1.0",
"zod": "^4.1.12",
"zustand": "^5.0.5"
},
"pnpm": {
"overrides": {
"form-data": "^4.0.4",
"linkifyjs": "^4.3.2"
}
}
}

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,10 +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 { Toaster } from "@xtablo/ui/components/sonner";
import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { UserStoreProvider } from "./providers/UserStoreProvider";
import { routes } from "./lib/routes";
import { DatadogRumProvider } from "./providers/DatadogRumProvider";
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
import { supabase } from "./lib/supabase";
const AppRoutes = () => {
const element = useRoutes(routes);
@ -14,7 +15,7 @@ const AppRoutes = () => {
export const App = () => {
return (
<ThemeProvider>
<SessionProvider>
<SessionProvider supabase={supabase}>
<UserStoreProvider>
<Toaster />
<Router>

View file

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View file

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

View file

@ -1,13 +1,26 @@
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 { renderWithRouter } from "../utils/testHelpers";
import { Route, Routes } from "react-router-dom";
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 +70,7 @@ describe("PublicRoute", () => {
it("renders public content when user is not authenticated", async () => {
renderWithRouter(
<SessionTestProvider>
<SessionTestProvider testUser={undefined}>
<Routes>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,26 +1,26 @@
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 { 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 { CopyButton } from "@xtablo/ui/components/clipboard";
import { useState } from "react";
import { TypographyMuted, TypographyP } from "@ui/components/ui/typography";
import { TypographyMuted, TypographyP } from "@xtablo/ui/components/typography";
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
type EmbedType = "full" | "floating";
export type EmbedType = "embed" | "floating" | "normal";
interface EmbedConfig {
embedType: EmbedType;
@ -31,30 +31,25 @@ interface EmbedConfig {
interface EmbedConfigModalProps {
isOpen: boolean;
onClose: () => void;
baseEmbedUrl: string;
baseFloatingUrl: string;
buildPublicLink: (type: "embed" | "floating" | "normal") => string;
}
export function EmbedConfigModal({
isOpen,
onClose,
baseEmbedUrl,
baseFloatingUrl,
}: EmbedConfigModalProps) {
export function EmbedConfigModal({ isOpen, onClose, buildPublicLink }: EmbedConfigModalProps) {
const [embedConfig, setEmbedConfig] = useState<EmbedConfig>({
embedType: "full",
embedType: "embed",
backgroundVariant: "purple",
buttonVariant: "purple",
});
const getEmbedUrl = () => {
const baseUrl = embedConfig.embedType === "full" ? baseEmbedUrl : baseFloatingUrl;
const baseUrl = buildPublicLink(embedConfig.embedType);
const params = new URLSearchParams({
mode: "embed",
buttonVariant: embedConfig.buttonVariant,
});
// Only add backgroundVariant for full embed
if (embedConfig.embedType === "full") {
if (embedConfig.embedType === "embed") {
params.set("backgroundVariant", embedConfig.backgroundVariant);
}
@ -122,7 +117,7 @@ export function EmbedConfigModal({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
<SelectItem value="embed">
<div className="flex flex-col items-start">
<TypographyP className="font-medium">Page complète</TypographyP>
</div>
@ -136,7 +131,7 @@ export function EmbedConfigModal({
</Select>
</div>
{embedConfig.embedType === "full" && (
{embedConfig.embedType === "embed" && (
<div className="space-y-2">
<Label>Couleur de fond</Label>
<Select
@ -199,7 +194,7 @@ export function EmbedConfigModal({
/>
<Button
variant="outline"
className="flex-shrink-0"
className="shrink-0"
onClick={() => window.open(getEmbedUrl(), "_blank")}
>
Aperçu
@ -217,7 +212,7 @@ export function EmbedConfigModal({
</TypographyMuted>
<div className="relative min-w-0">
<div className="overflow-auto max-w-full">
<pre className="p-4 pr-16 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-xs whitespace-pre-wrap break-words w-full">
<pre className="p-4 pr-16 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-xs whitespace-pre-wrap wrap-break-word w-full">
<code className="break-all">{generateEmbedCode()}</code>
</pre>
</div>

View file

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

View file

@ -1,6 +1,6 @@
import { getLocalTimeZone, parseDate, today } from "@internationalized/date";
import { Button } from "@ui/components/ui/button";
import { DatePicker } from "@ui/components/ui/date-picker";
import { Button } from "@xtablo/ui/components/button";
import { DatePicker } from "@xtablo/ui/components/date-picker";
import {
Dialog,
DialogContent,
@ -8,22 +8,22 @@ 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 { useCreateEvents, useEvent, useUpdateEvent } from "../hooks/events";
import { useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { Event, EventInsert } from "@xtablo/shared/types/events.types";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";

View file

@ -1,4 +1,4 @@
import { Button } from "@ui/components/ui/button";
import { Button } from "@xtablo/ui/components/button";
import {
Card,
CardAction,
@ -6,9 +6,9 @@ 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 { EmbedConfigModal, EmbedType } from "@ui/components/EmbedConfigModal";
import { EventType, EventTypeConfig, useEventTypes } from "../hooks/event-types";
import {
CheckIcon,
EditIcon,
@ -18,7 +18,8 @@ import {
XIcon,
} from "lucide-react";
import { useState } from "react";
import { useUser } from "src/providers/UserStoreProvider";
import { useUser } from "../providers/UserStoreProvider";
import { match } from "ts-pattern";
export function EventTypeCard({
eventType,
@ -31,10 +32,7 @@ export function EventTypeCard({
const user = useUser();
const [isEmbedModalOpen, setIsEmbedModalOpen] = useState(false);
const getPublicLink = (
standardName: string | null,
type: "normal" | "embed" | "floating" = "normal"
) => {
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()
@ -44,13 +42,17 @@ export function EventTypeCard({
const shortUserId = user.id.substring(0, 6);
// Construct the public booking URL
const baseUrl = window.location.origin;
if (type === "embed") {
return `${baseUrl}/embed/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
}
if (type === "floating") {
return `${baseUrl}/widget/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 (
@ -73,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" />
@ -154,8 +158,9 @@ export function EventTypeCard({
<EmbedConfigModal
isOpen={isEmbedModalOpen}
onClose={() => setIsEmbedModalOpen(false)}
baseEmbedUrl={getPublicLink(eventType.standardName ?? null, "embed")}
baseFloatingUrl={getPublicLink(eventType.standardName ?? null, "floating")}
buildPublicLink={(type: "embed" | "floating" | "normal") =>
getPublicLink(eventType.standardName ?? null, type)
}
/>
</Card>
);

View file

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

View file

@ -1,7 +1,7 @@
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 { 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,11 +9,11 @@ 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 { Exception } from "../hooks/availabilities";
import { toast } from "@xtablo/shared";
import { PlusIcon } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";

View file

@ -8,10 +8,10 @@ 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 { Button } from "@xtablo/ui/components/button";
import { Slider } from "@xtablo/ui/components/slider";
import { Label } from "@xtablo/ui/components/label";
interface ImageCropDialogProps {
open: boolean;

View file

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

View file

@ -1,7 +1,7 @@
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 { renderWithProviders } from "../utils/testHelpers";
import { BrowserRouter } from "react-router-dom";
describe("Layout", () => {
@ -19,9 +19,9 @@ describe("Layout", () => {
render(
<BrowserRouter>
<SessionProvider>
<SessionTestProvider testUser={undefined}>
<Layout />
</SessionProvider>
</SessionTestProvider>
</BrowserRouter>
);

View file

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

View file

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

View file

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

View file

@ -1,16 +1,16 @@
// shadcn components
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@ui/components/ui/avatar";
import { Button } from "@ui/components/ui/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";
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@xtablo/ui/components/dropdown-menu";
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
import { useUser } from "../providers/UserStoreProvider";
import { isProd, isStaging } from "../lib/env";
import { getXtabloIcon } from "../utils/iconHelpers";
import {
CalendarCheckIcon,
CalendarIcon,
@ -32,10 +32,9 @@ import { Separator } from "react-aria-components";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
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";
import { cn } from "@xtablo/shared/lib/cn.ts";
import { useLogout } from "../hooks/auth";
type NavLinkItem = {
isActive?: boolean;
@ -49,17 +48,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 +141,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()}
@ -162,7 +161,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
</div>
<MenuSeparator />
<MenuDropdownItem
icon={<LogOutIcon className="w-5 h-5" aria-hidden="true" />}
label="Se déconnecter"
@ -394,10 +393,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(
@ -415,4 +414,4 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
</ul>
</nav>
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { Button } from "@ui/components/ui/button";
import { getXtabloIcon } from "@ui/utils/iconHelpers";
import { Button } from "@xtablo/ui/components/button";
import { getXtabloIcon } from "../utils/iconHelpers";
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";

View file

@ -1,14 +1,15 @@
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 { api } from "../lib/api";
import { queryClient } from "@xtablo/shared";
import { toast } from "@xtablo/shared";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { match } from "ts-pattern";
import { supabase } from "../lib/supabase";
import { AxiosInstance } from "axios";
import { useSession } from "@xtablo/shared";
import { useSignUpToStream } from "@xtablo/shared/hooks/auth";
export type User = SupabaseUser & {
user_metadata: {
@ -20,15 +21,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 +43,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 +102,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 +192,12 @@ export function useLogout() {
},
});
}
export const useAuthedApi = (): AxiosInstance => {
const { session } = useSession();
return api.create({
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
};

View file

@ -1,10 +1,10 @@
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 { supabase } from "../lib/supabase";
import { queryClient } from "@xtablo/shared";
import { Database } from "@xtablo/shared/types/database.types";
import { useEffect, useState } from "react";
import { toast } from "src/lib/toast";
import { toast } from "@xtablo/shared";
import { useUser } from "../providers/UserStoreProvider";
export type TimeRange = {
start: string;
@ -53,8 +53,7 @@ export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce(
);
export function useAvailabilities() {
const { session } = useSession();
const user = useUser();
const { data: availabilities, isLoading } = useQuery<
Database["public"]["Tables"]["availabilities"]["Row"]
>({
@ -63,12 +62,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<
@ -96,7 +95,7 @@ export function useAvailabilities() {
{
availability_data: newAvailabilities,
exceptions: newExceptions,
user_id: session?.user.id,
user_id: user.id,
},
{
onConflict: "user_id",
@ -138,7 +137,7 @@ export function useAvailabilities() {
availability_data:
availabilities?.availability_data || DEFAULT_AVAILABILITIES,
exceptions: updatedExceptions,
user_id: session?.user.id,
user_id: user.id,
},
{
onConflict: "user_id",

View file

@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { Database } from "@ui/types/database.types";
import { supabase } from "./auth";
import { useSession } from "@xtablo/shared";
import { Database } from "@xtablo/shared/types/database.types";
import { supabase } from "../lib/supabase";
type Devis = Database["public"]["Tables"]["devis"];
@ -25,7 +25,10 @@ export const useDevis = (id: string) => {
return useQuery({
queryKey: ["devis", id],
queryFn: async () => {
const { data, error } = await supabase.from("devis").select("*").eq("id", id);
const { data, error } = await supabase
.from("devis")
.select("*")
.eq("id", id);
if (error) throw error;
return data[0];
},

View file

@ -1,9 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { queryClient } from "@ui/lib/api";
import { Database } from "@ui/types/database.types";
import { queryClient } from "@xtablo/shared";
import { Database } from "@xtablo/shared/types/database.types";
import { useMemo } from "react";
import { supabase } from "./auth";
import { supabase } from "../lib/supabase";
import { useUser } from "src/providers/UserStoreProvider";
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;
},
@ -95,7 +95,13 @@ export function useEventTypes() {
eventType: EventTypePayload;
}
>({
mutationFn: async ({ id, eventType }: { id: string; eventType: EventTypePayload }) => {
mutationFn: async ({
id,
eventType,
}: {
id: string;
eventType: EventTypePayload;
}) => {
const { error } = await supabase
.from("event_types")
.update({

View file

@ -1,8 +1,13 @@
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 { useUser } from "../providers/UserStoreProvider";
import {
Event,
EventAndTablo,
EventInsert,
EventUpdate,
} from "@xtablo/shared/types/events.types";
import { supabase } from "../lib/supabase";
// Fetch events for a specific tablo
export const useEventsByTablo = (tabloId: string | null) => {
@ -34,7 +39,11 @@ export const useEventsByTablo = (tabloId: string | null) => {
};
// Fetch events for a specific date range
export const useEventsByDateRange = (tabloId: string, startDate: string, endDate: string) => {
export const useEventsByDateRange = (
tabloId: string,
startDate: string,
endDate: string
) => {
return useQuery({
queryKey: ["events", tabloId, "dateRange", startDate, endDate],
queryFn: async () => {
@ -103,7 +112,9 @@ export const useCreateEvents = () => {
queryClient.invalidateQueries({ queryKey: ["events"] });
const eventsCount = Array.isArray(data) ? data.length : 1;
const pluralizeText =
eventsCount === 1 ? "L'événement a été créé" : "Les événements ont été créés";
eventsCount === 1
? "L'événement a été créé"
: "Les événements ont été créés";
toast.add(
{
title: `${eventsCount} événement(s) créé(s)`,

View file

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

View file

@ -1,9 +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 { supabase } from "../lib/supabase";
import { toast } from "@xtablo/shared";
import { useUser } from "../providers/UserStoreProvider";
import { queryClient } from "@xtablo/shared";
import { Tables } from "@xtablo/shared/types/database.types";
import { useEffect, useState } from "react";
type IntroductionConfig = {

View file

@ -1,11 +1,10 @@
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";
// Invite user by email
export const useInviteUser = () => {
const { session } = useSession();
const api = useAuthedApi();
const { mutate, isPending } = useMutation({
mutationFn: async ({
email,
@ -14,18 +13,10 @@ export const useInviteUser = () => {
email: string;
tablo_id: string;
}) => {
const { data } = await api.post(
"/api/v1/tablos/invite",
{
email,
tablo_id,
},
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
const { data } = await api.post("/api/v1/tablos/invite", {
email,
tablo_id,
});
return data;
},
onSuccess: () => {
@ -45,18 +36,10 @@ export const useInviteUser = () => {
};
export const useJoinTablo = () => {
const { session } = useSession();
const api = useAuthedApi();
const { mutate } = useMutation({
mutationFn: async ({ token }: { token: string }) => {
const { data } = await api.post(
"/api/v1/tablos/join",
{ token },
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
const { data } = await api.post("/api/v1/tablos/join", { token });
return data;
},
});

View file

@ -1,9 +1,9 @@
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 { supabase } from "../lib/supabase";
import { queryClient } from "@xtablo/shared";
import { toast } from "@xtablo/shared";
import { useUser } from "../providers/UserStoreProvider";
import { useAuthedApi } from "./auth";
/**
* Hook to update user profile using Supabase client
@ -66,7 +66,7 @@ type FileUploadRequest = {
};
export const useUploadAvatar = () => {
const { session } = useSession();
const api = useAuthedApi();
const { mutate, isPending } = useMutation({
mutationFn: async ({ file }: { file: File | null }) => {
if (!file) {
@ -104,13 +104,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",
},
}
uploadRequest
);
if (response.status !== 200) {
@ -143,14 +137,10 @@ export const useUploadAvatar = () => {
};
export const useRemoveAvatar = () => {
const { session } = useSession();
const api = useAuthedApi();
const { mutateAsync, isPending } = useMutation({
mutationFn: async () => {
await api.delete("/api/v1/users/profile/avatar", {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
await api.delete("/api/v1/users/profile/avatar");
},
onSuccess: () => {
toast.add({

View file

@ -1,7 +1,11 @@
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 {
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
// Types for tablo data API responses
export interface TabloFile {
@ -34,15 +38,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 +55,13 @@ 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 +73,14 @@ 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 +140,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 +149,10 @@ 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 +188,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 +197,10 @@ 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 +236,18 @@ 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 }>({
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");
}
@ -286,7 +282,10 @@ export function useDeleteTabloFile() {
}
// Utility function to invalidate all tablo data queries for a specific tablo
export const invalidateTabloData = (queryClient: QueryClient, tabloId: string) => {
export const invalidateTabloData = (
queryClient: QueryClient,
tabloId: string
) => {
queryClient.invalidateQueries({
queryKey: ["tablo-files", tabloId],
});
@ -315,7 +314,11 @@ export function useUploadTabloFile() {
// Check if file exists first (unless overwrite is explicitly true)
if (!overwrite) {
try {
const existingFile = queryClient.getQueryData(["tablo-file", tabloId, fileName]);
const existingFile = queryClient.getQueryData([
"tablo-file",
tabloId,
fileName,
]);
if (existingFile) {
// File exists, use update
return await updateFile.mutateAsync({ tabloId, fileName, data });

View file

@ -1,21 +1,19 @@
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 { useUser } from "../providers/UserStoreProvider";
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 { useAuthedApi } from "./auth";
type Tablo = Database["public"]["Tables"]["tablos"];
type TabloUpdate = Tablo["Update"];
type UserTablo = RemoveNullFromObject<Database["public"]["Views"]["user_tablos"]["Row"]>;
type UserTablo = RemoveNullFromObject<
Database["public"]["Views"]["user_tablos"]["Row"]
>;
// Fetch all tablos
export const useTablosList = () => {
@ -23,7 +21,10 @@ export const useTablosList = () => {
return useQuery({
queryKey: ["tablos"],
queryFn: async () => {
const { data, error } = await supabase.from("user_tablos").select("*").eq("user_id", user.id);
const { data, error } = await supabase
.from("user_tablos")
.select("*")
.eq("user_id", user.id);
if (error) throw error;
const tablos = data as UserTablo[];
return tablos;
@ -49,17 +50,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 +66,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 +93,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 +131,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"] });
@ -238,7 +161,10 @@ export const useGetAllTabloAccess = () => {
const { data, isLoading, error } = useQuery({
queryKey: ["tablo-access", user.id],
queryFn: async () => {
const { data } = await supabase.from("tablo_access").select("*").eq("user_id", user.id);
const { data } = await supabase
.from("tablo_access")
.select("*")
.eq("user_id", user.id);
return data;
},
});

View file

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

View file

@ -1,7 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { api } from "@ui/lib/api";
import { toast } from "@ui/lib/toast";
import { toast } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
export interface WebcalToken {
token: string;
@ -18,26 +17,21 @@ 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: () => {
toast.add(
{
title: "URL de synchronisation générée",
description: "Vous pouvez maintenant ajouter ce calendrier à votre application",
description:
"Vous pouvez maintenant ajouter ce calendrier à votre application",
type: "success",
},
{

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

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

4
apps/main/src/lib/env.ts Normal file
View file

@ -0,0 +1,4 @@
// Environment helpers using Vite's import.meta.env
export const isStaging = import.meta.env.MODE === "staging";
export const isProd = import.meta.env.MODE === "production";
export const isDev = import.meta.env.DEV;

View file

@ -1,25 +1,25 @@
import { AuthenticationGateway } from "@ui/components/AuthenticationGateway";
import { EventModal } from "@ui/components/EventModal";
import { Layout } from "@ui/components/Layout";
import { ProtectedRoute } from "@ui/components/ProtectedRoute";
import { AvailabilitiesPage } from "@ui/pages/availabilities";
import { BookingsPage } from "@ui/pages/bookings";
import { ChantiersPage } from "@ui/pages/chantiers";
import { ChatPage } from "@ui/pages/chat";
import { EventTypesPage } from "@ui/pages/event-types-page";
import { FeedbackPage } from "@ui/pages/feedback";
import { JoinPage } from "@ui/pages/join";
import { LandingPage } from "@ui/pages/landing";
import { LoginPage } from "@ui/pages/login";
import { NotFoundPage } from "@ui/pages/NotFoundPage";
import { OAuthSigninPage } from "@ui/pages/oauth-signin";
import { PublicBookingPage } from "@ui/pages/PublicBookingPage";
import { PlanningPage } from "@ui/pages/planning";
import { ResetPasswordPage } from "@ui/pages/reset-password";
import SettingsPage from "@ui/pages/settings";
import { SignUpPage } from "@ui/pages/signup";
import { TabloPage } from "@ui/pages/tablo";
import ChatProvider from "@ui/providers/ChatProvider";
import { AuthenticationGateway } from "../components/AuthenticationGateway";
import { EventModal } from "../components/EventModal";
import { Layout } from "../components/Layout";
import { ProtectedRoute } from "../components/ProtectedRoute";
import { AvailabilitiesPage } from "../pages/availabilities";
import { BookingsPage } from "../pages/bookings";
import { ChantiersPage } from "../pages/chantiers";
import { ChatPage } from "../pages/chat";
import { EventTypesPage } from "../pages/event-types-page";
import { FeedbackPage } from "../pages/feedback";
import { JoinPage } from "../pages/join";
import { LandingPage } from "../pages/landing";
import { LoginPage } from "../pages/login";
import { NotFoundPage } from "../pages/NotFoundPage";
import { OAuthSigninPage } from "../pages/oauth-signin";
import { PublicBookingPage } from "../pages/PublicBookingPage";
import { PlanningPage } from "../pages/planning";
import { ResetPasswordPage } from "../pages/reset-password";
import SettingsPage from "../pages/settings";
import { SignUpPage } from "../pages/signup";
import { TabloPage } from "../pages/tablo";
import ChatProvider from "../providers/ChatProvider";
import { RouteObject } from "react-router-dom";
export const routes: RouteObject[] = [

View file

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

1271
apps/main/src/main.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,10 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { queryClient } from "./lib/api";
import { queryClient } from "@xtablo/shared";
import "stream-chat-react/dist/css/v2/index.css";
import "@xtablo/ui/styles/globals.css";
import "./main.css";
createRoot(document.getElementById("root")!).render(

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