commit
a751697356
38 changed files with 1047 additions and 749 deletions
|
|
@ -1,31 +1,360 @@
|
|||
# Use the latest 2.1 version of CircleCI pipeline process engine.
|
||||
# See: https://circleci.com/docs/reference/configuration-reference
|
||||
version: 2.1
|
||||
|
||||
# Define a job to be invoked later in a workflow.
|
||||
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
|
||||
jobs:
|
||||
say-hello:
|
||||
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
|
||||
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
|
||||
# Define executor
|
||||
executors:
|
||||
node-executor:
|
||||
docker:
|
||||
# Specify the version you desire here
|
||||
# See: https://circleci.com/developer/images/image/cimg/base
|
||||
- image: cimg/base:current
|
||||
- image: node:20
|
||||
resource_class: small
|
||||
working_directory: ~/project
|
||||
|
||||
# Add steps to the job
|
||||
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
|
||||
# Reusable commands
|
||||
commands:
|
||||
setup-pnpm:
|
||||
steps:
|
||||
# Checkout the code as the first step.
|
||||
- checkout
|
||||
- run:
|
||||
name: "Say hello"
|
||||
command: "echo Hello, World!"
|
||||
- restore_cache:
|
||||
name: Restore pnpm Package Cache
|
||||
|
||||
# Orchestrate jobs using workflows
|
||||
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
|
||||
keys:
|
||||
- pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
|
||||
- run:
|
||||
name: Install pnpm package manager
|
||||
command: |
|
||||
npm install --global corepack@latest
|
||||
corepack enable
|
||||
corepack prepare pnpm@latest-10 --activate
|
||||
pnpm config set store-dir .pnpm-store
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: pnpm install
|
||||
- save_cache:
|
||||
name: Save pnpm Package Cache
|
||||
key: pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
|
||||
paths:
|
||||
- .pnpm-store
|
||||
|
||||
# Jobs
|
||||
jobs:
|
||||
# ============================================
|
||||
# TEST PHASE
|
||||
# ============================================
|
||||
|
||||
test-lint:
|
||||
executor: node-executor
|
||||
steps:
|
||||
- checkout
|
||||
- setup-pnpm
|
||||
- run:
|
||||
name: Run linting
|
||||
command: pnpm run lint
|
||||
- run:
|
||||
name: Check formatting
|
||||
command: pnpm run format --check || echo "Format check complete"
|
||||
|
||||
test-typecheck:
|
||||
executor: node-executor
|
||||
steps:
|
||||
- checkout
|
||||
- setup-pnpm
|
||||
- run:
|
||||
name: Type check all packages
|
||||
command: pnpm run typecheck
|
||||
|
||||
test-unit:
|
||||
executor: node-executor
|
||||
steps:
|
||||
- checkout
|
||||
- setup-pnpm
|
||||
- run:
|
||||
name: Run unit tests
|
||||
command: pnpm run test
|
||||
- store_test_results:
|
||||
path: apps/main/coverage
|
||||
- store_artifacts:
|
||||
path: apps/main/coverage
|
||||
destination: coverage
|
||||
|
||||
test-api:
|
||||
executor: node-executor
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
name: Restore npm API Cache
|
||||
keys:
|
||||
- npm-api-{{ checksum "api/package-lock.json" }}
|
||||
- run:
|
||||
name: Install API dependencies
|
||||
command: |
|
||||
cd api
|
||||
npm ci
|
||||
- save_cache:
|
||||
name: Save npm API Cache
|
||||
key: npm-api-{{ checksum "api/package-lock.json" }}
|
||||
paths:
|
||||
- api/node_modules
|
||||
- run:
|
||||
name: Lint API
|
||||
command: |
|
||||
cd api
|
||||
npm run lint
|
||||
- run:
|
||||
name: Run API tests
|
||||
command: |
|
||||
cd api
|
||||
npm run test
|
||||
|
||||
# ============================================
|
||||
# BUILD PHASE
|
||||
# ============================================
|
||||
|
||||
build-apps:
|
||||
executor: node-executor
|
||||
parameters:
|
||||
environment:
|
||||
type: string
|
||||
default: "staging"
|
||||
steps:
|
||||
- checkout
|
||||
- setup-pnpm
|
||||
- run:
|
||||
name: Build main app for << parameters.environment >>
|
||||
command: |
|
||||
cd apps/main
|
||||
npm run build:<< parameters.environment >>
|
||||
- run:
|
||||
name: Build external app
|
||||
command: |
|
||||
cd apps/external
|
||||
npm run build
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- apps/main/dist
|
||||
- apps/external/dist
|
||||
- packages/ui/dist
|
||||
- packages/shared/dist
|
||||
- store_artifacts:
|
||||
path: apps/main/dist
|
||||
destination: main-app-<< parameters.environment >>
|
||||
- store_artifacts:
|
||||
path: apps/external/dist
|
||||
destination: external-app
|
||||
|
||||
build-api:
|
||||
executor: node-executor
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
name: Restore npm API Cache
|
||||
keys:
|
||||
- npm-api-{{ checksum "api/package-lock.json" }}
|
||||
- run:
|
||||
name: Install API dependencies
|
||||
command: |
|
||||
cd api
|
||||
npm ci
|
||||
- run:
|
||||
name: Build API
|
||||
command: |
|
||||
cd api
|
||||
npm run build
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- api/dist
|
||||
- store_artifacts:
|
||||
path: api/dist
|
||||
destination: api
|
||||
|
||||
# ============================================
|
||||
# DOCKER BUILD PHASE
|
||||
# ============================================
|
||||
|
||||
build-docker-api:
|
||||
machine:
|
||||
image: ubuntu-2204:current
|
||||
resource_class: medium
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- run:
|
||||
name: Build API Docker image
|
||||
command: |
|
||||
cd api
|
||||
docker build -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest .
|
||||
- run:
|
||||
name: Save Docker image
|
||||
command: |
|
||||
mkdir -p /tmp/docker-images
|
||||
docker save xtablo-api:${CIRCLE_SHA1} -o /tmp/docker-images/api.tar
|
||||
- persist_to_workspace:
|
||||
root: /tmp
|
||||
paths:
|
||||
- docker-images/api.tar
|
||||
|
||||
# ============================================
|
||||
# DEPLOY PHASE
|
||||
# ============================================
|
||||
|
||||
deploy-staging:
|
||||
executor: node-executor
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- setup-pnpm
|
||||
- run:
|
||||
name: Deploy main app to staging
|
||||
command: |
|
||||
cd apps/main
|
||||
echo "Deploying main app to staging environment..."
|
||||
npx wrangler deploy --env staging
|
||||
- run:
|
||||
name: Deploy external app to staging
|
||||
command: |
|
||||
cd apps/external
|
||||
echo "Deploying external app to staging..."
|
||||
# Add external app staging deployment if needed
|
||||
# npx wrangler deploy --env staging
|
||||
- run:
|
||||
name: Deploy API to staging
|
||||
command: |
|
||||
echo "Deploying API to staging environment..."
|
||||
# Add your API deployment commands here
|
||||
# Example for Google Cloud Run:
|
||||
# gcloud run deploy xtablo-api-staging --image gcr.io/${GCP_PROJECT}/xtablo-api:${CIRCLE_SHA1} --region us-central1
|
||||
|
||||
deploy-production:
|
||||
executor: node-executor
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- setup-pnpm
|
||||
- run:
|
||||
name: Deploy main app to production
|
||||
command: |
|
||||
cd apps/main
|
||||
echo "Deploying main app to production environment..."
|
||||
npx wrangler deploy --env production
|
||||
- run:
|
||||
name: Deploy external app to production
|
||||
command: |
|
||||
cd apps/external
|
||||
echo "Deploying external app to production..."
|
||||
# Add external app production deployment if needed
|
||||
# npx wrangler deploy --env production
|
||||
- run:
|
||||
name: Deploy API to production
|
||||
command: |
|
||||
echo "Deploying API to production environment..."
|
||||
# Add your production API deployment commands here
|
||||
# Example for Google Cloud Run:
|
||||
# gcloud run deploy xtablo-api --image gcr.io/${GCP_PROJECT}/xtablo-api:${CIRCLE_SHA1} --region us-central1
|
||||
|
||||
# Workflows
|
||||
workflows:
|
||||
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
|
||||
# Inside the workflow, you define the jobs you want to run.
|
||||
version: 2
|
||||
|
||||
# Run on all branches (except main and develop)
|
||||
test-and-build:
|
||||
when:
|
||||
and:
|
||||
- not:
|
||||
equal: [ main, << pipeline.git.branch >> ]
|
||||
- not:
|
||||
equal: [ develop, << pipeline.git.branch >> ]
|
||||
jobs:
|
||||
- say-hello
|
||||
# Test phase - run in parallel
|
||||
- test-lint
|
||||
- test-typecheck
|
||||
- test-unit
|
||||
- test-api
|
||||
|
||||
# Build phase - run after tests pass
|
||||
- build-apps:
|
||||
requires:
|
||||
- test-lint
|
||||
- test-typecheck
|
||||
- test-unit
|
||||
|
||||
- build-api:
|
||||
requires:
|
||||
- test-api
|
||||
|
||||
- build-docker-api:
|
||||
requires:
|
||||
- build-api
|
||||
|
||||
# Staging deployment workflow (develop branch)
|
||||
deploy-to-staging:
|
||||
when:
|
||||
equal: [ develop, << pipeline.git.branch >> ]
|
||||
jobs:
|
||||
# Test phase
|
||||
- test-lint
|
||||
- test-typecheck
|
||||
- test-unit
|
||||
- test-api
|
||||
|
||||
# Build phase for staging
|
||||
- build-apps:
|
||||
environment: "staging"
|
||||
requires:
|
||||
- test-lint
|
||||
- test-typecheck
|
||||
- test-unit
|
||||
|
||||
- build-api:
|
||||
requires:
|
||||
- test-api
|
||||
|
||||
- build-docker-api:
|
||||
requires:
|
||||
- build-api
|
||||
|
||||
# Deploy to staging
|
||||
- deploy-staging:
|
||||
requires:
|
||||
- build-apps
|
||||
- build-docker-api
|
||||
|
||||
# Production deployment workflow (main branch)
|
||||
deploy-to-production:
|
||||
when:
|
||||
equal: [ main, << pipeline.git.branch >> ]
|
||||
jobs:
|
||||
# Test phase
|
||||
- test-lint
|
||||
- test-typecheck
|
||||
- test-unit
|
||||
- test-api
|
||||
|
||||
# Build phase for production
|
||||
- build-apps:
|
||||
environment: "prod"
|
||||
requires:
|
||||
- test-lint
|
||||
- test-typecheck
|
||||
- test-unit
|
||||
|
||||
- build-api:
|
||||
requires:
|
||||
- test-api
|
||||
|
||||
- build-docker-api:
|
||||
requires:
|
||||
- build-api
|
||||
|
||||
# Manual approval gate before production
|
||||
- hold-for-approval:
|
||||
type: approval
|
||||
requires:
|
||||
- build-apps
|
||||
- build-docker-api
|
||||
|
||||
# Deploy to production
|
||||
- deploy-production:
|
||||
requires:
|
||||
- hold-for-approval
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ STREAM_CHAT_API_KEY=v4yf8rs94aa8
|
|||
STREAM_CHAT_API_SECRET=jq2szvv73ua7sz9tvr9y24dxg37sw8ue8t576fu7ggr4h6wvcmunby4gvte8tm8f
|
||||
|
||||
XTABLO_URL=https://app.xtablo.com
|
||||
CORS_ORIGIN="https://app.xtablo.com"
|
||||
CORS_ORIGIN="https://app.xtablo.com,https://embed.xtablo.com"
|
||||
|
||||
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
|
||||
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
|
||||
|
|
|
|||
|
|
@ -734,10 +734,8 @@ describe("generateTimeSlots", () => {
|
|||
|
||||
expect(slot09_00?.available, "09:00 should be available").to.be.true;
|
||||
expect(slot09_30?.available, "09:30 should not be available").to.be.false;
|
||||
expect(slot10_00?.available, "10:00 should not be unavailable").to.be
|
||||
.false; // Within buffered time
|
||||
expect(slot10_30?.available, "10:30 should not be unavailable").to.be
|
||||
.false; // Within buffered time
|
||||
expect(slot10_00?.available, "10:00 should not be unavailable").to.be.false; // Within buffered time
|
||||
expect(slot10_30?.available, "10:30 should not be unavailable").to.be.false; // Within buffered time
|
||||
expect(slot11_00?.available, "11:00 should be available").to.be.true; // After buffered time
|
||||
});
|
||||
|
||||
|
|
@ -1145,12 +1143,8 @@ describe("generateTimeSlots", () => {
|
|||
});
|
||||
|
||||
it("should format date strings correctly", () => {
|
||||
expect(getDateStringCET(new Date("2024-01-15T10:30:00Z"))).to.equal(
|
||||
"2024-01-15"
|
||||
);
|
||||
expect(getDateStringCET(new Date("2024-12-31T23:59:59Z"))).to.equal(
|
||||
"2025-01-01"
|
||||
);
|
||||
expect(getDateStringCET(new Date("2024-01-15T10:30:00Z"))).to.equal("2024-01-15");
|
||||
expect(getDateStringCET(new Date("2024-12-31T23:59:59Z"))).to.equal("2025-01-01");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,38 +47,20 @@ function createConfig(): AppConfig {
|
|||
process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
),
|
||||
SUPABASE_CONNECTION_STRING: process.env.SUPABASE_CONNECTION_STRING || "",
|
||||
STREAM_CHAT_API_KEY: validateEnvVar(
|
||||
"STREAM_CHAT_API_KEY",
|
||||
process.env.STREAM_CHAT_API_KEY
|
||||
),
|
||||
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
|
||||
STREAM_CHAT_API_SECRET: validateEnvVar(
|
||||
"STREAM_CHAT_API_SECRET",
|
||||
process.env.STREAM_CHAT_API_SECRET
|
||||
),
|
||||
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
|
||||
EMAIL_CLIENT_ID: validateEnvVar(
|
||||
"EMAIL_CLIENT_ID",
|
||||
process.env.EMAIL_CLIENT_ID
|
||||
),
|
||||
EMAIL_CLIENT_SECRET: validateEnvVar(
|
||||
"EMAIL_CLIENT_SECRET",
|
||||
process.env.EMAIL_CLIENT_SECRET
|
||||
),
|
||||
EMAIL_REFRESH_TOKEN: validateEnvVar(
|
||||
"EMAIL_REFRESH_TOKEN",
|
||||
process.env.EMAIL_REFRESH_TOKEN
|
||||
),
|
||||
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
|
||||
EMAIL_CLIENT_SECRET: validateEnvVar("EMAIL_CLIENT_SECRET", process.env.EMAIL_CLIENT_SECRET),
|
||||
EMAIL_REFRESH_TOKEN: validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN),
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || "https://app.xtablo.com",
|
||||
XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com",
|
||||
R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID),
|
||||
R2_ACCESS_KEY_ID: validateEnvVar(
|
||||
"R2_ACCESS_KEY_ID",
|
||||
process.env.R2_ACCESS_KEY_ID
|
||||
),
|
||||
R2_SECRET_ACCESS_KEY: validateEnvVar(
|
||||
"R2_SECRET_ACCESS_KEY",
|
||||
process.env.R2_SECRET_ACCESS_KEY
|
||||
),
|
||||
R2_ACCESS_KEY_ID: validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID),
|
||||
R2_SECRET_ACCESS_KEY: validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY),
|
||||
SYNC_CALS_SECRET: process.env.SYNC_CALS_SECRET || "",
|
||||
LOG_LEVEL: "info",
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { EventAndTablo } from "./types.ts";
|
||||
|
||||
|
|
@ -54,14 +50,10 @@ export const generateICSFromEvents = (
|
|||
`DTSTART:${startDateTime}`,
|
||||
`DTEND:${endDateTime}`,
|
||||
`SUMMARY:${escapeICSText(event.title)}`,
|
||||
`DESCRIPTION:${escapeICSText(
|
||||
`Tablo: ${event.tablo_name}\n${event.description || ""}`
|
||||
)}`,
|
||||
`DESCRIPTION:${escapeICSText(`Tablo: ${event.tablo_name}\n${event.description || ""}`)}`,
|
||||
event.tablo_name ? `CATEGORIES:${escapeICSText(event.tablo_name)}` : "",
|
||||
`CREATED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
|
||||
`LAST-MODIFIED:${
|
||||
new Date().toISOString().replace(/[-:]/g, "").split(".")[0]
|
||||
}Z`,
|
||||
`LAST-MODIFIED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
|
||||
"STATUS:CONFIRMED",
|
||||
"TRANSP:OPAQUE",
|
||||
"END:VEVENT",
|
||||
|
|
@ -112,10 +104,7 @@ export const writeCalendarFileToR2 = async (
|
|||
);
|
||||
};
|
||||
|
||||
export const getTabloFileNames = async (
|
||||
s3_client: S3Client,
|
||||
tabloId: string
|
||||
) => {
|
||||
export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) => {
|
||||
const bucketName = "tablo-data";
|
||||
|
||||
const { Contents } = await s3_client.send(
|
||||
|
|
@ -130,11 +119,7 @@ export const getTabloFileNames = async (
|
|||
);
|
||||
};
|
||||
|
||||
export const isTabloMember = async (
|
||||
supabase: SupabaseClient,
|
||||
tabloId: string,
|
||||
userId: string
|
||||
) => {
|
||||
export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
|
||||
const { data: tabloAccess, error: isMemberError } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("*")
|
||||
|
|
@ -149,11 +134,7 @@ export const isTabloMember = async (
|
|||
return tabloAccess?.length > 0;
|
||||
};
|
||||
|
||||
export const isTabloAdmin = async (
|
||||
supabase: SupabaseClient,
|
||||
tabloId: string,
|
||||
userId: string
|
||||
) => {
|
||||
export const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
|
||||
const { data: tabloAccess, error: isAdminError } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("*")
|
||||
|
|
|
|||
|
|
@ -51,25 +51,22 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
|
|||
return c.json({ error: "Event type not found" }, 404);
|
||||
}
|
||||
|
||||
const eventType =
|
||||
eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
|
||||
const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
|
||||
const eventTypeConfig = eventType.config as EventTypeConfig;
|
||||
|
||||
// Get user's availabilities
|
||||
const { data: availabilitiesData, error: availabilitiesError } =
|
||||
await supabase
|
||||
.from("availabilities")
|
||||
.select("*")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
const { data: availabilitiesData, error: availabilitiesError } = await supabase
|
||||
.from("availabilities")
|
||||
.select("*")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
|
||||
if (availabilitiesError) {
|
||||
return c.json({ error: "Availabilities not found" }, 404);
|
||||
}
|
||||
|
||||
const availabilities = availabilitiesData as Tables<"availabilities">;
|
||||
const weeklyAvailability =
|
||||
availabilities.availability_data as WeeklyAvailability;
|
||||
const weeklyAvailability = availabilities.availability_data as WeeklyAvailability;
|
||||
const exceptions = (availabilities.exceptions as Exception[]) || [];
|
||||
|
||||
// Get existing events for the next month
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { Tables } from "./database.types.js";
|
||||
|
||||
import { DateTime } from "luxon";
|
||||
import type { Tables } from "./database.types.js";
|
||||
|
||||
// Types for availability calculation
|
||||
type TimeRange = {
|
||||
|
|
@ -57,9 +56,7 @@ function parseTime(timeStr: string): { hours: number; minutes: number } {
|
|||
}
|
||||
|
||||
function formatTime(hours: number, minutes: number): string {
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function addMinutes(timeStr: string, minutesToAdd: number): string {
|
||||
|
|
@ -82,9 +79,7 @@ function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
|
|||
if (ranges.length <= 1) return ranges;
|
||||
|
||||
// Sort ranges by start time
|
||||
const sortedRanges = [...ranges].sort((a, b) =>
|
||||
a.start.localeCompare(b.start)
|
||||
);
|
||||
const sortedRanges = [...ranges].sort((a, b) => a.start.localeCompare(b.start));
|
||||
const merged: TimeRange[] = [sortedRanges[0]];
|
||||
|
||||
for (let i = 1; i < sortedRanges.length; i++) {
|
||||
|
|
@ -94,8 +89,7 @@ function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
|
|||
// Check if current range overlaps with the last merged range
|
||||
if (current.start <= lastMerged.end) {
|
||||
// Merge by extending the end time if current range extends further
|
||||
lastMerged.end =
|
||||
current.end > lastMerged.end ? current.end : lastMerged.end;
|
||||
lastMerged.end = current.end > lastMerged.end ? current.end : lastMerged.end;
|
||||
} else {
|
||||
// No overlap, add current range to merged array
|
||||
merged.push(current);
|
||||
|
|
@ -144,9 +138,7 @@ function getMinAdvanceBookingDate(
|
|||
}
|
||||
|
||||
export function getDateStringCET(date: Date): string {
|
||||
return DateTime.fromJSDate(date)
|
||||
.setZone("Europe/Paris")
|
||||
.toFormat("yyyy-MM-dd");
|
||||
return DateTime.fromJSDate(date).setZone("Europe/Paris").toFormat("yyyy-MM-dd");
|
||||
}
|
||||
|
||||
export function generateTimeSlots(
|
||||
|
|
@ -183,10 +175,7 @@ export function generateTimeSlots(
|
|||
}
|
||||
|
||||
// Check minimum advance booking
|
||||
const minAdvanceBooking = getMinAdvanceBookingDate(
|
||||
eventTypeConfig,
|
||||
currentTime
|
||||
);
|
||||
const minAdvanceBooking = getMinAdvanceBookingDate(eventTypeConfig, currentTime);
|
||||
|
||||
// Generate slots for each time range
|
||||
for (const range of timeRanges) {
|
||||
|
|
@ -197,17 +186,13 @@ export function generateTimeSlots(
|
|||
const endMinutes = endTime.hours * 60 + endTime.minutes;
|
||||
|
||||
while (currentMinutes + eventTypeConfig.duration <= endMinutes) {
|
||||
const slotTime = formatTime(
|
||||
Math.floor(currentMinutes / 60),
|
||||
currentMinutes % 60
|
||||
);
|
||||
const slotTime = formatTime(Math.floor(currentMinutes / 60), currentMinutes % 60);
|
||||
|
||||
// Check if slot is in the future (considering minimum advance booking)
|
||||
// Compare dates first, then times if on the same date
|
||||
const isInFuture =
|
||||
dateStr > minAdvanceBooking.date ||
|
||||
(dateStr === minAdvanceBooking.date &&
|
||||
slotTime >= minAdvanceBooking.time);
|
||||
(dateStr === minAdvanceBooking.date && slotTime >= minAdvanceBooking.time);
|
||||
|
||||
slots.push({
|
||||
date: dateStr,
|
||||
|
|
@ -233,8 +218,7 @@ export function generateTimeSlots(
|
|||
if (event.start_date !== dateStr || event.deleted_at) return false;
|
||||
|
||||
const eventStart = event.start_time;
|
||||
const eventEnd =
|
||||
event.end_time || addMinutes(eventStart, eventTypeConfig.duration);
|
||||
const eventEnd = event.end_time || addMinutes(eventStart, eventTypeConfig.duration);
|
||||
|
||||
// Apply buffer time around the existing event
|
||||
const bufferedEventStart = addMinutes(eventStart, -bufferTime);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,7 @@ import type { StreamChat } from "stream-chat";
|
|||
import { config } from "./config.js";
|
||||
import type { Tables } from "./database.types.ts";
|
||||
import { writeCalendarFileToR2 } from "./helpers.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
r2Middleware,
|
||||
streamChatMiddleware,
|
||||
} from "./middleware.js";
|
||||
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
|
||||
import { generateToken } from "./token.js";
|
||||
import { transporter } from "./transporter.js";
|
||||
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
|
||||
|
|
@ -170,9 +166,7 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
|||
const { data: insertedTablo, error } = await supabase
|
||||
.from("tablos")
|
||||
.insert({
|
||||
name: `${invitedUserDataTyped.name || "Invité"} / ${
|
||||
ownerDataTyped.name || "Propriétaire"
|
||||
}`,
|
||||
name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`,
|
||||
color: "bg-blue-500",
|
||||
status: "todo",
|
||||
owner_id: ownerId,
|
||||
|
|
@ -190,22 +184,20 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
|||
}
|
||||
|
||||
// Grant access to the current user (invited user) as a non-admin member
|
||||
const { error: tabloAccessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.insert(
|
||||
{
|
||||
tablo_id: tabloData.id,
|
||||
user_id: user.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: ownerId,
|
||||
}
|
||||
// {
|
||||
// onConflict: "tablo_id, user_id",
|
||||
// }
|
||||
);
|
||||
const { error: tabloAccessError } = await supabase.from("tablo_access").insert(
|
||||
{
|
||||
tablo_id: tabloData.id,
|
||||
user_id: user.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: ownerId,
|
||||
}
|
||||
// {
|
||||
// onConflict: "tablo_id, user_id",
|
||||
// }
|
||||
);
|
||||
|
||||
if (tabloAccessError) {
|
||||
console.error("tabloAccessError", tabloAccessError);
|
||||
|
|
@ -298,8 +290,7 @@ tabloRouter.patch("/update", async (c) => {
|
|||
|
||||
const updatedTablo = update as Tables<"tablos">;
|
||||
|
||||
const isUpdatingName =
|
||||
tablo.name !== undefined && tablo.name !== updatedTablo.name;
|
||||
const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name;
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
|
|
@ -344,10 +335,7 @@ tabloRouter.delete("/delete", async (c) => {
|
|||
.single();
|
||||
|
||||
if (accessError || !tabloAccess || !tabloAccess.is_admin) {
|
||||
return c.json(
|
||||
{ error: "You are not authorized to delete this tablo" },
|
||||
403
|
||||
);
|
||||
return c.json({ error: "You are not authorized to delete this tablo" }, 403);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
@ -388,10 +376,7 @@ tabloRouter.post("/invite", async (c) => {
|
|||
}
|
||||
|
||||
if (tablo.owner_id !== sender.id) {
|
||||
return c.json(
|
||||
{ error: "You are not allowed to invite users to this tablo" },
|
||||
400
|
||||
);
|
||||
return c.json({ error: "You are not allowed to invite users to this tablo" }, 400);
|
||||
}
|
||||
|
||||
const { data: introConfigData, error: introError } = await supabase
|
||||
|
|
@ -425,8 +410,8 @@ tabloRouter.post("/invite", async (c) => {
|
|||
<p>Cliquez sur <a href="${
|
||||
config.XTABLO_URL
|
||||
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(
|
||||
token
|
||||
)}">ce lien</a> pour accepter l'invitation.</p>
|
||||
token
|
||||
)}">ce lien</a> pour accepter l'invitation.</p>
|
||||
<br>
|
||||
<p>Cordialement.</p>
|
||||
`,
|
||||
|
|
@ -462,17 +447,15 @@ tabloRouter.post("/join", async (c) => {
|
|||
|
||||
const { id: invite_id, tablo_id, invited_by } = inviteData;
|
||||
|
||||
const { error: tabloAccessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.insert({
|
||||
tablo_id,
|
||||
user_id: joiner.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: invited_by,
|
||||
});
|
||||
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
|
||||
tablo_id,
|
||||
user_id: joiner.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: invited_by,
|
||||
});
|
||||
|
||||
if (tabloAccessError) {
|
||||
console.error("tabloAccessError", tabloAccessError);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
|
|||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import { type Context, Hono, type Next } from "hono";
|
||||
import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
r2Middleware,
|
||||
streamChatMiddleware,
|
||||
} from "./middleware.js";
|
||||
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
|
||||
|
||||
export const tabloDataRouter = new Hono<{
|
||||
Variables: {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ taskRouter.post("/sync-calendars", async (c) => {
|
|||
token: string;
|
||||
tablo_id: string;
|
||||
tablos: { name: string };
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
calendarSubscriptionsData.forEach(async (subscription) => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import { Hono } from "hono";
|
||||
import type { Transporter } from "nodemailer";
|
||||
import { StreamChat } from "stream-chat";
|
||||
import type { Tables } from "./database.types.ts";
|
||||
import {
|
||||
authMiddleware,
|
||||
r2Middleware,
|
||||
streamChatMiddleware,
|
||||
} from "./middleware.js";
|
||||
import { transporter } from "./transporter.js";
|
||||
import {
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import { Hono } from "hono";
|
||||
import type { Transporter } from "nodemailer";
|
||||
import { StreamChat } from "stream-chat";
|
||||
import type { Tables } from "./database.types.ts";
|
||||
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
|
||||
import { transporter } from "./transporter.js";
|
||||
|
||||
export const userRouter = new Hono<{
|
||||
Variables: {
|
||||
|
|
@ -34,11 +30,7 @@ userRouter.post("/sign-up-to-stream", async (c) => {
|
|||
const { id } = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
|
||||
const { data } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
const { data } = await supabase.from("profiles").select("*").eq("id", id).single();
|
||||
|
||||
const user = data as Tables<"profiles">;
|
||||
|
||||
|
|
@ -59,11 +51,7 @@ userRouter.get("/me", async (c) => {
|
|||
const supabase = c.get("supabase");
|
||||
const streamServerClient = c.get("streamServerClient");
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
|
||||
|
||||
const userData = data as Tables<"profiles">;
|
||||
|
||||
|
|
@ -212,9 +200,7 @@ userRouter.post("/profile/avatar", async (c) => {
|
|||
|
||||
const randomString = Math.random().toString(36).substring(2, 15);
|
||||
const base64Content = Buffer.from(content, "base64");
|
||||
const key = `${user.id}/public_avatar_${randomString}.${
|
||||
contentType.split("/")[1]
|
||||
}`;
|
||||
const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`;
|
||||
|
||||
try {
|
||||
await s3Client.send(
|
||||
|
|
@ -263,8 +249,7 @@ userRouter.delete("/profile/avatar", async (c) => {
|
|||
})
|
||||
);
|
||||
|
||||
if (listedObjects.Contents.length === 0)
|
||||
return c.json({ error: "No objects found" }, 404);
|
||||
if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404);
|
||||
|
||||
await s3Client.send(
|
||||
new DeleteObjectsCommand({
|
||||
|
|
|
|||
8
apps/external/package.json
vendored
8
apps/external/package.json
vendored
|
|
@ -6,13 +6,14 @@
|
|||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "tsc -b && vite build",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"deploy": "echo 'Configure deployment command for external app (e.g., wrangler pages deploy dist, vercel deploy, etc.)'",
|
||||
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite"
|
||||
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.5",
|
||||
|
|
@ -24,7 +25,8 @@
|
|||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.2.2",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"wrangler": "^4.24.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xtablo/ui": "workspace:*",
|
||||
|
|
|
|||
15
apps/external/src/EmbeddedBookingPage.tsx
vendored
15
apps/external/src/EmbeddedBookingPage.tsx
vendored
|
|
@ -25,7 +25,6 @@ import { useState } from "react";
|
|||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { CustomModal } from "./CustomModal";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { api } from "./lib/api";
|
||||
import { supabase } from "./lib/supabase";
|
||||
import { useMaybeUser } from "./UserStoreProvider";
|
||||
|
|
@ -214,8 +213,7 @@ export function EmbeddedBookingPage() {
|
|||
|
||||
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
|
||||
|
||||
console.log({ shortUserId, eventTypeStandardName });
|
||||
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
|
||||
const { data: publicSlots } = usePublicSlots(
|
||||
api,
|
||||
shortUserId || "",
|
||||
eventTypeStandardName || ""
|
||||
|
|
@ -454,17 +452,6 @@ export function EmbeddedBookingPage() {
|
|||
}
|
||||
};
|
||||
|
||||
if (isLoadingSlots) {
|
||||
return (
|
||||
<div className="w-[1130px] h-[700px] flex items-center justify-center bg-gray-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner />
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Chargement des disponibilités...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[1130px] h-[700px] bg-transparent overflow-hidden">
|
||||
<div className="h-full bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 flex overflow-hidden">
|
||||
|
|
|
|||
21
apps/external/src/FloatingBookingWidget.tsx
vendored
21
apps/external/src/FloatingBookingWidget.tsx
vendored
|
|
@ -21,7 +21,6 @@ import { useState } from "react";
|
|||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { CustomModal } from "./CustomModal";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { api } from "./lib/api";
|
||||
import { supabase } from "./lib/supabase";
|
||||
// import { useCreateTabloWithOwner } from "@xtablo/shared";
|
||||
|
|
@ -126,7 +125,7 @@ export function FloatingBookingWidget() {
|
|||
// Get color schemes based on variants
|
||||
const btnColors = buttonColors[buttonVariant];
|
||||
|
||||
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
|
||||
const { data: publicSlots } = usePublicSlots(
|
||||
api,
|
||||
shortUserId || "",
|
||||
eventTypeStandardName || ""
|
||||
|
|
@ -371,24 +370,6 @@ export function FloatingBookingWidget() {
|
|||
}
|
||||
};
|
||||
|
||||
if (isLoadingSlots) {
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className={twMerge(
|
||||
"rounded-full h-14 w-14 shadow-lg transition-all duration-200",
|
||||
btnColors.floating
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
|
|
|
|||
12
apps/external/src/LoadingSpinner.tsx
vendored
12
apps/external/src/LoadingSpinner.tsx
vendored
|
|
@ -1,12 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
3
apps/external/src/UserStoreProvider.tsx
vendored
3
apps/external/src/UserStoreProvider.tsx
vendored
|
|
@ -3,7 +3,6 @@ import { useSession } from "@xtablo/shared/contexts/SessionContext";
|
|||
import { Tables } from "@xtablo/shared/types/database.types";
|
||||
import React from "react";
|
||||
import { createStore, StoreApi, useStore } from "zustand";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { api } from "./lib/api";
|
||||
|
||||
export type User = Tables<"profiles"> & {
|
||||
|
|
@ -34,7 +33,7 @@ export const UserStoreProvider = ({ children }: { children: React.ReactNode }) =
|
|||
});
|
||||
|
||||
if (isPending && shouldFetchUser) {
|
||||
return <LoadingSpinner />;
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
|
|
|||
2
apps/external/tsconfig.tsbuildinfo
vendored
2
apps/external/tsconfig.tsbuildinfo
vendored
|
|
@ -1 +1 @@
|
|||
{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/loadingspinner.tsx","./src/userstoreprovider.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"}
|
||||
12
apps/external/turbo.json
vendored
Normal file
12
apps/external/turbo.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"deploy": {
|
||||
"dependsOn": ["build"],
|
||||
"cache": false,
|
||||
"outputLogs": "new-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
apps/external/worker/index.d.ts
vendored
Normal file
4
apps/external/worker/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare const _default: {
|
||||
fetch(request: any): Response;
|
||||
};
|
||||
export default _default;
|
||||
16
apps/external/worker/index.ts
vendored
Normal file
16
apps/external/worker/index.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// @ts-nocheck
|
||||
// biome-ignore-file: Worker entry point with dynamic imports
|
||||
/* eslint-disable */
|
||||
|
||||
export default {
|
||||
fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return Response.json({
|
||||
name: "Cloudflare",
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
},
|
||||
};
|
||||
17
apps/external/wrangler.toml
vendored
Normal file
17
apps/external/wrangler.toml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
name = "xtablo-embeds"
|
||||
main = "worker/index.ts"
|
||||
compatibility_date = "2025-07-09"
|
||||
|
||||
[assets]
|
||||
directory = "./dist/"
|
||||
not_found_handling = "single-page-application"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[vars]
|
||||
PYTHON_VERSION = "3.11.5"
|
||||
|
||||
[[routes]]
|
||||
pattern = "embed.xtablo.com"
|
||||
custom_domain = true
|
||||
|
|
@ -13,7 +13,8 @@
|
|||
"preview": "vite preview",
|
||||
"build:staging": "tsc -b && vite build --mode staging",
|
||||
"build:prod": "tsc -b && vite build --mode production",
|
||||
"deploy": "wrangler deploy --env=\"\"",
|
||||
"deploy:staging": "wrangler deploy --env=\"\"",
|
||||
"deploy:prod": "wrangler deploy --env=\"\"",
|
||||
"cf-typegen": "wrangler types",
|
||||
"test": "vitest run --mode dev --passWithNoTests",
|
||||
"test:watch": "vitest watch --passWithNoTests",
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const ProtectedRoute = ({ fallback, shouldRedirectToCurrentPage }: Protec
|
|||
|
||||
return match(status)
|
||||
.with("loading", () => <LoadingSpinner />)
|
||||
.with("should-land-user", () => <Navigate to={redirectUrl} replace />)
|
||||
.with("should-land-user", () => <Navigate to="/landing" replace />)
|
||||
.with("should-redirect", () => <Navigate to={redirectUrl} replace />)
|
||||
.with("should-pass", () => <Outlet />)
|
||||
.exhaustive();
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,6 +29,16 @@
|
|||
"outputs": ["dist/**", "tsconfig.tsbuildinfo"],
|
||||
"outputLogs": "new-only",
|
||||
"env": ["NODE_ENV", "VITE_*"]
|
||||
},
|
||||
"deploy:staging": {
|
||||
"dependsOn": ["build:staging"],
|
||||
"cache": false,
|
||||
"outputLogs": "new-only"
|
||||
},
|
||||
"deploy:prod": {
|
||||
"dependsOn": ["build:prod"],
|
||||
"cache": false,
|
||||
"outputLogs": "new-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
apps/main/vite.config.d.ts
vendored
Normal file
2
apps/main/vite.config.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const _default: import("vite").UserConfigFnObject;
|
||||
export default _default;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
/// <reference types="vitest" />
|
||||
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
// import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { visualizer } from "rollup-plugin-visualizer";
|
||||
|
|
@ -9,14 +8,34 @@ import { defineConfig, type PluginOption } from "vite";
|
|||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), visualizer() as PluginOption, tailwindcss(), tsconfigPaths(), cloudflare()],
|
||||
server: {
|
||||
cors: false,
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/setupTests.ts",
|
||||
},
|
||||
export default defineConfig(({ mode }) => {
|
||||
const plugins: PluginOption[] = [
|
||||
react(),
|
||||
visualizer() as PluginOption,
|
||||
tailwindcss(),
|
||||
tsconfigPaths(),
|
||||
];
|
||||
|
||||
// Only include cloudflare plugin when not in test mode
|
||||
if (mode !== "test" && process.env.VITEST !== "true") {
|
||||
plugins.push(cloudflare());
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
server: {
|
||||
cors: false,
|
||||
},
|
||||
define: process.env.VITEST
|
||||
? {
|
||||
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify("https://test.supabase.co"),
|
||||
"import.meta.env.VITE_SUPABASE_ANON_KEY": JSON.stringify("test-anon-key"),
|
||||
}
|
||||
: undefined,
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/setupTests.ts",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"build:apps": "turbo build --filter='./apps/*'",
|
||||
|
|
@ -12,9 +15,9 @@
|
|||
"dev": "turbo dev",
|
||||
"dev:main": "turbo dev --filter=@xtablo/main",
|
||||
"dev:external": "turbo dev --filter=@xtablo/external",
|
||||
"deploy:main": "turbo deploy --filter=@xtablo/main",
|
||||
"deploy:main:staging": "turbo deploy:staging --filter=@xtablo/main",
|
||||
"deploy:main:prod": "turbo deploy:prod --filter=@xtablo/main",
|
||||
"deploy:external": "turbo deploy --filter=@xtablo/external",
|
||||
"deploy:all": "pnpm deploy:main && pnpm deploy:external",
|
||||
"lint": "turbo lint",
|
||||
"lint:fix": "turbo lint:fix",
|
||||
"format": "turbo format",
|
||||
|
|
|
|||
|
|
@ -193,11 +193,11 @@ export const parseICSFile = (icsContent: string): ParsedICSEvent[] => {
|
|||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i].trim();
|
||||
let line = lines[i]?.trim() || "";
|
||||
|
||||
// Handle line folding (lines starting with space or tab)
|
||||
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1])) {
|
||||
line += lines[i + 1].substring(1);
|
||||
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1] || "")) {
|
||||
line += lines[i + 1]?.substring(1) || "";
|
||||
i++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ function ButtonGroup({
|
|||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: This is a UI grouping element, not a form fieldset
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@xtablo/shared";
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { type DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import { Button, buttonVariants } from "./button";
|
||||
|
||||
// Component definitions outside to avoid recreation on every render
|
||||
const CalendarRoot = ({
|
||||
className,
|
||||
rootRef,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { rootRef?: React.Ref<HTMLDivElement> }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
};
|
||||
|
||||
const CalendarChevron = ({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & { orientation?: "up" | "left" | "right" | "down" }) => {
|
||||
switch (orientation) {
|
||||
case "up":
|
||||
return <ChevronUpIcon className={cn("size-4", className)} {...props} />;
|
||||
case "left":
|
||||
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
|
||||
case "right":
|
||||
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
|
||||
case "down":
|
||||
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||
default:
|
||||
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
const CalendarWeekNumber = ({
|
||||
children,
|
||||
...props
|
||||
}: React.TdHTMLAttributes<HTMLTableCellElement>) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
|
|
@ -104,30 +145,10 @@ function Calendar({
|
|||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||
},
|
||||
Root: CalendarRoot,
|
||||
Chevron: CalendarChevron,
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
WeekNumber: CalendarWeekNumber,
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export function DateFieldLabel({
|
|||
"aria-required"?: boolean;
|
||||
}) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noLabelWithoutControl: This label is for display only within a custom date picker component
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{children}
|
||||
{ariaRequired && <span className="text-destructive ml-1">*</span>}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { CalendarDate } from "@internationalized/date";
|
||||
import { cn } from "@xtablo/shared";
|
||||
import { Calendar as CalendarIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { Button } from "./button";
|
||||
import { Calendar } from "./calendar";
|
||||
import { Label } from "./label";
|
||||
|
|
@ -91,15 +91,16 @@ export const DatePickerV1 = ({
|
|||
onChange: (date: Date | undefined) => void;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<Label htmlFor="date" className="px-1">
|
||||
<Label htmlFor={id} className="px-1">
|
||||
{label}
|
||||
</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" id="date" className="w-full justify-between font-normal">
|
||||
<Button variant="outline" id={id} className="w-full justify-between font-normal">
|
||||
{value ? value.toLocaleDateString() : "Choisir une date"}
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ function Field({
|
|||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: This is a UI grouping element, not a form fieldset
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
|
|
@ -194,7 +195,10 @@ function FieldError({
|
|||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{errors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
|
||||
{errors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={`error-${error.message}-${index}`}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ export function useCopyToClipboard({ timeout = 2000 } = {}) {
|
|||
const [copyTimeout, setCopyTimeout] = React.useState<number | null>(null);
|
||||
|
||||
const handleCopyResult = (value: boolean) => {
|
||||
window.clearTimeout(copyTimeout!);
|
||||
if (copyTimeout !== null) {
|
||||
window.clearTimeout(copyTimeout);
|
||||
}
|
||||
setCopyTimeout(window.setTimeout(() => setCopied(false), timeout));
|
||||
setCopied(value);
|
||||
};
|
||||
|
|
@ -25,7 +27,9 @@ export function useCopyToClipboard({ timeout = 2000 } = {}) {
|
|||
const reset = () => {
|
||||
setCopied(false);
|
||||
setError(null);
|
||||
window.clearTimeout(copyTimeout!);
|
||||
if (copyTimeout !== null) {
|
||||
window.clearTimeout(copyTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
return { copy, reset, error, copied };
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ importers:
|
|||
vite-tsconfig-paths:
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
wrangler:
|
||||
specifier: ^4.24.3
|
||||
version: 4.44.0
|
||||
|
||||
apps/main:
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@
|
|||
"inputs": ["src/**", "biome.json", "package.json"],
|
||||
"outputLogs": "new-only"
|
||||
},
|
||||
"lint:fix": {
|
||||
"inputs": ["src/**", "biome.json", "package.json"],
|
||||
"outputLogs": "new-only"
|
||||
},
|
||||
"format": {
|
||||
"inputs": ["src/**", "biome.json", "package.json"],
|
||||
"outputLogs": "errors-only"
|
||||
|
|
@ -39,11 +43,6 @@
|
|||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"deploy": {
|
||||
"dependsOn": ["build"],
|
||||
"cache": false,
|
||||
"outputLogs": "new-only"
|
||||
},
|
||||
"clean": {
|
||||
"cache": false
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue