Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c5d221c76 | ||
|
|
852daa0175 | ||
|
|
1123b56a09 | ||
|
|
12e07f20d4 | ||
|
|
18415e35a7 | ||
|
|
856792e1e7 | ||
|
|
52d5261b51 | ||
|
|
53776d28e6 | ||
|
|
2b666f3e91 | ||
|
|
7562ad2a16 | ||
|
|
5626bceeff | ||
|
|
e312d1c044 | ||
|
|
9050f9b61f | ||
|
|
2218a45d83 | ||
|
|
4e1d05a7e7 | ||
|
|
0d4108dd69 | ||
|
|
6e2e826001 | ||
|
|
84a4dd4147 | ||
|
|
deca7ce40a | ||
|
|
5394d80969 | ||
|
|
fd8f2a9ff6 | ||
|
|
28f9c8f4cf | ||
|
|
37884d26e3 | ||
|
|
e46df92492 | ||
|
|
35cf827cb1 | ||
|
|
e9c3d7e40c | ||
|
|
c218a0afad | ||
|
|
b9a09117e3 | ||
|
|
550caa6665 | ||
|
|
a8f43da472 | ||
|
|
43b6c63294 | ||
|
|
9ef650ceed | ||
|
|
e394a03365 | ||
|
|
972f983824 | ||
|
|
68a8fade89 | ||
|
|
d81993892a | ||
|
|
d034e7aff7 | ||
|
|
13103c0881 | ||
|
|
983af06787 | ||
|
|
29076d681b | ||
|
|
3cadf105d5 | ||
|
|
411483a447 | ||
|
|
223c967f81 | ||
|
|
440b69138e | ||
|
|
5a3f15357c | ||
|
|
e46c1bb40c | ||
|
|
758b820a92 | ||
|
|
b46fb82695 | ||
|
|
71c9567169 | ||
|
|
5918e66cc5 | ||
|
|
2ad24048b6 | ||
|
|
a69fe2ed00 | ||
|
|
a5000cb3b7 | ||
|
|
eb0cf10f67 | ||
|
|
728e1173cb | ||
|
|
b4e773da69 | ||
|
|
bf38eb8cb1 | ||
|
|
010ee0d066 | ||
|
|
f0a7d69c7e | ||
|
|
cb2509683d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: Booker accepts rrHostSubsetIds to specify round robin hosts
|
||||
@@ -127,6 +127,14 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
|
||||
NEXT_PUBLIC_FRESHCHAT_TOKEN=
|
||||
NEXT_PUBLIC_FRESHCHAT_HOST=
|
||||
|
||||
# For holiday feature:
|
||||
# Step-by-step: Get a Google Calendar API Key
|
||||
# 1. Go to Google Cloud Console: https://console.cloud.google.com/
|
||||
# 2. Select or Create a Project
|
||||
# 3. Enable Google Calendar API (APIs & Services → Library , Search for Google Calendar API)
|
||||
# 4. Create the API Key (APIs & Services → Credentials)
|
||||
GOOGLE_CALENDAR_API_KEY=
|
||||
|
||||
# Google OAuth credentials
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` below
|
||||
|
||||
@@ -14,24 +14,29 @@ jobs:
|
||||
name: Build Companion App & Extension
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: companion/package-lock.json
|
||||
bun-version: 1.3.0
|
||||
cache: true
|
||||
cache-dependency-path: companion/bun.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: npm ci
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build Expo web app
|
||||
working-directory: companion
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: 1
|
||||
CI: true
|
||||
run: npx expo export --platform web
|
||||
run: bunx expo export --platform web
|
||||
|
||||
- name: Build browser extension
|
||||
working-directory: companion
|
||||
run: npm run ext:build
|
||||
run: bun run ext:build
|
||||
|
||||
@@ -15,18 +15,6 @@ jobs:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
team-labels:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: equitybee/team-label-action@main
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, api, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, sre, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -45,11 +45,20 @@ yarn prettier --write path/to/file.tsx
|
||||
# Unit test specific file
|
||||
yarn vitest run path/to/file.test.ts
|
||||
|
||||
# Unit test specific file + specific test
|
||||
yarn vitest run path/to/file.test.ts --testNamePattern="specific test name"
|
||||
|
||||
# Integration test specific file
|
||||
yarn test path/to/file.integration-test.ts -- --integrationTestsOnly
|
||||
|
||||
# Integration test specific file + specific test
|
||||
yarn test path/to/file.integration-test.ts --testNamePattern="specific test name" -- --integrationTestsOnly
|
||||
|
||||
# E2E test specific file
|
||||
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts
|
||||
|
||||
# E2E test specific file + specific test
|
||||
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts --grep "specific test name"
|
||||
```
|
||||
|
||||
### Project-wide (use sparingly)
|
||||
|
||||
@@ -77,6 +77,9 @@ RUN scripts/replace-placeholder.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${N
|
||||
FROM node:20 AS runner
|
||||
|
||||
WORKDIR /calcom
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd wget && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder-two /calcom ./
|
||||
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
|
||||
|
||||
@@ -28,11 +28,17 @@
|
||||
|
||||
- `yarn test` - Run unit tests (vitest)
|
||||
- `yarn test <filename>` - Run tests for specific file
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests for specific file
|
||||
- `yarn test <filename> -t "<testName>" -- --integrationTestsOnly` - Run specific test by name
|
||||
- `yarn test <filename> -t "<testName>"` - Run specific test by name for specific file
|
||||
- `yarn tdd` - Run tests in watch mode
|
||||
- `yarn test:ui` - Run tests with UI interface
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- `yarn test -- --integrationTestsOnly` - Run integration tests (vitest)
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests for specific file
|
||||
- `yarn test <filename> -t "<testName>" -- --integrationTestsOnly` - Run specific integration test by name for specific file
|
||||
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
- `yarn e2e` - Run end-to-end tests (Playwright)
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "15.4.9",
|
||||
"next": "15.4.10",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
|
||||
import {
|
||||
validateCustomEventName,
|
||||
@@ -129,6 +130,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
slug,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
@@ -144,6 +146,8 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||
: {};
|
||||
|
||||
const slugifiedSlug = slugify(slug);
|
||||
|
||||
const metadata: EventTypeMetadata = {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
@@ -153,6 +157,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
slug: slugifiedSlug,
|
||||
length: lengthInMinutes,
|
||||
locations: locationsTransformed,
|
||||
bookingFields: this.transformInputBookingFields(effectiveBookingFields),
|
||||
@@ -209,6 +214,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
slug,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
@@ -243,6 +249,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
...(slug ? { slug: slugify(slug) } : {}),
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: effectiveBookingFields
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
|
||||
constructor(taskerService: TaskerService) {
|
||||
constructor(taskerService: TaskerService, bridgeLogger: Logger) {
|
||||
super({
|
||||
tasker: taskerService.getTasker(),
|
||||
log: bridgeLogger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
@@ -42,11 +43,13 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const slugifiedInputEventType = { ...inputEventType, slug: slugify(inputEventType.slug) };
|
||||
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
await this.validateTeamEventTypeSlug(teamId, slugifiedInputEventType.slug);
|
||||
|
||||
const transformedBody = await this.transformInputCreateTeamEventType(teamId, inputEventType);
|
||||
const transformedBody = await this.transformInputCreateTeamEventType(teamId, slugifiedInputEventType);
|
||||
|
||||
await this.inputEventTypesService.validateEventTypeInputs({
|
||||
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
|
||||
@@ -75,13 +78,21 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const slugifiedInputEventType = inputEventType.slug
|
||||
? { ...inputEventType, slug: slugify(inputEventType.slug) }
|
||||
: inputEventType;
|
||||
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
if (inputEventType.slug) {
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
if (slugifiedInputEventType.slug) {
|
||||
await this.validateTeamEventTypeSlug(teamId, slugifiedInputEventType.slug);
|
||||
}
|
||||
|
||||
const transformedBody = await this.transformInputUpdateTeamEventType(eventTypeId, teamId, inputEventType);
|
||||
const transformedBody = await this.transformInputUpdateTeamEventType(
|
||||
eventTypeId,
|
||||
teamId,
|
||||
slugifiedInputEventType
|
||||
);
|
||||
|
||||
await this.inputEventTypesService.validateEventTypeInputs({
|
||||
eventTypeId: eventTypeId,
|
||||
|
||||
@@ -64,7 +64,7 @@ export class SlotsController_2024_09_04 {
|
||||
description: `
|
||||
There are 4 ways to get available slots for event type of an individual user:
|
||||
|
||||
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
1. By event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
@@ -74,7 +74,10 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
And 3 ways to get available slots for team event type:
|
||||
|
||||
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'.
|
||||
**Note for managed event types**: Managed event types are templates that create individual child event types for each team member. You cannot fetch slots for the parent managed event type directly. Instead, you must:
|
||||
- Find the child event type IDs (the ones assigned to specific users)
|
||||
- Use those child event type IDs to fetch slots as individual user event types using as described in the individual user section above.
|
||||
|
||||
2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
|
||||
@@ -53,11 +53,9 @@ const Page = async ({ params }: PageProps) => {
|
||||
canReadOthersBookings = teamIdsWithPermission.length > 0;
|
||||
}
|
||||
|
||||
const userProfile = session?.user?.profile;
|
||||
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const bookingsV3Enabled = orgId
|
||||
? await featuresRepository.checkIfTeamHasFeature(orgId, "bookings-v3")
|
||||
const bookingsV3Enabled = session?.user?.id
|
||||
? await featuresRepository.checkIfUserHasFeature(session.user.id, "bookings-v3")
|
||||
: false;
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,9 @@ import { z } from "zod";
|
||||
|
||||
import { validPassword } from "@calcom/features/auth/lib/validPassword";
|
||||
import { hashPassword } from "@calcom/lib/auth/hashPassword";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -36,8 +39,14 @@ async function handler(req: NextRequest) {
|
||||
// token verified, delete the cookie / a resubmit on failure requires a new csrf token.
|
||||
cookieStore.delete("calcom.csrf_token");
|
||||
|
||||
// rate-limited there is a low, very low chance that a password request stays valid long enough
|
||||
// to brute force 3.8126967e+40 options.
|
||||
const remoteIp = getIP(req);
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:reset-password:${piiHasher.hash(remoteIp)}`,
|
||||
});
|
||||
|
||||
// Note: There is a low, very low chance that a password request stays valid long enough
|
||||
// to brute force 3.8126967e+40 options, but rate limiting provides additional protection.
|
||||
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
|
||||
where: {
|
||||
id: rawRequestId,
|
||||
|
||||
@@ -5,10 +5,12 @@ import { NextResponse, type NextRequest } from "next/server";
|
||||
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
|
||||
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -38,6 +40,12 @@ async function handler(req: NextRequest) {
|
||||
const remoteIp = getIP(req);
|
||||
// Use a try catch instead of returning res every time
|
||||
try {
|
||||
// Rate limit: 10 signups per 60 seconds per IP
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:signup:${piiHasher.hash(remoteIp)}`,
|
||||
});
|
||||
|
||||
const body = await parseRequestData(req);
|
||||
await checkCfTurnstileToken({
|
||||
token: req.headers.get("cf-access-token") as string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { NextRequest } from "next/server";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -27,6 +28,11 @@ async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:totp-disable:${session.user.id}`,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -25,6 +26,11 @@ async function postHandler(req: NextRequest) {
|
||||
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:totp-enable:${session.user.id}`,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
if (!user) {
|
||||
console.error(`Session references user that no longer exists.`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import qrcode from "qrcode";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
@@ -29,6 +30,11 @@ async function postHandler(req: NextRequest) {
|
||||
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:totp-setup:${session.user.id}`,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
|
||||
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
|
||||
|
||||
@@ -26,6 +29,15 @@ async function handler(req: NextRequest) {
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
// Rate limit: 10 booking cancellations per 60 seconds per user (or IP if not authenticated)
|
||||
const identifier = session?.user?.id
|
||||
? `api:cancel-user:${session.user.id}`
|
||||
: `api:cancel-ip:${piiHasher.hash(getIP(req))}`;
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier,
|
||||
});
|
||||
|
||||
const result = await handleCancelBooking({
|
||||
bookingData,
|
||||
userId: session?.user?.id || -1,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
||||
defaultResponderForAppDir:
|
||||
(handler: (req: NextRequest) => Promise<Response>) =>
|
||||
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
|
||||
handler(req),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn().mockResolvedValue(new Headers()),
|
||||
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
redirect: vi.fn((url: string | URL, init?: { status?: number }) => {
|
||||
const location = typeof url === "string" ? url : url.toString();
|
||||
return {
|
||||
status: init?.status ?? 302,
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === "location" ? location : null),
|
||||
},
|
||||
} as unknown as Response;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn().mockReturnValue(
|
||||
JSON.stringify({
|
||||
bookingUid: "test-booking-uid",
|
||||
userId: 1,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
booking: {
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
uid: "test-booking-uid",
|
||||
recurringEventId: null,
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
locale: "en",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/createContext", () => ({
|
||||
createContext: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
|
||||
bookingsRouter: {},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/trpc", () => ({
|
||||
createCallerFactory: vi.fn().mockReturnValue(() => ({
|
||||
confirm: vi.fn().mockResolvedValue({}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { GET } from "../route";
|
||||
|
||||
const createMockRequest = (url: string): NextRequest => {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
method: "GET",
|
||||
url,
|
||||
nextUrl: {
|
||||
searchParams: urlObj.searchParams,
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
describe("link route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET handler - redirect URL construction", () => {
|
||||
it("should redirect to booking page with the same origin as the request", async () => {
|
||||
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
});
|
||||
|
||||
it("should preserve custom domain origin in redirect URL", async () => {
|
||||
const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://custom-domain.company.com");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
it("should preserve self-hosted domain origin in redirect URL", async () => {
|
||||
const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://calcom.internal.company.net");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
it("should construct redirect URLs relative to the request URL for various origins", async () => {
|
||||
const testOrigins = [
|
||||
"https://app.cal.com",
|
||||
"https://acme.cal.com",
|
||||
"https://calcom.company.internal",
|
||||
"http://192.168.1.100:3000",
|
||||
];
|
||||
|
||||
for (const origin of testOrigins) {
|
||||
vi.clearAllMocks();
|
||||
const baseUrl = `${origin}/api/link?action=accept&token=encrypted-token`;
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe(origin);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET handler - error handling", () => {
|
||||
it("should redirect with error message when TRPC throws an error", async () => {
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
|
||||
// Mock createCallerFactory to throw a TRPCError
|
||||
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
|
||||
vi.mocked(createCallerFactory).mockReturnValue(() => ({
|
||||
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })),
|
||||
}));
|
||||
|
||||
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Custom error");
|
||||
});
|
||||
|
||||
it("should preserve origin in error redirect URL", async () => {
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
|
||||
// Mock createCallerFactory to throw a TRPCError
|
||||
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
|
||||
vi.mocked(createCallerFactory).mockReturnValue(() => ({
|
||||
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })),
|
||||
}));
|
||||
|
||||
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://self-hosted.company.org");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,6 @@ const createSessionGetter = (userId: number) => async () => {
|
||||
};
|
||||
|
||||
async function handler(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
const { action, token, reason } = querySchema.parse(Object.fromEntries(searchParams.entries()));
|
||||
@@ -120,10 +119,10 @@ async function handler(request: NextRequest) {
|
||||
} catch (e) {
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent(message)}`);
|
||||
return NextResponse.redirect(new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url));
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}`);
|
||||
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
||||
defaultResponderForAppDir:
|
||||
(handler: (req: NextRequest) => Promise<Response>) =>
|
||||
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
|
||||
handler(req),
|
||||
}));
|
||||
|
||||
vi.mock("app/api/parseRequestData", () => ({
|
||||
parseRequestData: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn().mockResolvedValue(new Headers()),
|
||||
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
redirect: vi.fn((url: string | URL, init?: { status?: number }) => {
|
||||
const location = typeof url === "string" ? url : url.toString();
|
||||
return {
|
||||
status: init?.status ?? 302,
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === "location" ? location : null),
|
||||
},
|
||||
} as unknown as Response;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
booking: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/createContext", () => ({
|
||||
createContext: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
|
||||
bookingsRouter: {},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/trpc", () => ({
|
||||
createCallerFactory: vi.fn().mockReturnValue(() => ({
|
||||
confirm: vi.fn().mockResolvedValue({}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { GET, POST } from "../route";
|
||||
|
||||
const createMockRequest = (url: string, method: "GET" | "POST" = "GET"): NextRequest => {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
method,
|
||||
url,
|
||||
nextUrl: {
|
||||
searchParams: urlObj.searchParams,
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
describe("verify-booking-token route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET handler", () => {
|
||||
it("should redirect to booking page with error when action is reject (requires POST)", async () => {
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?action=reject&token=test-token&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Rejection requires POST method");
|
||||
});
|
||||
|
||||
it("should redirect with error when query params are invalid (missing action)", async () => {
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
|
||||
it("should redirect with error when query params are invalid (missing token)", async () => {
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?action=accept&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
|
||||
it("should preserve the request origin in redirect URL (not hardcode localhost)", async () => {
|
||||
const baseUrl =
|
||||
"https://custom-domain.example.org/api/verify-booking-token?action=reject&token=t&bookingUid=booking-uid&userId=1";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://custom-domain.example.org");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
it("should handle empty bookingUid in error case", async () => {
|
||||
const baseUrl = "https://app.example.com/api/verify-booking-token?token=test-token&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.pathname).toBe("/booking/");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST handler", () => {
|
||||
it("should redirect with status 303 when query params are invalid", async () => {
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "POST");
|
||||
|
||||
const res = await POST(req, { params: Promise.resolve({}) });
|
||||
|
||||
expect(res.status).toBe(303);
|
||||
|
||||
const location = res.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
|
||||
it("should preserve the request origin in POST redirect URL", async () => {
|
||||
const baseUrl =
|
||||
"https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1";
|
||||
const req = createMockRequest(baseUrl, "POST");
|
||||
|
||||
const res = await POST(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://self-hosted.company.com");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirect URL construction", () => {
|
||||
it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => {
|
||||
const testOrigins = [
|
||||
"https://app.cal.com",
|
||||
"https://acme.cal.com",
|
||||
"https://calcom.company.internal",
|
||||
"http://192.168.1.100:3000",
|
||||
];
|
||||
|
||||
for (const origin of testOrigins) {
|
||||
vi.clearAllMocks();
|
||||
const baseUrl = `${origin}/api/verify-booking-token?action=reject&token=t&bookingUid=test-uid&userId=1`;
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe(origin);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-uid");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,6 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
async function getHandler(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
|
||||
|
||||
try {
|
||||
@@ -38,21 +37,20 @@ async function getHandler(request: NextRequest) {
|
||||
if (action === DirectAction.REJECT) {
|
||||
// Rejections should use POST method
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, request.url)
|
||||
);
|
||||
}
|
||||
|
||||
return await handleBookingAction(action, token, bookingUid, userId, request, undefined);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function postHandler(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
|
||||
|
||||
try {
|
||||
@@ -61,10 +59,10 @@ async function postHandler(request: NextRequest) {
|
||||
const { reason } = z.object({ reason: z.string().optional() }).parse(body || {});
|
||||
|
||||
return await handleBookingAction(action, token, bookingUid, userId, request, reason);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -78,14 +76,13 @@ async function handleBookingAction(
|
||||
request: NextRequest,
|
||||
reason?: string
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { oneTimePassword: token },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -143,7 +140,7 @@ async function handleBookingAction(
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`,
|
||||
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -153,7 +150,7 @@ async function handleBookingAction(
|
||||
data: { oneTimePassword: null },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`, { status: 303 });
|
||||
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
|
||||
@@ -91,13 +91,13 @@ vi.mock("@calcom/emails/email-manager", () => ({
|
||||
const mockFindByPhoneNumber = vi.fn();
|
||||
const mockFindByProviderAgentId = vi.fn();
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
|
||||
vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => ({
|
||||
PrismaPhoneNumberRepository: vi.fn().mockImplementation(() => ({
|
||||
findByPhoneNumber: mockFindByPhoneNumber,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
|
||||
vi.mock("@calcom/features/calAIPhone/repositories/PrismaAgentRepository", () => ({
|
||||
PrismaAgentRepository: vi.fn().mockImplementation(() => ({
|
||||
findByProviderAgentId: mockFindByProviderAgentId,
|
||||
})),
|
||||
|
||||
@@ -4,12 +4,12 @@ import { NextResponse } from "next/server";
|
||||
import { Retell } from "retell-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PrismaAgentRepository } from "@calcom/features/calAIPhone/repositories/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository";
|
||||
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { CreditUsageType } from "@calcom/prisma/enums";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
|
||||
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
|
||||
@@ -41,6 +41,7 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
|
||||
import { useBookingDetailsSheetStore } from "../../modules/bookings/store/bookingDetailsSheetStore";
|
||||
import type { BookingAttendee } from "../../modules/bookings/types";
|
||||
import { AcceptBookingButton } from "./AcceptBookingButton";
|
||||
import { RejectBookingButton } from "./RejectBookingButton";
|
||||
@@ -136,6 +137,7 @@ const ConditionalLink = ({
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
const parsedBooking = buildParsedBooking(booking);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
const { onClick } = booking;
|
||||
@@ -144,6 +146,21 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
|
||||
// Get selected booking UID from store
|
||||
// The provider should always be available when BookingListItem is rendered (bookingsV3Enabled is true)
|
||||
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
|
||||
const isSelected = !!selectedBookingUid && selectedBookingUid === booking.uid;
|
||||
|
||||
// Scroll into view when this booking becomes selected
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => ({
|
||||
...attendee,
|
||||
noShow: attendee.noShow || false,
|
||||
@@ -265,10 +282,17 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
data-testid="booking-item"
|
||||
data-today={String(booking.isToday)}
|
||||
data-booking-list-item="true"
|
||||
className="hover:bg-cal-muted group w-full">
|
||||
data-booking-uid={booking.uid}
|
||||
className={classNames(
|
||||
"group relative w-full transition-all duration-100 ease-out",
|
||||
"hover:bg-cal-muted",
|
||||
isSelected &&
|
||||
"bg-cal-muted before:bg-brand-default rounded-r-md before:absolute before:left-0 before:top-0 before:h-full before:w-1"
|
||||
)}>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
|
||||
<div className="flex h-full items-center">
|
||||
@@ -381,7 +405,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
|
||||
"max-w-10/12 text-emphasis sm:max-w-56 break-words text-sm font-medium leading-6 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{title}
|
||||
@@ -395,7 +419,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||
className="max-w-10/12 text-default sm:max-w-32 md:max-w-52 xl:max-w-80 truncate text-sm"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isWithinMinimumRescheduleNotice } from "@calcom/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
|
||||
@@ -122,8 +123,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
? `?seatReferenceUid=${seatReferenceUid}`
|
||||
: ""
|
||||
}`,
|
||||
disabled:
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
|
||||
disabled: isActionDisabled("reschedule", {
|
||||
...context,
|
||||
booking,
|
||||
isBookingInPast,
|
||||
isDisabledRescheduling,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
@@ -131,9 +136,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
|
||||
label: t("send_reschedule_request"),
|
||||
disabled:
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
|
||||
isDisabledRescheduling ||
|
||||
booking.seatsReferences.length > 0,
|
||||
isActionDisabled("reschedule_request", {
|
||||
...context,
|
||||
booking,
|
||||
isBookingInPast,
|
||||
isDisabledRescheduling,
|
||||
}) || booking.seatsReferences.length > 0,
|
||||
},
|
||||
isBookingFromRoutingForm
|
||||
? {
|
||||
@@ -229,12 +237,29 @@ export function shouldShowIndividualReportButton(context: BookingActionContext):
|
||||
}
|
||||
|
||||
export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
|
||||
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context;
|
||||
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isAttendee } = context;
|
||||
|
||||
switch (actionId) {
|
||||
case "reschedule":
|
||||
case "reschedule_request":
|
||||
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
|
||||
// Only apply minimum reschedule notice restriction if user is NOT the organizer
|
||||
// If user is an attendee (or not authenticated), apply the restriction
|
||||
const isUserOrganizer =
|
||||
!isAttendee &&
|
||||
booking.loggedInUser?.userId &&
|
||||
booking.user?.id &&
|
||||
booking.loggedInUser.userId === booking.user.id;
|
||||
const isWithinMinimumNotice =
|
||||
!isUserOrganizer &&
|
||||
isWithinMinimumRescheduleNotice(
|
||||
new Date(booking.startTime),
|
||||
booking.eventType.minimumRescheduleNotice ?? null
|
||||
);
|
||||
return (
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
|
||||
isDisabledRescheduling ||
|
||||
isWithinMinimumNotice
|
||||
);
|
||||
case "cancel":
|
||||
return isDisabledCancelling || isBookingInPast;
|
||||
case "view_recordings":
|
||||
|
||||
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
hideOrganizerEmail: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
minimumRescheduleNotice: true,
|
||||
disableGuests: true,
|
||||
timeZone: true,
|
||||
profile: {
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
userId: true,
|
||||
responses: true,
|
||||
eventType: {
|
||||
select: {
|
||||
@@ -59,6 +60,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
allowReschedulingPastBookings: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
minimumRescheduleNotice: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -122,17 +124,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
booking: {
|
||||
uid,
|
||||
status: booking.status,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
responses: booking.responses,
|
||||
userId: booking.userId,
|
||||
eventType: {
|
||||
disableRescheduling: !!eventType?.disableRescheduling,
|
||||
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
|
||||
allowBookingFromCancelledBookingReschedule: !!eventType.allowReschedulingCancelledBookings,
|
||||
minimumRescheduleNotice: eventType.minimumRescheduleNotice,
|
||||
teamId: eventType.team?.id ?? null,
|
||||
},
|
||||
},
|
||||
eventUrl,
|
||||
forceRescheduleForCancelledBooking: allowRescheduleForCancelledBooking,
|
||||
currentUserId: session?.user?.id ?? null,
|
||||
bookingSeat,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -12,32 +13,47 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { FilterSearchField, Select } from "@calcom/ui/components/form";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
|
||||
|
||||
interface BookingLogsViewProps {
|
||||
bookingUid: string;
|
||||
}
|
||||
|
||||
type TranslationComponent = {
|
||||
type: "link";
|
||||
href: string;
|
||||
};
|
||||
|
||||
type TranslationWithParams = {
|
||||
key: string;
|
||||
params?: Record<string, string | number>;
|
||||
components?: TranslationComponent[];
|
||||
};
|
||||
|
||||
type AuditLog = {
|
||||
id: string;
|
||||
action: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: Record<string, unknown> | null;
|
||||
actionDisplayTitle: TranslationWithParams;
|
||||
displayFields?: Array<{ labelKey: string; valueKey: string }>;
|
||||
actor: {
|
||||
type: string;
|
||||
type: AuditActorType;
|
||||
displayName: string | null;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
interface BookingLogsFiltersProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
typeFilter: string | null;
|
||||
onTypeFilterChange: (value: string | null) => void;
|
||||
actorFilter: string | null;
|
||||
onActorFilterChange: (value: string | null) => void;
|
||||
typeOptions: Array<{ label: string; value: string }>;
|
||||
actorOptions: Array<{ label: string; value: string }>;
|
||||
actorFilter: AuditActorType | null;
|
||||
onActorFilterChange: (value: AuditActorType | null) => void;
|
||||
actorOptions: Array<{ label: string; value: AuditActorType }>;
|
||||
}
|
||||
|
||||
interface BookingLogsTimelineProps {
|
||||
@@ -70,14 +86,26 @@ const getActionIcon = (action: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getActorRoleLabel = (actorType: AuditActorType): string | null => {
|
||||
switch (actorType) {
|
||||
case "GUEST":
|
||||
return "Guest";
|
||||
case "ATTENDEE":
|
||||
return "Attendee";
|
||||
case "SYSTEM":
|
||||
return null;
|
||||
case "USER":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function BookingLogsFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
actorFilter,
|
||||
onActorFilterChange,
|
||||
typeOptions,
|
||||
actorOptions,
|
||||
}: BookingLogsFiltersProps) {
|
||||
const { t } = useLocale();
|
||||
@@ -93,25 +121,13 @@ function BookingLogsFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[140px]">
|
||||
<Select
|
||||
size="sm"
|
||||
value={typeFilter ? { label: `${t("type")}: ${t(`audit_action.${typeFilter.toLowerCase()}`)}`, value: typeFilter } : { label: `${t("type")}: ${t("all")}`, value: "" }}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
onTypeFilterChange(option.value || null);
|
||||
}}
|
||||
options={[{ label: `${t("type")}: ${t("all")}`, value: "" }, ...typeOptions.map(opt => ({ ...opt, label: `${t("type")}: ${opt.label}` }))]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[140px]">
|
||||
<Select
|
||||
size="sm"
|
||||
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
onActorFilterChange(option.value || null);
|
||||
onActorFilterChange((option.value as AuditActorType) || null);
|
||||
}}
|
||||
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
|
||||
/>
|
||||
@@ -120,6 +136,68 @@ function BookingLogsFilters({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the action display title with support for Trans component interpolation
|
||||
* Handles translations with embedded components (e.g., links) for proper i18n support
|
||||
*/
|
||||
function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWithParams }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (actionDisplayTitle.components?.length) {
|
||||
return (
|
||||
<ServerTrans
|
||||
t={t}
|
||||
i18nKey={actionDisplayTitle.key}
|
||||
values={actionDisplayTitle.params}
|
||||
components={actionDisplayTitle.components.map((comp) =>
|
||||
comp.type === "link" ? (
|
||||
<Link
|
||||
key={comp.href}
|
||||
href={comp.href}
|
||||
className="text-emphasis underline hover:no-underline"
|
||||
/>
|
||||
) : (
|
||||
<span key={comp.href} />
|
||||
)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{t(actionDisplayTitle.key, actionDisplayTitle.params)}</>;
|
||||
}
|
||||
|
||||
interface JsonViewerProps {
|
||||
data: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function JsonViewer({ data }: JsonViewerProps) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(data, null, 2);
|
||||
const lines = jsonString.split("\n");
|
||||
const lineCount = lines.length;
|
||||
const lineNumberWidth = Math.max(2, Math.ceil(Math.log10(lineCount)) + 1);
|
||||
|
||||
return (
|
||||
<div className="bg-default p-3 rounded-md text-[10px] overflow-x-auto font-mono">
|
||||
{lines.map((line, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<span
|
||||
className="text-subtle select-none text-right shrink-0"
|
||||
style={{ minWidth: `${lineNumberWidth}ch` }}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-default whitespace-pre">{line || " "}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
const { t } = useLocale();
|
||||
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
||||
@@ -157,8 +235,8 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
{logs.map((log, index) => {
|
||||
const isLast = index === logs.length - 1;
|
||||
const isExpanded = expandedLogIds.has(log.id);
|
||||
const actionDisplay = t(`audit_action.${log.action.toLowerCase()}`);
|
||||
const showJson = showJsonMap[log.id] || false;
|
||||
const actorRole = getActorRoleLabel(log.actor.type);
|
||||
|
||||
return (
|
||||
<div key={log.id} className="flex gap-1">
|
||||
@@ -178,78 +256,84 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-emphasis leading-4">
|
||||
{actionDisplay}
|
||||
<ActionTitle actionDisplayTitle={log.actionDisplayTitle} />
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
|
||||
<span>{log.actor.displayName}</span>
|
||||
{log.actor.displayAvatar && (
|
||||
<Avatar
|
||||
size="xs"
|
||||
imageSrc={log.actor.displayAvatar}
|
||||
alt={log.actor.displayName || ""}
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{log.actor.displayName}
|
||||
{actorRole && ` (${actorRole})`}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs(log.timestamp).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<div className="bg-muted rounded-[10px] py-1">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(log.id)}
|
||||
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
|
||||
className="w-full justify-start text-xs font-medium text-subtle h-6">
|
||||
{isExpanded ? t("hide_details") : t("show_details")}
|
||||
</Button>
|
||||
<div className="bg-muted rounded-lg border border-muted">
|
||||
<div className="py-1">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(log.id)}
|
||||
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
|
||||
className="justify-start text-xs font-medium text-subtle h-6">
|
||||
{isExpanded ? t("hide_details") : t("show_details")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-default rounded-lg m-0.5 text-xs">
|
||||
{/* Render displayFields if available, otherwise show type */}
|
||||
{log.displayFields && log.displayFields.length > 0 ? (
|
||||
log.displayFields.map((field, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t(field.labelKey)}</span>
|
||||
<span className="text-[#096638] font-medium">{t(field.valueKey)}</span>
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
|
||||
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">
|
||||
{t("timestamp")}
|
||||
</span>
|
||||
<span className="text-default">
|
||||
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
{log.data && Object.keys(log.data).length > 0 && (
|
||||
<div>
|
||||
<div className="flex flex-col items-start gap-2 py-1 px-3 border-b border-subtle">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleJson(log.id)}
|
||||
StartIcon={showJson ? "chevron-down" : "chevron-right"}
|
||||
className="-ml-3 h-6 px-2 font-medium text-xs">
|
||||
{t("json")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{showJson && <JsonViewer data={log.data} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 px-3">
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("type")}</span>
|
||||
<span className="text-default">{log.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
|
||||
<span className="text-default">{log.actor.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">
|
||||
{t("timestamp")}
|
||||
</span>
|
||||
<span className="text-default">
|
||||
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{log.data && Object.keys(log.data).length > 0 && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleJson(log.id)}
|
||||
StartIcon={showJson ? "chevron-down" : "chevron-right"}
|
||||
className="mb-1 h-6 px-0 font-medium">
|
||||
{t("json")}
|
||||
</Button>
|
||||
{showJson && (
|
||||
<pre className="bg-subtle p-3 rounded-md text-[10px] overflow-x-auto text-default font-mono border border-subtle">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -261,8 +345,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [actorFilter, setActorFilter] = useState<string | null>(null);
|
||||
const [actorFilter, setActorFilter] = useState<AuditActorType | null>(null);
|
||||
const { t } = useLocale();
|
||||
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
|
||||
bookingUid,
|
||||
@@ -302,19 +385,12 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
|
||||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesType = !typeFilter || log.action === typeFilter;
|
||||
const matchesActor = !actorFilter || log.actor.type === actorFilter;
|
||||
|
||||
return matchesSearch && matchesType && matchesActor;
|
||||
return matchesSearch && matchesActor;
|
||||
});
|
||||
|
||||
const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action)));
|
||||
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type)));
|
||||
|
||||
const typeOptions = uniqueTypes.map((type) => ({
|
||||
label: t(`audit_action.${type.toLowerCase()}`),
|
||||
value: type,
|
||||
}));
|
||||
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))) as AuditActorType[];
|
||||
|
||||
const actorOptions = uniqueActorTypes.map((actorType) => ({
|
||||
label: actorType,
|
||||
@@ -326,11 +402,8 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
|
||||
<BookingLogsFilters
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
actorFilter={actorFilter}
|
||||
onActorFilterChange={setActorFilter}
|
||||
typeOptions={typeOptions}
|
||||
actorOptions={actorOptions}
|
||||
/>
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Icon } from "@calcom/ui/components/icon";
|
||||
import { useBookingCalendarData } from "~/bookings/hooks/useBookingCalendarData";
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import { useCalendarAllowedFilters } from "~/bookings/hooks/useCalendarAllowedFilters";
|
||||
import { useCalendarAutoSelector } from "~/bookings/hooks/useCalendarAutoSelector";
|
||||
import { useCalendarNavigationCapabilities } from "~/bookings/hooks/useCalendarNavigationCapabilities";
|
||||
import { useCurrentWeekStart } from "~/bookings/hooks/useCurrentWeekStart";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
@@ -36,6 +38,7 @@ interface BookingCalendarContainerProps {
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
bookingsV3Enabled: boolean;
|
||||
}
|
||||
|
||||
interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
|
||||
@@ -48,20 +51,39 @@ interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
|
||||
isPending: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
hasNextPage: boolean;
|
||||
isFetched: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
}
|
||||
|
||||
function BookingCalendarInner({
|
||||
status,
|
||||
permissions,
|
||||
bookingsV3Enabled,
|
||||
data,
|
||||
allowedFilterIds,
|
||||
hasError,
|
||||
errorMessage,
|
||||
hasNextPage,
|
||||
isFetched,
|
||||
isFetchingNextPage,
|
||||
}: BookingCalendarInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
|
||||
|
||||
const rowData = useBookingCalendarData({ data, status });
|
||||
|
||||
// Extract bookings from table data
|
||||
const bookings = useMemo(() => {
|
||||
return rowData
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [rowData]);
|
||||
|
||||
// Handle auto-selection for calendar view
|
||||
useCalendarAutoSelector(bookings, hasNextPage, isFetched, isFetchingNextPage);
|
||||
|
||||
const goToPreviousWeek = () => {
|
||||
setCurrentWeekStart(currentWeekStart.subtract(1, "week"));
|
||||
};
|
||||
@@ -86,8 +108,6 @@ function BookingCalendarInner({
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const rowData = useBookingCalendarData({ data, status });
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: rowData,
|
||||
columns,
|
||||
@@ -99,13 +119,6 @@ function BookingCalendarInner({
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
// Extract bookings from table data
|
||||
const bookings = useMemo(() => {
|
||||
return rowData
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [rowData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
@@ -132,7 +145,7 @@ function BookingCalendarInner({
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ViewToggleButton />
|
||||
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
|
||||
</div>
|
||||
</div>
|
||||
{hasError && ErrorView ? (
|
||||
@@ -158,7 +171,7 @@ function BookingCalendarInner({
|
||||
export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
const { canReadOthersBookings } = props.permissions;
|
||||
const { userIds } = useBookingFilters();
|
||||
const { currentWeekStart } = useCurrentWeekStart();
|
||||
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
|
||||
|
||||
const allowedFilterIds = useCalendarAllowedFilters({
|
||||
canReadOthersBookings,
|
||||
@@ -182,8 +195,9 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
}
|
||||
);
|
||||
|
||||
const { isFetched, hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
|
||||
// Automatically fetch all pages until no more data
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
@@ -208,8 +222,17 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
|
||||
const bookings = useMemo(() => data?.bookings ?? [], [data?.bookings]);
|
||||
|
||||
// Create navigation capabilities for calendar view
|
||||
// This hook handles probe queries and prefetching internally
|
||||
const capabilities = useCalendarNavigationCapabilities({
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
userWeekStart,
|
||||
filters: { statuses: STATUSES, userIds },
|
||||
});
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
|
||||
<BookingCalendarInner
|
||||
{...props}
|
||||
data={data}
|
||||
@@ -217,6 +240,9 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetched={isFetched}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ export function BookingCalendarView({
|
||||
onWeekStartChange,
|
||||
}: BookingCalendarViewProps) {
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
|
||||
const { timezone } = useTimePreferences();
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
const { bannersHeight } = useBanners();
|
||||
@@ -91,6 +92,7 @@ export function BookingCalendarView({
|
||||
showBackgroundPattern={false}
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
selectedBookingUid={selectedBookingUid}
|
||||
onEventClick={(event) => {
|
||||
const bookingUid = event.options?.bookingUid;
|
||||
if (bookingUid) {
|
||||
|
||||
@@ -42,10 +42,7 @@ import { BookingActionsDropdown } from "../../../components/booking/actions/Book
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { usePaymentStatus } from "../hooks/usePaymentStatus";
|
||||
import {
|
||||
useBookingDetailsSheetStore,
|
||||
useBookingDetailsSheetStoreApi,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingOutput } from "../types";
|
||||
import { JoinMeetingButton } from "./JoinMeetingButton";
|
||||
|
||||
@@ -103,35 +100,38 @@ function BookingDetailsSheetInner({
|
||||
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
enabled: Boolean(booking.rescheduled || booking.fromReschedule),
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
// Get navigation state directly from the store
|
||||
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
|
||||
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
// Get navigation state from the store in a single selector
|
||||
const navigation = useBookingDetailsSheetStore((state) => {
|
||||
const hasNextInArray = state.hasNextInArray();
|
||||
const hasPreviousInArray = state.hasPreviousInArray();
|
||||
const isLastInArray = state.isLastInArray();
|
||||
const isFirstInArray = state.isFirstInArray();
|
||||
|
||||
return {
|
||||
navigateNext: state.navigateNext,
|
||||
navigatePrevious: state.navigatePrevious,
|
||||
isTransitioning: state.isTransitioning,
|
||||
setSelectedBookingUid: state.setSelectedBookingUid,
|
||||
canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
|
||||
canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
|
||||
};
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedBookingUid(null);
|
||||
navigation.setSelectedBookingUid(null);
|
||||
};
|
||||
|
||||
const storeApi = useBookingDetailsSheetStoreApi();
|
||||
|
||||
const handleNext = () => {
|
||||
const nextUid = storeApi.getState().getNextBookingUid();
|
||||
if (nextUid !== null) {
|
||||
setSelectedBookingUid(nextUid);
|
||||
}
|
||||
navigation.navigateNext();
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const prevUid = storeApi.getState().getPreviousBookingUid();
|
||||
if (prevUid !== null) {
|
||||
setSelectedBookingUid(prevUid);
|
||||
}
|
||||
navigation.navigatePrevious();
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime).tz(userTimeZone);
|
||||
@@ -214,7 +214,7 @@ function BookingDetailsSheetInner({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-up"
|
||||
disabled={!hasPrevious}
|
||||
disabled={!navigation.canGoPrev || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrevious();
|
||||
@@ -225,7 +225,7 @@ function BookingDetailsSheetInner({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
disabled={!hasNext}
|
||||
disabled={!navigation.canGoNext || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
@@ -289,6 +289,8 @@ function BookingDetailsSheetInner({
|
||||
customResponses={customResponses}
|
||||
bookingFields={booking.eventType?.bookingFields}
|
||||
/>
|
||||
|
||||
<TrackingSection tracking={bookingDetails?.tracking} />
|
||||
</div>
|
||||
</SheetBody>
|
||||
|
||||
@@ -841,6 +843,43 @@ function BookingHeaderBadges({
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingSection({
|
||||
tracking,
|
||||
}: {
|
||||
tracking?: {
|
||||
utm_source: string | null;
|
||||
utm_medium: string | null;
|
||||
utm_campaign: string | null;
|
||||
utm_term: string | null;
|
||||
utm_content: string | null;
|
||||
} | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!tracking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value));
|
||||
|
||||
if (utmEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("utm_params")}>
|
||||
<div className="text-default text-sm">
|
||||
{utmEntries.map(([key, value]) => (
|
||||
<div key={key} className="mb-1 last:mb-0">
|
||||
<span className="font-medium">{key}</span>:{" "}
|
||||
<code className="bg-subtle text-default rounded px-1 py-0.5 font-mono text-xs">{value}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
className,
|
||||
|
||||
@@ -25,6 +25,8 @@ import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
|
||||
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
|
||||
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
|
||||
import { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities";
|
||||
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
@@ -77,11 +79,13 @@ interface BookingListInnerProps extends BookingListContainerProps {
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
totalRowCount?: number;
|
||||
bookings: BookingsGetOutput["bookings"];
|
||||
}
|
||||
|
||||
function BookingListInner({
|
||||
status,
|
||||
permissions,
|
||||
bookings,
|
||||
bookingsV3Enabled,
|
||||
data,
|
||||
isPending,
|
||||
@@ -95,6 +99,9 @@ function BookingListInner({
|
||||
const router = useRouter();
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
// Handle auto-selection for list view
|
||||
useListAutoSelector(bookings);
|
||||
|
||||
const ErrorView = errorMessage ? (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
|
||||
) : undefined;
|
||||
@@ -180,7 +187,7 @@ function BookingListInner({
|
||||
<div className="hidden grow md:block" />
|
||||
|
||||
<DataTableSegment.Select shortLabel />
|
||||
{bookingsV3Enabled && <ViewToggleButton />}
|
||||
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
|
||||
</div>
|
||||
{displayedFilterCount > 0 && showFilters && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
@@ -221,12 +228,13 @@ function BookingListInner({
|
||||
}
|
||||
|
||||
export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const { limit, offset } = useDataTable();
|
||||
const { limit, offset, setPageIndex } = useDataTable();
|
||||
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
|
||||
useBookingFilters();
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
// Build query input once - shared between query and prefetching
|
||||
const queryInput = useMemo(
|
||||
() => ({
|
||||
limit,
|
||||
offset,
|
||||
filters: {
|
||||
@@ -242,17 +250,40 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
}),
|
||||
[
|
||||
limit,
|
||||
offset,
|
||||
props.status,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
dateRange,
|
||||
]
|
||||
);
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(queryInput, {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
});
|
||||
|
||||
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
|
||||
|
||||
// Always call the hook and provide navigation capabilities
|
||||
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
|
||||
const capabilities = useListNavigationCapabilities({
|
||||
limit,
|
||||
offset,
|
||||
totalCount: query.data?.totalCount,
|
||||
setPageIndex,
|
||||
queryInput,
|
||||
});
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
|
||||
<BookingListInner
|
||||
{...props}
|
||||
data={query.data}
|
||||
@@ -260,6 +291,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
totalRowCount={query.data?.totalCount}
|
||||
bookings={bookings}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -8,14 +7,15 @@ import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { viewParser, type BookingView } from "../lib/viewParser";
|
||||
import { useBookingsView } from "../hooks/useBookingsView";
|
||||
|
||||
export function ViewToggleButton() {
|
||||
type ViewToggleButtonProps = {
|
||||
bookingsV3Enabled: boolean;
|
||||
};
|
||||
|
||||
export function ViewToggleButton({ bookingsV3Enabled }: ViewToggleButtonProps) {
|
||||
const { t } = useLocale();
|
||||
const [view, setView] = useQueryState(
|
||||
"view",
|
||||
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
|
||||
);
|
||||
const [view, setView] = useBookingsView({ bookingsV3Enabled });
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,9 +33,9 @@ export function ViewToggleButton() {
|
||||
<div className="hidden sm:block">
|
||||
<ToggleGroup
|
||||
value={view}
|
||||
onValueChange={(value) => {
|
||||
onValueChange={(value: "list" | "calendar") => {
|
||||
if (!value) return;
|
||||
setView(value as BookingView);
|
||||
setView(value);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
|
||||
@@ -26,6 +26,18 @@ export function useBookingListData({
|
||||
userTimeZone?: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Build a Map for recurringInfo lookups
|
||||
const recurringInfoMap = useMemo(() => {
|
||||
const map = new Map<string, NonNullable<typeof data>["recurringInfo"][number]>();
|
||||
for (const info of data?.recurringInfo ?? []) {
|
||||
if (info.recurringEventId) {
|
||||
map.set(info.recurringEventId, info);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [data?.recurringInfo]);
|
||||
|
||||
/**
|
||||
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
@@ -70,11 +82,11 @@ export function useBookingListData({
|
||||
data?.bookings.filter(filterBookings).map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
recurringInfo: booking.recurringEventId ? recurringInfoMap.get(booking.recurringEventId) : undefined,
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [data, status, userTimeZone]);
|
||||
}, [data?.bookings, recurringInfoMap, status, userTimeZone]);
|
||||
|
||||
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
|
||||
const bookingsToday = useMemo<BookingRowData[]>(() => {
|
||||
@@ -88,10 +100,10 @@ export function useBookingListData({
|
||||
.map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
recurringInfo: booking.recurringEventId ? recurringInfoMap.get(booking.recurringEventId) : undefined,
|
||||
isToday: true,
|
||||
}));
|
||||
}, [data, userTimeZone]);
|
||||
}, [data?.bookings, recurringInfoMap, userTimeZone]);
|
||||
|
||||
// Combine data with section separators for "upcoming" tab
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
const STORAGE_KEY = "bookings-preferred-view";
|
||||
|
||||
type BookingView = "list" | "calendar";
|
||||
|
||||
const viewParser = createParser({
|
||||
parse: (value: string) => {
|
||||
if (value === "calendar") return "calendar";
|
||||
return "list";
|
||||
},
|
||||
serialize: (value: BookingView) => value,
|
||||
});
|
||||
|
||||
// Create a store for localStorage value
|
||||
const createLocalStorageStore = () => {
|
||||
let listeners: Array<() => void> = [];
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
};
|
||||
|
||||
const getSnapshot = (): BookingView => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "list" || stored === "calendar") {
|
||||
return stored;
|
||||
}
|
||||
return "list";
|
||||
};
|
||||
|
||||
const getServerSnapshot = (): BookingView => {
|
||||
return "list";
|
||||
};
|
||||
|
||||
const notify = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
return { subscribe, getSnapshot, getServerSnapshot, notify };
|
||||
};
|
||||
|
||||
const localStorageStore = createLocalStorageStore();
|
||||
|
||||
type UseBookingsViewOptions = {
|
||||
bookingsV3Enabled: boolean;
|
||||
};
|
||||
|
||||
export function useBookingsView({ bookingsV3Enabled }: UseBookingsViewOptions) {
|
||||
// Always use "list" as the default for useQueryState to keep instances in sync
|
||||
const [_view, setView] = useQueryState("view", viewParser.withDefault("list"));
|
||||
|
||||
// Track if we've completed the initial sync to prevent race conditions
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Read from localStorage using useSyncExternalStore
|
||||
const storedView = useSyncExternalStore(
|
||||
localStorageStore.subscribe,
|
||||
localStorageStore.getSnapshot,
|
||||
localStorageStore.getServerSnapshot
|
||||
);
|
||||
|
||||
// Force view to be "list" if calendar view is disabled
|
||||
const view = bookingsV3Enabled ? _view : "list";
|
||||
|
||||
// Sync localStorage value to URL on initial mount
|
||||
useEffect(() => {
|
||||
// Only sync if there's no URL parameter AND localStorage has a non-default value
|
||||
const urlHasViewParam =
|
||||
typeof window !== "undefined" && new URLSearchParams(window.location.search).has("view");
|
||||
|
||||
if (!urlHasViewParam && storedView !== "list" && _view !== storedView) {
|
||||
setView(storedView);
|
||||
} else {
|
||||
// No sync needed, mark as initialized
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Mark as initialized when _view matches storedView after initial sync
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current && _view === storedView) {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
}, [_view, storedView]);
|
||||
|
||||
// Sync to localStorage when view changes (only if initialized)
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) return;
|
||||
|
||||
if (bookingsV3Enabled && view && view !== storedView) {
|
||||
localStorage.setItem(STORAGE_KEY, view);
|
||||
localStorageStore.notify(); // Notify all subscribers
|
||||
}
|
||||
}, [view, storedView, bookingsV3Enabled]);
|
||||
|
||||
return [view, setView] as const;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingsGetOutput } from "../types";
|
||||
|
||||
/**
|
||||
* Calendar-specific auto-selection logic hook.
|
||||
* Handles auto-selecting bookings when navigating across weeks in calendar view.
|
||||
*
|
||||
* - For "first": Selects as soon as the first page loads (immediate)
|
||||
* - For "last": Waits for all pages to load to ensure we get the actual last booking
|
||||
*/
|
||||
export function useCalendarAutoSelector(
|
||||
bookings: BookingsGetOutput["bookings"],
|
||||
hasNextPage: boolean,
|
||||
isFetched: boolean,
|
||||
isFetchingNextPage: boolean
|
||||
) {
|
||||
const pendingSelection = useBookingDetailsSheetStore((state) => state.pendingSelection);
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const clearPendingSelection = useBookingDetailsSheetStore((state) => state.clearPendingSelection);
|
||||
const setIsTransitioning = useBookingDetailsSheetStore((state) => state.setIsTransitioning);
|
||||
const bookingsRef = useRef(bookings);
|
||||
|
||||
useEffect(() => {
|
||||
// Early return if no pending selection
|
||||
if (!pendingSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasBookingsChanged = bookings !== bookingsRef.current;
|
||||
bookingsRef.current = bookings;
|
||||
|
||||
if (!hasBookingsChanged) return;
|
||||
|
||||
if (pendingSelection === "first" && isFetched && bookings.length === 0) {
|
||||
// data fetching is finished but there is no booking to select
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// For "first", we can select immediately when the first page arrives
|
||||
if (pendingSelection === "first" && bookings.length > 0) {
|
||||
setSelectedBookingUid(bookings[0].uid);
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// For "last", wait until all pages are loaded to ensure we get the actual last booking
|
||||
if (pendingSelection === "last") {
|
||||
const isAllDataLoaded = !hasNextPage && !isFetchingNextPage;
|
||||
|
||||
if (isAllDataLoaded && bookings.length === 0) {
|
||||
// data fetching is finished but there is no booking to select
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for all data to load AND for bookings to actually arrive
|
||||
if (!isAllDataLoaded || bookings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastBooking = bookings[bookings.length - 1];
|
||||
setSelectedBookingUid(lastBooking.uid);
|
||||
|
||||
// Clear transition state and pending selection after handling
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
}
|
||||
}, [
|
||||
bookings,
|
||||
hasNextPage,
|
||||
isFetched,
|
||||
isFetchingNextPage,
|
||||
pendingSelection,
|
||||
setSelectedBookingUid,
|
||||
clearPendingSelection,
|
||||
setIsTransitioning,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { getWeekStart } from "../lib/weekUtils";
|
||||
import type { NavigationCapabilities } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingListingStatus } from "../types";
|
||||
import { useNearestFutureBooking } from "./useNearestFutureBooking";
|
||||
import { useNearestPastBooking } from "./useNearestPastBooking";
|
||||
|
||||
interface UseCalendarNavigationCapabilitiesProps {
|
||||
currentWeekStart: Dayjs;
|
||||
setCurrentWeekStart: (date: Dayjs) => void;
|
||||
userWeekStart: number;
|
||||
/** Filters to use for probe queries and prefetching */
|
||||
filters: {
|
||||
statuses: BookingListingStatus[];
|
||||
userIds?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar view navigation capabilities adapter.
|
||||
* Provides week-based navigation logic for the booking details sheet.
|
||||
*
|
||||
* This hook:
|
||||
* - Uses probe queries to find nearest bookings in each direction
|
||||
* - Jumps directly to the week containing the nearest booking (not just adjacent week)
|
||||
* - Disables navigation buttons when no bookings exist in that direction
|
||||
* - Prefetches booking data for the target weeks to improve navigation performance
|
||||
*/
|
||||
export function useCalendarNavigationCapabilities({
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
userWeekStart,
|
||||
filters,
|
||||
}: UseCalendarNavigationCapabilitiesProps): NavigationCapabilities {
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
// Probe queries for navigation - find nearest bookings in each direction
|
||||
const { nearestBooking: nearestFutureBooking } = useNearestFutureBooking({
|
||||
currentWeekStart,
|
||||
filters,
|
||||
});
|
||||
|
||||
const { nearestBooking: nearestPastBooking } = useNearestPastBooking({
|
||||
currentWeekStart,
|
||||
filters,
|
||||
});
|
||||
|
||||
const hasFutureBooking = !!nearestFutureBooking;
|
||||
const hasPastBooking = !!nearestPastBooking;
|
||||
const nextBookingDate = nearestFutureBooking?.startTime?.toString() ?? null;
|
||||
const prevBookingDate = nearestPastBooking?.startTime?.toString() ?? null;
|
||||
|
||||
// Calculate target week starts for prefetching
|
||||
const nextWeekStart = useMemo(() => {
|
||||
if (!nextBookingDate) return null;
|
||||
return getWeekStart(dayjs(nextBookingDate), userWeekStart);
|
||||
}, [nextBookingDate, userWeekStart]);
|
||||
|
||||
const prevWeekStart = useMemo(() => {
|
||||
if (!prevBookingDate) return null;
|
||||
return getWeekStart(dayjs(prevBookingDate), userWeekStart);
|
||||
}, [prevBookingDate, userWeekStart]);
|
||||
|
||||
// Prefetch bookings for the next week with bookings
|
||||
// Uses prefetchInfinite to match the useInfiniteQuery used in BookingCalendarContainer
|
||||
useEffect(() => {
|
||||
if (!nextWeekStart) return;
|
||||
|
||||
trpcUtils.viewer.bookings.get.prefetchInfinite({
|
||||
limit: 100,
|
||||
filters: {
|
||||
statuses: filters.statuses,
|
||||
userIds: filters.userIds,
|
||||
afterStartDate: nextWeekStart.startOf("day").toISOString(),
|
||||
beforeEndDate: nextWeekStart.add(6, "day").endOf("day").toISOString(),
|
||||
},
|
||||
});
|
||||
}, [nextWeekStart, filters.statuses, filters.userIds, trpcUtils]);
|
||||
|
||||
// Prefetch bookings for the previous week with bookings
|
||||
// Uses prefetchInfinite to match the useInfiniteQuery used in BookingCalendarContainer
|
||||
useEffect(() => {
|
||||
if (!prevWeekStart) return;
|
||||
|
||||
trpcUtils.viewer.bookings.get.prefetchInfinite({
|
||||
limit: 100,
|
||||
filters: {
|
||||
statuses: filters.statuses,
|
||||
userIds: filters.userIds,
|
||||
afterStartDate: prevWeekStart.startOf("day").toISOString(),
|
||||
beforeEndDate: prevWeekStart.add(6, "day").endOf("day").toISOString(),
|
||||
},
|
||||
});
|
||||
}, [prevWeekStart, filters.statuses, filters.userIds, trpcUtils]);
|
||||
|
||||
const canNavigateToNextPeriod = useCallback(() => hasFutureBooking, [hasFutureBooking]);
|
||||
|
||||
const canNavigateToPreviousPeriod = useCallback(() => hasPastBooking, [hasPastBooking]);
|
||||
|
||||
const requestNextPeriod = useCallback(() => {
|
||||
if (!nextBookingDate) return;
|
||||
const targetWeekStart = getWeekStart(dayjs(nextBookingDate), userWeekStart);
|
||||
setCurrentWeekStart(targetWeekStart);
|
||||
}, [nextBookingDate, setCurrentWeekStart, userWeekStart]);
|
||||
|
||||
const requestPreviousPeriod = useCallback(() => {
|
||||
if (!prevBookingDate) return;
|
||||
const targetWeekStart = getWeekStart(dayjs(prevBookingDate), userWeekStart);
|
||||
setCurrentWeekStart(targetWeekStart);
|
||||
}, [prevBookingDate, setCurrentWeekStart, userWeekStart]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
canNavigateToNextPeriod,
|
||||
canNavigateToPreviousPeriod,
|
||||
requestNextPeriod,
|
||||
requestPreviousPeriod,
|
||||
}),
|
||||
[canNavigateToNextPeriod, canNavigateToPreviousPeriod, requestNextPeriod, requestPreviousPeriod]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingsGetOutput } from "../types";
|
||||
|
||||
/**
|
||||
* List-specific auto-selection logic hook.
|
||||
* Handles auto-selecting bookings when navigating across pages in list view.
|
||||
* For list view, data is loaded per page, so we can select immediately when bookings change.
|
||||
*/
|
||||
export function useListAutoSelector(bookings: BookingsGetOutput["bookings"]) {
|
||||
const pendingSelection = useBookingDetailsSheetStore((state) => state.pendingSelection);
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const clearPendingSelection = useBookingDetailsSheetStore((state) => state.clearPendingSelection);
|
||||
const setIsTransitioning = useBookingDetailsSheetStore((state) => state.setIsTransitioning);
|
||||
const bookingsRef = useRef(bookings);
|
||||
|
||||
useEffect(() => {
|
||||
const hasBookingsChanged = bookings !== bookingsRef.current;
|
||||
// Always track current bookings to avoid stale ref when pendingSelection is later set
|
||||
bookingsRef.current = bookings;
|
||||
|
||||
// Early return if no pending selection
|
||||
if (!pendingSelection) return;
|
||||
|
||||
if (!hasBookingsChanged) return;
|
||||
|
||||
if (bookings.length > 0) {
|
||||
const bookingToSelect = pendingSelection === "first" ? bookings[0] : bookings[bookings.length - 1];
|
||||
setSelectedBookingUid(bookingToSelect.uid);
|
||||
}
|
||||
|
||||
// Always clear transition state and pending selection after handling
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
}, [bookings, pendingSelection, setSelectedBookingUid, clearPendingSelection, setIsTransitioning]);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useEffect } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { NavigationCapabilities } from "../store/bookingDetailsSheetStore";
|
||||
|
||||
interface UseListNavigationCapabilitiesProps {
|
||||
limit: number;
|
||||
offset: number;
|
||||
totalCount: number | undefined;
|
||||
setPageIndex: (index: number) => void;
|
||||
queryInput: Parameters<typeof trpc.viewer.bookings.get.useQuery>[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* List view navigation capabilities adapter.
|
||||
* Provides pagination-specific navigation logic for the booking details sheet.
|
||||
*
|
||||
* This hook:
|
||||
* - Calculates page boundaries based on limit/offset
|
||||
* - Prefetches the next page when available (using the same query params as parent)
|
||||
* - Provides methods to navigate between pages
|
||||
*/
|
||||
export function useListNavigationCapabilities({
|
||||
limit,
|
||||
offset,
|
||||
totalCount,
|
||||
setPageIndex,
|
||||
queryInput,
|
||||
}: UseListNavigationCapabilitiesProps): NavigationCapabilities {
|
||||
const trpcUtils = trpc.useUtils();
|
||||
const currentPageIndex = limit > 0 ? offset / limit : 0;
|
||||
|
||||
// Calculate if there are more pages
|
||||
const hasNextPage = useMemo(() => {
|
||||
if (!totalCount) return false;
|
||||
return offset + limit < totalCount;
|
||||
}, [offset, limit, totalCount]);
|
||||
|
||||
const hasPreviousPage = offset > 0;
|
||||
|
||||
// Build query params for next page by reusing parent's query input
|
||||
const nextPageParams = useMemo(
|
||||
() => ({
|
||||
...queryInput,
|
||||
offset: (currentPageIndex + 1) * limit,
|
||||
}),
|
||||
[queryInput, currentPageIndex, limit]
|
||||
);
|
||||
|
||||
// Prefetch next page when it exists
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
trpcUtils.viewer.bookings.get.prefetch(nextPageParams);
|
||||
}
|
||||
}, [hasNextPage, nextPageParams, trpcUtils]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
canNavigateToNextPeriod: () => hasNextPage,
|
||||
canNavigateToPreviousPeriod: () => hasPreviousPage,
|
||||
|
||||
requestNextPeriod: () => {
|
||||
if (!hasNextPage) return;
|
||||
setPageIndex(currentPageIndex + 1);
|
||||
},
|
||||
|
||||
requestPreviousPeriod: () => {
|
||||
if (!hasPreviousPage) return;
|
||||
setPageIndex(currentPageIndex - 1);
|
||||
},
|
||||
}),
|
||||
[hasNextPage, hasPreviousPage, currentPageIndex, setPageIndex]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { NAVIGATION_PROBE_WINDOW_MONTHS } from "../lib/constants";
|
||||
import type { BookingListingStatus } from "../types";
|
||||
|
||||
interface UseNearestFutureBookingProps {
|
||||
currentWeekStart: Dayjs;
|
||||
filters: {
|
||||
statuses: BookingListingStatus[];
|
||||
userIds?: number[];
|
||||
};
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe hook to find the nearest future booking after the current week.
|
||||
* Used for calendar view navigation to determine if there are any bookings
|
||||
* in the future and to jump directly to the week containing that booking.
|
||||
*
|
||||
* Uses a broad date range (NAVIGATION_PROBE_WINDOW_MONTHS) but only fetches
|
||||
* 1 booking (the nearest one) to minimize data transfer.
|
||||
*/
|
||||
export function useNearestFutureBooking({
|
||||
currentWeekStart,
|
||||
filters,
|
||||
enabled = true,
|
||||
}: UseNearestFutureBookingProps) {
|
||||
// Search from the end of current week to NAVIGATION_PROBE_WINDOW_MONTHS months ahead
|
||||
const afterDate = currentWeekStart.add(1, "week").startOf("day");
|
||||
const beforeDate = currentWeekStart.add(NAVIGATION_PROBE_WINDOW_MONTHS, "month").endOf("day");
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
filters: {
|
||||
statuses: filters.statuses,
|
||||
userIds: filters.userIds,
|
||||
afterStartDate: afterDate.toISOString(),
|
||||
beforeEndDate: beforeDate.toISOString(),
|
||||
},
|
||||
limit: 1, // Only need the nearest one
|
||||
// Default sort for "upcoming" status is ASC, which gives us the nearest future booking
|
||||
},
|
||||
{
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
nearestBooking: query.data?.bookings[0] ?? null,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { NAVIGATION_PROBE_WINDOW_MONTHS } from "../lib/constants";
|
||||
import type { BookingListingStatus } from "../types";
|
||||
|
||||
interface UseNearestPastBookingProps {
|
||||
currentWeekStart: Dayjs;
|
||||
filters: {
|
||||
statuses: BookingListingStatus[];
|
||||
userIds?: number[];
|
||||
};
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe hook to find the nearest past booking before the current week.
|
||||
* Used for calendar view navigation to determine if there are any bookings
|
||||
* in the past and to jump directly to the week containing that booking.
|
||||
*
|
||||
* Uses a broad date range (NAVIGATION_PROBE_WINDOW_MONTHS) but only fetches
|
||||
* 1 booking (the nearest one) to minimize data transfer.
|
||||
* Uses descending sort to get the closest past booking first.
|
||||
*/
|
||||
export function useNearestPastBooking({
|
||||
currentWeekStart,
|
||||
filters,
|
||||
enabled = true,
|
||||
}: UseNearestPastBookingProps) {
|
||||
// Search from NAVIGATION_PROBE_WINDOW_MONTHS months ago to the start of current week
|
||||
const afterDate = currentWeekStart.subtract(NAVIGATION_PROBE_WINDOW_MONTHS, "month").startOf("day");
|
||||
const beforeDate = currentWeekStart.startOf("day");
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
filters: {
|
||||
statuses: filters.statuses,
|
||||
userIds: filters.userIds,
|
||||
afterStartDate: afterDate.toISOString(),
|
||||
beforeEndDate: beforeDate.toISOString(),
|
||||
},
|
||||
limit: 1, // Only need the nearest one
|
||||
sort: { sortStart: "desc" }, // Get the closest past booking first
|
||||
},
|
||||
{
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
nearestBooking: query.data?.bookings[0] ?? null,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Number of months to look ahead/behind when probing for nearest bookings
|
||||
* in calendar view navigation. This determines how far the navigation
|
||||
* probe queries will search for bookings.
|
||||
*/
|
||||
export const NAVIGATION_PROBE_WINDOW_MONTHS = 3;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createParser } from "nuqs";
|
||||
|
||||
export const viewParser = createParser({
|
||||
parse: (value: string) => {
|
||||
if (value === "calendar") return "calendar";
|
||||
return "list";
|
||||
},
|
||||
serialize: (value: "list" | "calendar") => value,
|
||||
});
|
||||
|
||||
export type BookingView = "list" | "calendar";
|
||||
@@ -1,27 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { createStore, useStore } from "zustand";
|
||||
|
||||
import { useSelectedBookingUid } from "../hooks/useSelectedBookingUid";
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
export type PendingSelectionType = "first" | "last" | null;
|
||||
|
||||
/**
|
||||
* Capabilities interface for view-specific navigation logic.
|
||||
* This allows different views (list/calendar) to provide their own
|
||||
* navigation implementations without the core store knowing about them.
|
||||
*/
|
||||
export interface NavigationCapabilities {
|
||||
/**
|
||||
* Check if navigation to the previous period is possible
|
||||
* (e.g., previous page in list view, previous week in calendar view)
|
||||
*/
|
||||
canNavigateToPreviousPeriod: () => boolean;
|
||||
|
||||
/**
|
||||
* Check if navigation to the next period is possible
|
||||
* (e.g., next page in list view, next week in calendar view)
|
||||
*/
|
||||
canNavigateToNextPeriod: () => boolean;
|
||||
|
||||
/**
|
||||
* Request navigation to the previous period.
|
||||
* This should trigger the view state update (e.g., page index change).
|
||||
* The parent component will handle fetching data and updating bookings.
|
||||
*/
|
||||
requestPreviousPeriod: () => void;
|
||||
|
||||
/**
|
||||
* Request navigation to the next period.
|
||||
* This should trigger the view state update (e.g., page index change).
|
||||
* The parent component will handle fetching data and updating bookings.
|
||||
*/
|
||||
requestNextPeriod: () => void;
|
||||
}
|
||||
|
||||
interface BookingDetailsSheetStore {
|
||||
// State
|
||||
// Core state (view-agnostic)
|
||||
selectedBookingUid: string | null;
|
||||
bookings: BookingOutput[];
|
||||
isTransitioning: boolean;
|
||||
pendingSelection: PendingSelectionType;
|
||||
|
||||
// Actions
|
||||
// Injected capabilities (provided by adapters)
|
||||
capabilities: NavigationCapabilities | null;
|
||||
|
||||
// Core actions
|
||||
setSelectedBookingUid: (uid: string | null) => void;
|
||||
setBookings: (bookings: BookingOutput[]) => void;
|
||||
setCapabilities: (capabilities: NavigationCapabilities | null) => void;
|
||||
clearSelection: () => void;
|
||||
clearPendingSelection: () => void;
|
||||
setIsTransitioning: (isTransitioning: boolean) => void;
|
||||
|
||||
// Computed getters (used via selectors)
|
||||
// Navigation methods (delegates to capabilities)
|
||||
navigateNext: () => Promise<void>;
|
||||
navigatePrevious: () => Promise<void>;
|
||||
|
||||
// Simple getters (no view-specific logic)
|
||||
getSelectedBooking: () => BookingOutput | null;
|
||||
getNextBookingUid: () => string | null;
|
||||
getPreviousBookingUid: () => string | null;
|
||||
hasNext: () => boolean;
|
||||
hasPrevious: () => boolean;
|
||||
getCurrentIndex: () => number;
|
||||
hasNextInArray: () => boolean;
|
||||
hasPreviousInArray: () => boolean;
|
||||
isFirstInArray: () => boolean;
|
||||
isLastInArray: () => boolean;
|
||||
}
|
||||
|
||||
type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetStore>;
|
||||
@@ -31,53 +79,90 @@ const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) =
|
||||
// Initial state
|
||||
selectedBookingUid: null,
|
||||
bookings: initialBookings,
|
||||
isTransitioning: false,
|
||||
pendingSelection: null,
|
||||
capabilities: null,
|
||||
|
||||
// Actions
|
||||
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
|
||||
setSelectedBookingUid: (uid) => {
|
||||
set({ selectedBookingUid: uid });
|
||||
},
|
||||
setBookings: (bookings) => set({ bookings }),
|
||||
setCapabilities: (capabilities) => set({ capabilities }),
|
||||
clearSelection: () => set({ selectedBookingUid: null }),
|
||||
clearPendingSelection: () => set({ pendingSelection: null }),
|
||||
setIsTransitioning: (isTransitioning) => set({ isTransitioning }),
|
||||
|
||||
// Computed getters
|
||||
// Core getters
|
||||
getSelectedBooking: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return null;
|
||||
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
|
||||
},
|
||||
|
||||
getNextBookingUid: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return null;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null;
|
||||
|
||||
return state.bookings[currentIndex + 1].uid;
|
||||
getCurrentIndex: () => {
|
||||
const { bookings, selectedBookingUid } = get();
|
||||
if (!selectedBookingUid) return -1;
|
||||
return bookings.findIndex((b) => b.uid === selectedBookingUid);
|
||||
},
|
||||
|
||||
getPreviousBookingUid: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return null;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
if (currentIndex <= 0) return null;
|
||||
|
||||
return state.bookings[currentIndex - 1].uid;
|
||||
hasNextInArray: () => {
|
||||
const { bookings, getCurrentIndex } = get();
|
||||
const index = getCurrentIndex();
|
||||
return index >= 0 && index < bookings.length - 1;
|
||||
},
|
||||
|
||||
hasNext: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return false;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
return currentIndex >= 0 && currentIndex < state.bookings.length - 1;
|
||||
hasPreviousInArray: () => {
|
||||
const { getCurrentIndex } = get();
|
||||
return getCurrentIndex() > 0;
|
||||
},
|
||||
|
||||
hasPrevious: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return false;
|
||||
isFirstInArray: () => get().getCurrentIndex() === 0,
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
return currentIndex > 0;
|
||||
isLastInArray: () => {
|
||||
const { bookings, getCurrentIndex } = get();
|
||||
return getCurrentIndex() === bookings.length - 1;
|
||||
},
|
||||
|
||||
// Navigation methods
|
||||
navigateNext: async () => {
|
||||
const state = get();
|
||||
|
||||
// Try navigating within current array first
|
||||
if (state.hasNextInArray()) {
|
||||
const nextIndex = state.getCurrentIndex() + 1;
|
||||
set({ selectedBookingUid: state.bookings[nextIndex].uid });
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to navigate to next period
|
||||
if (!state.capabilities?.canNavigateToNextPeriod()) return;
|
||||
|
||||
// Set pending selection to "first" and mark as transitioning
|
||||
set({ isTransitioning: true, pendingSelection: "first" });
|
||||
// Trigger page/week change synchronously - the parent component will handle the data fetch
|
||||
state.capabilities.requestNextPeriod();
|
||||
},
|
||||
|
||||
navigatePrevious: async () => {
|
||||
const state = get();
|
||||
|
||||
// Try navigating within current array first
|
||||
if (state.hasPreviousInArray()) {
|
||||
const prevIndex = state.getCurrentIndex() - 1;
|
||||
set({ selectedBookingUid: state.bookings[prevIndex].uid });
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to navigate to previous period
|
||||
if (!state.capabilities?.canNavigateToPreviousPeriod()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set pending selection to "last" and mark as transitioning
|
||||
set({ isTransitioning: true, pendingSelection: "last" });
|
||||
// Trigger page/week change synchronously - the parent component will handle the data fetch
|
||||
state.capabilities.requestPreviousPeriod();
|
||||
},
|
||||
}));
|
||||
};
|
||||
@@ -87,20 +172,34 @@ const BookingDetailsSheetStoreContext = React.createContext<BookingDetailsSheetS
|
||||
export function BookingDetailsSheetStoreProvider({
|
||||
children,
|
||||
bookings,
|
||||
capabilities,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
bookings: BookingOutput[];
|
||||
capabilities?: NavigationCapabilities | null;
|
||||
}) {
|
||||
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
|
||||
const [store] = useState(() => createBookingDetailsSheetStore(bookings));
|
||||
const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid();
|
||||
const previousBookingsRef = useRef<BookingOutput[]>(bookings);
|
||||
|
||||
// Update bookings in store
|
||||
useEffect(() => {
|
||||
const previousBookings = previousBookingsRef.current;
|
||||
const hasBookingsChanged = bookings !== previousBookings;
|
||||
|
||||
if (!hasBookingsChanged) return;
|
||||
|
||||
// Update bookings when they change
|
||||
React.useEffect(() => {
|
||||
store.getState().setBookings(bookings);
|
||||
previousBookingsRef.current = bookings;
|
||||
}, [bookings, store]);
|
||||
|
||||
// Update capabilities when they change
|
||||
useEffect(() => {
|
||||
store.getState().setCapabilities(capabilities ?? null);
|
||||
}, [capabilities, store]);
|
||||
|
||||
// Sync Store → URL
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
const storeUid = state.selectedBookingUid;
|
||||
if (storeUid !== selectedBookingUidFromUrl) {
|
||||
@@ -112,7 +211,7 @@ export function BookingDetailsSheetStoreProvider({
|
||||
}, [selectedBookingUidFromUrl, setSelectedBookingUidToUrl, store]);
|
||||
|
||||
// Sync URL → Store
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const currentStoreUid = store.getState().selectedBookingUid;
|
||||
if (currentStoreUid !== selectedBookingUidFromUrl) {
|
||||
store.getState().setSelectedBookingUid(selectedBookingUidFromUrl);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||
import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/lib/getCalendarLinks";
|
||||
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
|
||||
import { isWithinMinimumRescheduleNotice as isWithinMinimumRescheduleNoticeUtil } from "@calcom/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice";
|
||||
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
|
||||
@@ -52,6 +53,7 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { EmailInput, TextArea } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { useCalcomTheme } from "@calcom/ui/styles";
|
||||
import CancelBooking from "@calcom/web/components/booking/CancelBooking";
|
||||
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
|
||||
@@ -385,6 +387,15 @@ export default function Success(props: PageProps) {
|
||||
const canCancel = !eventType?.disableCancelling;
|
||||
const canReschedule = !eventType?.disableRescheduling;
|
||||
|
||||
// Check if reschedule should be disabled due to minimum reschedule notice
|
||||
// Use server-side computed isHost prop instead of client-side computation
|
||||
const isWithinMinimumRescheduleNotice = isHost
|
||||
? false // Organizers can always reschedule
|
||||
: isWithinMinimumRescheduleNoticeUtil(
|
||||
bookingInfo?.startTime ?? null,
|
||||
eventType?.minimumRescheduleNotice ?? null
|
||||
);
|
||||
const isRescheduleDisabled = !canReschedule || isWithinMinimumRescheduleNotice;
|
||||
const paymentStatusMessage = usePaymentStatus({
|
||||
bookingStatus: bookingInfo.status,
|
||||
startTime: bookingInfo.startTime,
|
||||
@@ -489,7 +500,7 @@ export default function Success(props: PageProps) {
|
||||
{!isFeedbackMode && (
|
||||
<>
|
||||
<div
|
||||
className={classNames(isRoundRobin && "min-w-32 min-h-24 relative mx-auto h-24 w-32")}>
|
||||
className={classNames(isRoundRobin && "relative mx-auto h-24 min-h-24 w-32 min-w-32")}>
|
||||
{isRoundRobin && bookingInfo.user && (
|
||||
<Avatar
|
||||
className="mx-auto flex items-center justify-center"
|
||||
@@ -539,7 +550,7 @@ export default function Success(props: PageProps) {
|
||||
(bookingInfo.status === BookingStatus.CANCELLED ||
|
||||
bookingInfo.status === BookingStatus.REJECTED) && <h4>{paymentStatusMessage}</h4>}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left sm:gap-x-0 rtl:text-right">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
@@ -810,47 +821,56 @@ export default function Success(props: PageProps) {
|
||||
canCancelOrReschedule &&
|
||||
(!isCancellationMode ? (
|
||||
<>
|
||||
<hr className="border-subtle mb-8" />
|
||||
<div className="text-center last:pb-0">
|
||||
<span className="text-emphasis ltr:mr-2 rtl:ml-2">
|
||||
{t("need_to_make_a_change")}
|
||||
</span>
|
||||
|
||||
{/* Only show section if there's at least one actionable option */}
|
||||
{((!props.recurringBookings &&
|
||||
(!isBookingInPast || eventType.allowReschedulingPastBookings) &&
|
||||
canReschedule) ||
|
||||
(!isBookingInPast && canCancel)) && (
|
||||
<>
|
||||
{!props.recurringBookings &&
|
||||
(!isBookingInPast || eventType.allowReschedulingPastBookings) &&
|
||||
canReschedule && (
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
|
||||
currentUserEmail
|
||||
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
|
||||
: ""
|
||||
}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
</span>
|
||||
{!isBookingInPast && canCancel && (
|
||||
<span className="mx-2">{t("or_lowercase")}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<hr className="border-subtle mb-8" />
|
||||
<div className="text-center last:pb-0">
|
||||
<span className="text-emphasis ltr:mr-2 rtl:ml-2">
|
||||
{t("need_to_make_a_change")}
|
||||
</span>
|
||||
|
||||
{!isBookingInPast && canCancel && (
|
||||
<button
|
||||
data-testid="cancel"
|
||||
className={classNames(
|
||||
"text-default underline",
|
||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||
<>
|
||||
{!props.recurringBookings &&
|
||||
(!isBookingInPast || eventType.allowReschedulingPastBookings) &&
|
||||
canReschedule &&
|
||||
!isRescheduleDisabled && (
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
|
||||
currentUserEmail
|
||||
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
|
||||
: ""
|
||||
}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
</span>
|
||||
{!isBookingInPast && canCancel && (
|
||||
<span className="mx-2">{t("or_lowercase")}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isBookingInPast && canCancel && (
|
||||
<button
|
||||
data-testid="cancel"
|
||||
className={classNames(
|
||||
"text-default underline",
|
||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||
)}
|
||||
onClick={() => setIsCancellationMode(true)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
)}
|
||||
onClick={() => setIsCancellationMode(true)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -1067,7 +1087,7 @@ export default function Success(props: PageProps) {
|
||||
</div>
|
||||
{isGmail && !isFeedbackMode && (
|
||||
<Alert
|
||||
className="main -mb-20 mt-4 inline-block ltr:text-left rtl:text-right sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
className="main -mb-20 mt-4 inline-block sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle ltr:text-left rtl:text-right"
|
||||
severity="warning"
|
||||
message={
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { DataTableProvider, type SystemFilterSegment, ColumnFilterType } from "@calcom/features/data-table";
|
||||
@@ -11,8 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
import { BookingListContainer } from "../components/BookingListContainer";
|
||||
import { useBookingsView } from "../hooks/useBookingsView";
|
||||
import type { validStatuses } from "../lib/validStatuses";
|
||||
import { viewParser } from "../lib/viewParser";
|
||||
|
||||
const BookingCalendarContainer = dynamic(() =>
|
||||
import("../components/BookingCalendarContainer").then((mod) => ({
|
||||
@@ -69,9 +68,7 @@ export default function Bookings(props: BookingsProps) {
|
||||
}
|
||||
|
||||
function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsProps) {
|
||||
const [_view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
// Force view to be "list" if calendar view is disabled
|
||||
const view = bookingsV3Enabled ? _view : "list";
|
||||
const [view] = useBookingsView({ bookingsV3Enabled });
|
||||
|
||||
return (
|
||||
<div className={classNames(view === "calendar" && "-mb-8")}>
|
||||
@@ -83,7 +80,11 @@ function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsPro
|
||||
/>
|
||||
)}
|
||||
{bookingsV3Enabled && view === "calendar" && (
|
||||
<BookingCalendarContainer status={status} permissions={permissions} />
|
||||
<BookingCalendarContainer
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
bookingsV3Enabled={bookingsV3Enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
@@ -13,6 +13,7 @@ type OnboardingTeamsBrowserViewProps = {
|
||||
organizationLogo?: string | null;
|
||||
organizationName?: string;
|
||||
organizationBanner?: string | null;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export const OnboardingTeamsBrowserView = ({
|
||||
@@ -20,10 +21,11 @@ export const OnboardingTeamsBrowserView = ({
|
||||
organizationLogo,
|
||||
organizationName,
|
||||
organizationBanner,
|
||||
slug,
|
||||
}: OnboardingTeamsBrowserViewProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useLocale();
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
|
||||
|
||||
// Show placeholder if no teams or all teams are empty
|
||||
const hasValidTeams = teams.some((team) => team.name && team.name.trim().length > 0);
|
||||
@@ -54,9 +56,9 @@ export const OnboardingTeamsBrowserView = ({
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-cal-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
|
||||
<div className="bg-cal-muted flex w-full min-w-0 items-center gap-2 rounded-[32px] px-3 py-2">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-sm font-medium leading-tight">{webappUrl}/teams</p>
|
||||
<p className="text-default truncate text-sm font-medium leading-tight">{displayUrl}/teams</p>
|
||||
</div>
|
||||
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
|
||||
@@ -150,6 +150,7 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
organizationLogo={organizationBrand.logo}
|
||||
organizationName={organizationDetails.name}
|
||||
organizationBanner={organizationBrand.banner}
|
||||
slug={organizationDetails.link}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
|
||||
import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
|
||||
import { CreateOrEditOutOfOfficeEntryModal } from "@calcom/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal";
|
||||
import type { BookingRedirectForm } from "@calcom/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal";
|
||||
import { HolidaysView } from "@calcom/features/settings/outOfOffice/HolidaysView";
|
||||
import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
|
||||
import { OutOfOfficeTab } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup";
|
||||
|
||||
export default function OutOfOfficeView() {
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
@@ -15,6 +17,7 @@ export default function OutOfOfficeView() {
|
||||
|
||||
const params = useCompatSearchParams();
|
||||
const openModalOnStart = !!params?.get("om");
|
||||
const selectedTab = params?.get("type") ?? OutOfOfficeTab.MINE;
|
||||
|
||||
useEffect(() => {
|
||||
if (openModalOnStart) {
|
||||
@@ -37,6 +40,11 @@ export default function OutOfOfficeView() {
|
||||
setCurrentlyEditingOutOfOfficeEntry(null);
|
||||
};
|
||||
|
||||
// Show HolidaysView when holidays tab is selected
|
||||
if (selectedTab === OutOfOfficeTab.HOLIDAYS) {
|
||||
return <HolidaysView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OutOfOfficeEntriesList
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.9.14",
|
||||
"version": "6.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -110,7 +110,7 @@
|
||||
"memory-cache": "^0.2.0",
|
||||
"micro": "^10.0.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"next": "15.5.8",
|
||||
"next": "15.5.9",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-i18next": "^15.4.2",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Before adding new endpoints here
|
||||
|
||||
We're deprecating adding new endpoints in favor of creating tRPC procedures.
|
||||
|
||||
You can learn about [tRPC procedures in the docs](https://trpc.io/docs/v10/procedures).
|
||||
|
||||
You can see our current tRPC procedures in this file `packages/trpc/server/routers/_app.ts`
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "@calcom/features/ee/organizations/api/subteams";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { holidaysRouter } from "@calcom/trpc/server/routers/viewer/holidays/_router";
|
||||
|
||||
export default createNextApiHandler(holidaysRouter);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { insightsRouter } from "@calcom/features/insights/server/trpc-router";
|
||||
import { insightsRouter } from "@calcom/trpc/server/routers/viewer/insights/_router";
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
|
||||
export default createNextApiHandler(insightsRouter);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router";
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { userAdminRouter } from "@calcom/trpc/server/routers/viewer/users/_router";
|
||||
|
||||
export default createNextApiHandler(userAdminRouter);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<svg width="171" height="35" viewBox="0 0 171 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dd_2049_5880)">
|
||||
<path d="M3.5 11.5C3.5 5.97715 7.97715 1.5 13.5 1.5H156.959C162.482 1.5 166.959 5.97715 166.959 11.5V19.5C166.959 25.0228 162.482 29.5 156.959 29.5H13.5C7.97717 29.5 3.5 25.0228 3.5 19.5V11.5Z" fill="white"/>
|
||||
<path d="M3.5 11.5C3.5 5.97715 7.97715 1.5 13.5 1.5H156.959C162.482 1.5 166.959 5.97715 166.959 11.5V19.5C166.959 25.0228 162.482 29.5 156.959 29.5H13.5C7.97717 29.5 3.5 25.0228 3.5 19.5V11.5Z" stroke="#D3D3D3"/>
|
||||
<path d="M145.269 13.239H147.131V14.303C147.514 13.4817 148.256 13.071 149.357 13.071C149.871 13.071 150.333 13.1923 150.743 13.435C151.163 13.6683 151.49 14.0043 151.723 14.443C151.975 13.9857 152.293 13.645 152.675 13.421C153.067 13.1877 153.571 13.071 154.187 13.071C154.71 13.071 155.181 13.197 155.601 13.449C156.021 13.6917 156.353 14.0417 156.595 14.499C156.838 14.947 156.959 15.4697 156.959 16.067V20.519H155.069V16.529C155.069 15.9223 154.939 15.4697 154.677 15.171C154.416 14.8723 154.061 14.723 153.613 14.723C153.137 14.723 152.769 14.8583 152.507 15.129C152.255 15.3997 152.129 15.8663 152.129 16.529V20.519H150.239V16.529C150.239 15.9223 150.099 15.4697 149.819 15.171C149.539 14.8723 149.157 14.723 148.671 14.723C148.242 14.723 147.883 14.877 147.593 15.185C147.304 15.4837 147.159 15.9317 147.159 16.529V20.519H145.269V13.239Z" fill="#070A0D"/>
|
||||
<path d="M140.555 20.701C139.817 20.701 139.155 20.533 138.567 20.197C137.979 19.8517 137.517 19.3897 137.181 18.811C136.845 18.223 136.677 17.5837 136.677 16.893C136.677 16.2023 136.845 15.563 137.181 14.975C137.517 14.387 137.979 13.925 138.567 13.589C139.155 13.2437 139.817 13.071 140.555 13.071C141.292 13.071 141.955 13.2437 142.543 13.589C143.131 13.925 143.593 14.387 143.929 14.975C144.265 15.563 144.433 16.2023 144.433 16.893C144.433 17.5837 144.265 18.223 143.929 18.811C143.593 19.3897 143.131 19.8517 142.543 20.197C141.955 20.533 141.292 20.701 140.555 20.701ZM138.567 16.893C138.567 17.2757 138.646 17.6257 138.805 17.943C138.973 18.2603 139.206 18.5123 139.505 18.699C139.813 18.8857 140.163 18.979 140.555 18.979C140.947 18.979 141.292 18.8857 141.591 18.699C141.899 18.5123 142.132 18.2603 142.291 17.943C142.459 17.6257 142.543 17.2757 142.543 16.893C142.543 16.5103 142.459 16.1603 142.291 15.843C142.123 15.5257 141.889 15.2737 141.591 15.087C141.292 14.891 140.947 14.793 140.555 14.793C140.163 14.793 139.817 14.891 139.519 15.087C139.22 15.2737 138.987 15.5257 138.819 15.843C138.651 16.1603 138.567 16.5103 138.567 16.893Z" fill="#070A0D"/>
|
||||
<path d="M133.552 20.701C132.87 20.701 132.24 20.533 131.662 20.197C131.092 19.8517 130.64 19.385 130.304 18.797C129.968 18.209 129.8 17.5697 129.8 16.879C129.8 16.179 129.968 15.5397 130.304 14.961C130.64 14.373 131.106 13.911 131.704 13.575C132.301 13.2297 132.968 13.057 133.706 13.057C134.35 13.057 134.928 13.1783 135.442 13.421C135.955 13.6543 136.366 13.9857 136.674 14.415L135.218 15.605C135.05 15.353 134.83 15.1523 134.56 15.003C134.289 14.8537 133.995 14.779 133.678 14.779C133.286 14.779 132.94 14.877 132.642 15.073C132.343 15.2597 132.11 15.5117 131.942 15.829C131.774 16.1463 131.69 16.4963 131.69 16.879C131.69 17.2523 131.774 17.6023 131.942 17.929C132.119 18.2463 132.362 18.503 132.67 18.699C132.978 18.8857 133.328 18.979 133.72 18.979C134.065 18.979 134.364 18.9043 134.616 18.755C134.868 18.6057 135.096 18.391 135.302 18.111L136.674 19.301C135.946 20.2343 134.905 20.701 133.552 20.701Z" fill="#070A0D"/>
|
||||
<path d="M128.244 20.701C127.889 20.701 127.591 20.5797 127.348 20.337C127.105 20.0943 126.984 19.805 126.984 19.469C126.984 19.133 127.105 18.8483 127.348 18.615C127.591 18.3723 127.889 18.251 128.244 18.251C128.599 18.251 128.893 18.3723 129.126 18.615C129.369 18.8483 129.49 19.133 129.49 19.469C129.49 19.8143 129.369 20.1083 129.126 20.351C128.893 20.5843 128.599 20.701 128.244 20.701Z" fill="#070A0D"/>
|
||||
<path d="M124.228 10.299H126.118V20.519H124.228V10.299Z" fill="#070A0D"/>
|
||||
<path d="M118.919 20.701C118.266 20.701 117.664 20.533 117.113 20.197C116.563 19.8517 116.124 19.3897 115.797 18.811C115.48 18.2323 115.321 17.5977 115.321 16.907C115.321 16.2163 115.48 15.5817 115.797 15.003C116.124 14.415 116.563 13.9483 117.113 13.603C117.664 13.2577 118.266 13.085 118.919 13.085C119.517 13.085 119.997 13.197 120.361 13.421C120.735 13.645 121.024 13.967 121.229 14.387V13.239H123.105V20.519H121.271V19.329C121.057 19.7677 120.763 20.1083 120.389 20.351C120.025 20.5843 119.535 20.701 118.919 20.701ZM117.211 16.893C117.211 17.2663 117.295 17.6163 117.463 17.943C117.641 18.2603 117.879 18.517 118.177 18.713C118.485 18.8997 118.835 18.993 119.227 18.993C119.629 18.993 119.983 18.8997 120.291 18.713C120.609 18.5263 120.851 18.2743 121.019 17.957C121.187 17.6397 121.271 17.2897 121.271 16.907C121.271 16.5243 121.187 16.1743 121.019 15.857C120.851 15.5303 120.609 15.2737 120.291 15.087C119.983 14.891 119.629 14.793 119.227 14.793C118.835 14.793 118.485 14.891 118.177 15.087C117.869 15.2737 117.631 15.5257 117.463 15.843C117.295 16.1603 117.211 16.5103 117.211 16.893Z" fill="#070A0D"/>
|
||||
<path d="M111.642 20.701C110.69 20.701 109.822 20.477 109.038 20.029C108.263 19.5717 107.652 18.9557 107.204 18.181C106.756 17.4063 106.532 16.557 106.532 15.633C106.532 14.6997 106.747 13.8457 107.176 13.071C107.615 12.287 108.221 11.671 108.996 11.223C109.771 10.7657 110.653 10.537 111.642 10.537C112.435 10.537 113.117 10.663 113.686 10.915C114.265 11.1577 114.778 11.545 115.226 12.077L113.84 13.281C113.252 12.665 112.519 12.357 111.642 12.357C111.026 12.357 110.485 12.5063 110.018 12.805C109.551 13.0943 109.192 13.491 108.94 13.995C108.688 14.4897 108.562 15.0357 108.562 15.633C108.562 16.2303 108.688 16.7763 108.94 17.271C109.201 17.7657 109.565 18.1577 110.032 18.447C110.508 18.7363 111.059 18.881 111.684 18.881C112.599 18.881 113.345 18.5637 113.924 17.929L115.352 19.147C114.96 19.623 114.447 20.001 113.812 20.281C113.187 20.561 112.463 20.701 111.642 20.701Z" fill="#070A0D"/>
|
||||
<path d="M96.1837 20.519V10.719H97.6677V14.527H97.7517C97.929 14.2283 98.1857 13.9623 98.5217 13.729C98.867 13.4957 99.301 13.379 99.8237 13.379C100.309 13.379 100.743 13.491 101.126 13.715C101.518 13.939 101.826 14.2517 102.05 14.653C102.283 15.0543 102.4 15.521 102.4 16.053V20.519H100.916V16.403C100.916 15.8803 100.78 15.4697 100.51 15.171C100.239 14.863 99.861 14.709 99.3757 14.709C98.853 14.709 98.4377 14.863 98.1297 15.171C97.8217 15.4697 97.6677 15.8803 97.6677 16.403V20.519H96.1837Z" fill="#070A0D"/>
|
||||
<path d="M93.6722 20.701C92.9442 20.6917 92.3655 20.4863 91.9362 20.085C91.5162 19.6743 91.3062 19.1143 91.3062 18.405V14.891H89.8362V13.561H91.3062V11.867L92.7902 11.671V13.561H94.9322V14.891H92.7902V18.293C92.7902 18.6663 92.8929 18.9463 93.0982 19.133C93.3129 19.3103 93.5929 19.399 93.9382 19.399C94.1062 19.399 94.2695 19.3943 94.4282 19.385C94.5962 19.3663 94.7642 19.3337 94.9322 19.287V20.561C94.6895 20.617 94.4795 20.6543 94.3022 20.673C94.1249 20.6917 93.9149 20.701 93.6722 20.701Z" fill="#070A0D"/>
|
||||
<path d="M87.7792 12.273C87.5179 12.273 87.2985 12.1843 87.1212 12.007C86.9439 11.8297 86.8552 11.6103 86.8552 11.349C86.8552 11.1063 86.9439 10.8963 87.1212 10.719C87.2985 10.5417 87.5179 10.453 87.7792 10.453C88.0499 10.453 88.2692 10.5417 88.4372 10.719C88.6145 10.8963 88.7032 11.1063 88.7032 11.349C88.7032 11.6103 88.6145 11.8297 88.4372 12.007C88.2692 12.1843 88.0499 12.273 87.7792 12.273ZM87.0372 20.519V13.561H88.5212V20.519H87.0372Z" fill="#070A0D"/>
|
||||
<path d="M76.9554 20.519L74.6734 13.561H76.3954L77.4874 17.621C77.5528 17.873 77.6134 18.1343 77.6694 18.405C77.7348 18.6663 77.7814 18.9137 77.8094 19.147H77.9074C77.9448 18.6523 78.0428 18.139 78.2014 17.607L79.4194 13.561H81.1414L82.3594 17.607C82.4341 17.859 82.4994 18.125 82.5554 18.405C82.6208 18.6757 82.6628 18.923 82.6814 19.147H82.7794C82.7981 18.923 82.8354 18.6757 82.8914 18.405C82.9568 18.125 83.0221 17.8637 83.0874 17.621L84.2074 13.561H85.8174L83.5494 20.519H81.8974L80.6374 16.445C80.5441 16.1557 80.4741 15.8897 80.4274 15.647C80.3808 15.395 80.3434 15.157 80.3154 14.933H80.2174C80.1894 15.1663 80.1521 15.4043 80.1054 15.647C80.0681 15.8897 80.0028 16.1557 79.9094 16.445L78.6914 20.519H76.9554Z" fill="#070A0D"/>
|
||||
<path d="M67.738 20.701C67.0193 20.701 66.3893 20.5423 65.848 20.225C65.3066 19.9077 64.882 19.4783 64.574 18.937C64.2753 18.3863 64.126 17.761 64.126 17.061C64.126 16.3237 64.28 15.6797 64.588 15.129C64.896 14.5783 65.316 14.149 65.848 13.841C66.3893 13.533 67.0053 13.379 67.696 13.379C68.3773 13.379 68.97 13.533 69.474 13.841C69.978 14.1397 70.3653 14.5597 70.636 15.101C70.916 15.633 71.056 16.2537 71.056 16.963C71.056 17.0563 71.0513 17.1543 71.042 17.257C71.042 17.3597 71.0326 17.4437 71.014 17.509H65.652C65.736 18.041 65.9646 18.4843 66.338 18.839C66.7113 19.1843 67.1873 19.357 67.766 19.357C68.6246 19.357 69.2686 19.063 69.698 18.475L70.706 19.427C70.3886 19.819 69.9733 20.1317 69.46 20.365C68.956 20.589 68.382 20.701 67.738 20.701ZM67.668 14.639C67.1453 14.639 66.702 14.7837 66.338 15.073C65.9833 15.3623 65.7593 15.7823 65.666 16.333H69.544C69.4693 15.8103 69.2733 15.3997 68.956 15.101C68.648 14.793 68.2186 14.639 67.668 14.639Z" fill="#070A0D"/>
|
||||
<path d="M59.0262 20.701C58.5596 20.701 58.1349 20.589 57.7522 20.365C57.3789 20.1317 57.0756 19.8143 56.8422 19.413C56.6182 19.0117 56.5062 18.545 56.5062 18.013V13.561H57.9902V17.663C57.9902 18.1857 58.1256 18.601 58.3962 18.909C58.6762 19.217 59.0356 19.371 59.4743 19.371C59.9596 19.371 60.3516 19.217 60.6502 18.909C60.9582 18.601 61.1122 18.1857 61.1122 17.663V13.561H62.5962V20.519H61.1682V19.539H61.0842C60.9069 19.8377 60.6456 20.1083 60.3002 20.351C59.9642 20.5843 59.5396 20.701 59.0262 20.701Z" fill="#070A0D"/>
|
||||
<path d="M48.4415 20.519V13.561H49.8695V14.541H49.9535C50.1308 14.2423 50.3875 13.9763 50.7235 13.743C51.0688 13.5003 51.5168 13.379 52.0675 13.379C52.5528 13.379 52.9915 13.491 53.3835 13.715C53.7755 13.939 54.0835 14.2563 54.3075 14.667C54.5408 15.0683 54.6575 15.535 54.6575 16.067V20.519H53.1735V16.417C53.1735 15.8943 53.0335 15.479 52.7535 15.171C52.4735 14.863 52.0955 14.709 51.6195 14.709C51.1062 14.709 50.6955 14.863 50.3875 15.171C50.0795 15.479 49.9255 15.8943 49.9255 16.417V20.519H48.4415Z" fill="#070A0D"/>
|
||||
<path d="M45.7108 12.273C45.4495 12.273 45.2302 12.1843 45.0528 12.007C44.8755 11.8297 44.7868 11.6103 44.7868 11.349C44.7868 11.1063 44.8755 10.8963 45.0528 10.719C45.2302 10.5417 45.4495 10.453 45.7108 10.453C45.9815 10.453 46.2008 10.5417 46.3688 10.719C46.5462 10.8963 46.6348 11.1063 46.6348 11.349C46.6348 11.6103 46.5462 11.8297 46.3688 12.007C46.2008 12.1843 45.9815 12.273 45.7108 12.273ZM44.9688 20.519V13.561H46.4528V20.519H44.9688Z" fill="#070A0D"/>
|
||||
<path d="M42.4574 20.701C41.7294 20.6917 41.1507 20.4863 40.7214 20.085C40.3014 19.6743 40.0914 19.1143 40.0914 18.405V14.891H38.6214V13.561H40.0914V11.867L41.5754 11.671V13.561H43.7174V14.891H41.5754V18.293C41.5754 18.6663 41.678 18.9463 41.8834 19.133C42.098 19.3103 42.378 19.399 42.7234 19.399C42.8914 19.399 43.0547 19.3943 43.2134 19.385C43.3814 19.3663 43.5494 19.3337 43.7174 19.287V20.561C43.4747 20.617 43.2647 20.6543 43.0874 20.673C42.91 20.6917 42.7 20.701 42.4574 20.701Z" fill="#070A0D"/>
|
||||
<path d="M31.3516 20.519V13.561H32.7796V14.541H32.8636C33.041 14.2423 33.2976 13.9763 33.6336 13.743C33.979 13.5003 34.427 13.379 34.9776 13.379C35.463 13.379 35.9016 13.491 36.2936 13.715C36.6856 13.939 36.9936 14.2563 37.2176 14.667C37.451 15.0683 37.5676 15.535 37.5676 16.067V20.519H36.0836V16.417C36.0836 15.8943 35.9436 15.479 35.6636 15.171C35.3836 14.863 35.0056 14.709 34.5296 14.709C34.0163 14.709 33.6056 14.863 33.2976 15.171C32.9896 15.479 32.8356 15.8943 32.8356 16.417V20.519H31.3516Z" fill="#070A0D"/>
|
||||
<path d="M26.2298 20.701C25.5392 20.701 24.9232 20.5423 24.3818 20.225C23.8405 19.9077 23.4112 19.4737 23.0938 18.923C22.7858 18.3723 22.6318 17.747 22.6318 17.047C22.6318 16.3377 22.7858 15.7077 23.0938 15.157C23.4112 14.6063 23.8405 14.1723 24.3818 13.855C24.9232 13.5377 25.5392 13.379 26.2298 13.379C26.9205 13.379 27.5365 13.5377 28.0778 13.855C28.6285 14.1723 29.0578 14.6063 29.3658 15.157C29.6738 15.7077 29.8278 16.333 29.8278 17.033C29.8278 17.7423 29.6738 18.3723 29.3658 18.923C29.0578 19.4737 28.6285 19.9077 28.0778 20.225C27.5365 20.5423 26.9205 20.701 26.2298 20.701ZM26.2298 19.343C26.8645 19.343 27.3732 19.1283 27.7558 18.699C28.1478 18.2697 28.3438 17.719 28.3438 17.047C28.3438 16.3657 28.1478 15.8103 27.7558 15.381C27.3732 14.9517 26.8645 14.737 26.2298 14.737C25.5952 14.737 25.0818 14.9517 24.6898 15.381C24.3072 15.8103 24.1158 16.3657 24.1158 17.047C24.1158 17.719 24.3072 18.2697 24.6898 18.699C25.0818 19.1283 25.5952 19.343 26.2298 19.343Z" fill="#070A0D"/>
|
||||
<path d="M18.568 20.701C17.8493 20.701 17.182 20.5703 16.566 20.309C15.95 20.0477 15.4133 19.6883 14.956 19.231C14.4987 18.7643 14.1393 18.2277 13.878 17.621C13.626 17.005 13.5 16.3423 13.5 15.633C13.5 14.9237 13.626 14.261 13.878 13.645C14.13 13.029 14.48 12.4877 14.928 12.021C15.3853 11.5543 15.922 11.1903 16.538 10.929C17.154 10.6677 17.8307 10.537 18.568 10.537C19.296 10.537 19.9633 10.663 20.57 10.915C21.186 11.167 21.718 11.559 22.166 12.091L21.06 13.099C20.7333 12.7163 20.3647 12.441 19.954 12.273C19.5433 12.0957 19.0813 12.007 18.568 12.007C17.8773 12.007 17.2707 12.1657 16.748 12.483C16.2253 12.8003 15.8193 13.2343 15.53 13.785C15.2407 14.3263 15.096 14.9423 15.096 15.633C15.096 16.3237 15.2453 16.9397 15.544 17.481C15.852 18.0223 16.2673 18.4517 16.79 18.769C17.322 19.077 17.9287 19.231 18.61 19.231C19.1327 19.231 19.5947 19.1423 19.996 18.965C20.4067 18.7783 20.7707 18.503 21.088 18.139L22.222 19.105C21.8207 19.581 21.3167 19.9683 20.71 20.267C20.1033 20.5563 19.3893 20.701 18.568 20.701Z" fill="#070A0D"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dd_2049_5880" x="0" y="0" width="170.459" height="35" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="1" operator="erode" in="SourceAlpha" result="effect1_dropShadow_2049_5880"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2049_5880"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="1.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_2049_5880" result="effect2_dropShadow_2049_5880"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_2049_5880" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,61 @@
|
||||
<svg width="182" height="45" viewBox="0 0 182 45" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dddii_2049_5871)">
|
||||
<path d="M7.5 14.5C7.5 8.97715 11.9772 4.5 17.5 4.5H161.807C167.33 4.5 171.807 8.97715 171.807 14.5V22.5C171.807 28.0228 167.33 32.5 161.807 32.5H17.5C11.9772 32.5 7.5 28.0228 7.5 22.5V14.5Z" fill="#1D2836"/>
|
||||
<path d="M17.5 4.5V5H161.807V4.5V4H17.5V4.5ZM171.807 14.5H171.307V22.5H171.807H172.307V14.5H171.807ZM161.807 32.5V32H17.5V32.5V33H161.807V32.5ZM7.5 22.5H8V14.5H7.5H7V22.5H7.5ZM17.5 32.5V32C12.2533 32 8 27.7467 8 22.5H7.5H7C7 28.299 11.701 33 17.5 33V32.5ZM171.807 22.5H171.307C171.307 27.7467 167.054 32 161.807 32V32.5V33C167.606 33 172.307 28.299 172.307 22.5H171.807ZM161.807 4.5V5C167.054 5 171.307 9.2533 171.307 14.5H171.807H172.307C172.307 8.70101 167.606 4 161.807 4V4.5ZM17.5 4.5V4C11.701 4 7 8.70101 7 14.5H7.5H8C8 9.2533 12.2533 5 17.5 5V4.5Z" fill="#1D2836"/>
|
||||
<path d="M150.117 16.239H151.979V17.303C152.362 16.4817 153.104 16.071 154.205 16.071C154.718 16.071 155.18 16.1923 155.591 16.435C156.011 16.6683 156.338 17.0043 156.571 17.443C156.823 16.9857 157.14 16.645 157.523 16.421C157.915 16.1877 158.419 16.071 159.035 16.071C159.558 16.071 160.029 16.197 160.449 16.449C160.869 16.6917 161.2 17.0417 161.443 17.499C161.686 17.947 161.807 18.4697 161.807 19.067V23.519H159.917V19.529C159.917 18.9223 159.786 18.4697 159.525 18.171C159.264 17.8723 158.909 17.723 158.461 17.723C157.985 17.723 157.616 17.8583 157.355 18.129C157.103 18.3997 156.977 18.8663 156.977 19.529V23.519H155.087V19.529C155.087 18.9223 154.947 18.4697 154.667 18.171C154.387 17.8723 154.004 17.723 153.519 17.723C153.09 17.723 152.73 17.877 152.441 18.185C152.152 18.4837 152.007 18.9317 152.007 19.529V23.519H150.117V16.239Z" fill="white"/>
|
||||
<path d="M145.402 23.701C144.665 23.701 144.002 23.533 143.414 23.197C142.826 22.8517 142.364 22.3897 142.028 21.811C141.692 21.223 141.524 20.5837 141.524 19.893C141.524 19.2023 141.692 18.563 142.028 17.975C142.364 17.387 142.826 16.925 143.414 16.589C144.002 16.2437 144.665 16.071 145.402 16.071C146.14 16.071 146.802 16.2437 147.39 16.589C147.978 16.925 148.44 17.387 148.776 17.975C149.112 18.563 149.28 19.2023 149.28 19.893C149.28 20.5837 149.112 21.223 148.776 21.811C148.44 22.3897 147.978 22.8517 147.39 23.197C146.802 23.533 146.14 23.701 145.402 23.701ZM143.414 19.893C143.414 20.2757 143.494 20.6257 143.652 20.943C143.82 21.2603 144.054 21.5123 144.352 21.699C144.66 21.8857 145.01 21.979 145.402 21.979C145.794 21.979 146.14 21.8857 146.438 21.699C146.746 21.5123 146.98 21.2603 147.138 20.943C147.306 20.6257 147.39 20.2757 147.39 19.893C147.39 19.5103 147.306 19.1603 147.138 18.843C146.97 18.5257 146.737 18.2737 146.438 18.087C146.14 17.891 145.794 17.793 145.402 17.793C145.01 17.793 144.665 17.891 144.366 18.087C144.068 18.2737 143.834 18.5257 143.666 18.843C143.498 19.1603 143.414 19.5103 143.414 19.893Z" fill="white"/>
|
||||
<path d="M138.399 23.701C137.718 23.701 137.088 23.533 136.509 23.197C135.94 22.8517 135.487 22.385 135.151 21.797C134.815 21.209 134.647 20.5697 134.647 19.879C134.647 19.179 134.815 18.5397 135.151 17.961C135.487 17.373 135.954 16.911 136.551 16.575C137.149 16.2297 137.816 16.057 138.553 16.057C139.197 16.057 139.776 16.1783 140.289 16.421C140.803 16.6543 141.213 16.9857 141.521 17.415L140.065 18.605C139.897 18.353 139.678 18.1523 139.407 18.003C139.137 17.8537 138.843 17.779 138.525 17.779C138.133 17.779 137.788 17.877 137.489 18.073C137.191 18.2597 136.957 18.5117 136.789 18.829C136.621 19.1463 136.537 19.4963 136.537 19.879C136.537 20.2523 136.621 20.6023 136.789 20.929C136.967 21.2463 137.209 21.503 137.517 21.699C137.825 21.8857 138.175 21.979 138.567 21.979C138.913 21.979 139.211 21.9043 139.463 21.755C139.715 21.6057 139.944 21.391 140.149 21.111L141.521 22.301C140.793 23.2343 139.753 23.701 138.399 23.701Z" fill="white"/>
|
||||
<path d="M133.092 23.701C132.737 23.701 132.438 23.5797 132.196 23.337C131.953 23.0943 131.832 22.805 131.832 22.469C131.832 22.133 131.953 21.8483 132.196 21.615C132.438 21.3723 132.737 21.251 133.092 21.251C133.446 21.251 133.74 21.3723 133.974 21.615C134.216 21.8483 134.338 22.133 134.338 22.469C134.338 22.8143 134.216 23.1083 133.974 23.351C133.74 23.5843 133.446 23.701 133.092 23.701Z" fill="white"/>
|
||||
<path d="M129.076 13.299H130.966V23.519H129.076V13.299Z" fill="white"/>
|
||||
<path d="M123.767 23.701C123.114 23.701 122.512 23.533 121.961 23.197C121.41 22.8517 120.972 22.3897 120.645 21.811C120.328 21.2323 120.169 20.5977 120.169 19.907C120.169 19.2163 120.328 18.5817 120.645 18.003C120.972 17.415 121.41 16.9483 121.961 16.603C122.512 16.2577 123.114 16.085 123.767 16.085C124.364 16.085 124.845 16.197 125.209 16.421C125.582 16.645 125.872 16.967 126.077 17.387V16.239H127.953V23.519H126.119V22.329C125.904 22.7677 125.61 23.1083 125.237 23.351C124.873 23.5843 124.383 23.701 123.767 23.701ZM122.059 19.893C122.059 20.2663 122.143 20.6163 122.311 20.943C122.488 21.2603 122.726 21.517 123.025 21.713C123.333 21.8997 123.683 21.993 124.075 21.993C124.476 21.993 124.831 21.8997 125.139 21.713C125.456 21.5263 125.699 21.2743 125.867 20.957C126.035 20.6397 126.119 20.2897 126.119 19.907C126.119 19.5243 126.035 19.1743 125.867 18.857C125.699 18.5303 125.456 18.2737 125.139 18.087C124.831 17.891 124.476 17.793 124.075 17.793C123.683 17.793 123.333 17.891 123.025 18.087C122.717 18.2737 122.479 18.5257 122.311 18.843C122.143 19.1603 122.059 19.5103 122.059 19.893Z" fill="white"/>
|
||||
<path d="M116.49 23.701C115.538 23.701 114.67 23.477 113.886 23.029C113.111 22.5717 112.5 21.9557 112.052 21.181C111.604 20.4063 111.38 19.557 111.38 18.633C111.38 17.6997 111.594 16.8457 112.024 16.071C112.462 15.287 113.069 14.671 113.844 14.223C114.618 13.7657 115.5 13.537 116.49 13.537C117.283 13.537 117.964 13.663 118.534 13.915C119.112 14.1577 119.626 14.545 120.074 15.077L118.688 16.281C118.1 15.665 117.367 15.357 116.49 15.357C115.874 15.357 115.332 15.5063 114.866 15.805C114.399 16.0943 114.04 16.491 113.788 16.995C113.536 17.4897 113.41 18.0357 113.41 18.633C113.41 19.2303 113.536 19.7763 113.788 20.271C114.049 20.7657 114.413 21.1577 114.88 21.447C115.356 21.7363 115.906 21.881 116.532 21.881C117.446 21.881 118.193 21.5637 118.772 20.929L120.2 22.147C119.808 22.623 119.294 23.001 118.66 23.281C118.034 23.561 117.311 23.701 116.49 23.701Z" fill="white"/>
|
||||
<path d="M100.184 23.519V13.719H101.668V17.527H101.752C101.929 17.2283 102.186 16.9623 102.522 16.729C102.867 16.4957 103.301 16.379 103.824 16.379C104.309 16.379 104.743 16.491 105.126 16.715C105.518 16.939 105.826 17.2517 106.05 17.653C106.283 18.0543 106.4 18.521 106.4 19.053V23.519H104.916V19.403C104.916 18.8803 104.78 18.4697 104.51 18.171C104.239 17.863 103.861 17.709 103.376 17.709C102.853 17.709 102.438 17.863 102.13 18.171C101.822 18.4697 101.668 18.8803 101.668 19.403V23.519H100.184Z" fill="white"/>
|
||||
<path d="M97.6722 23.701C96.9442 23.6917 96.3655 23.4863 95.9362 23.085C95.5162 22.6743 95.3062 22.1143 95.3062 21.405V17.891H93.8362V16.561H95.3062V14.867L96.7902 14.671V16.561H98.9322V17.891H96.7902V21.293C96.7902 21.6663 96.8929 21.9463 97.0982 22.133C97.3129 22.3103 97.5929 22.399 97.9382 22.399C98.1062 22.399 98.2695 22.3943 98.4282 22.385C98.5962 22.3663 98.7642 22.3337 98.9322 22.287V23.561C98.6895 23.617 98.4795 23.6543 98.3022 23.673C98.1249 23.6917 97.9149 23.701 97.6722 23.701Z" fill="white"/>
|
||||
<path d="M91.7792 15.273C91.5179 15.273 91.2985 15.1843 91.1212 15.007C90.9439 14.8297 90.8552 14.6103 90.8552 14.349C90.8552 14.1063 90.9439 13.8963 91.1212 13.719C91.2985 13.5417 91.5179 13.453 91.7792 13.453C92.0499 13.453 92.2692 13.5417 92.4372 13.719C92.6145 13.8963 92.7032 14.1063 92.7032 14.349C92.7032 14.6103 92.6145 14.8297 92.4372 15.007C92.2692 15.1843 92.0499 15.273 91.7792 15.273ZM91.0372 23.519V16.561H92.5212V23.519H91.0372Z" fill="white"/>
|
||||
<path d="M80.9554 23.519L78.6734 16.561H80.3954L81.4874 20.621C81.5528 20.873 81.6134 21.1343 81.6694 21.405C81.7348 21.6663 81.7814 21.9137 81.8094 22.147H81.9074C81.9448 21.6523 82.0428 21.139 82.2014 20.607L83.4194 16.561H85.1414L86.3594 20.607C86.4341 20.859 86.4994 21.125 86.5554 21.405C86.6208 21.6757 86.6628 21.923 86.6814 22.147H86.7794C86.7981 21.923 86.8354 21.6757 86.8914 21.405C86.9568 21.125 87.0221 20.8637 87.0874 20.621L88.2074 16.561H89.8174L87.5494 23.519H85.8974L84.6374 19.445C84.5441 19.1557 84.4741 18.8897 84.4274 18.647C84.3808 18.395 84.3434 18.157 84.3154 17.933H84.2174C84.1894 18.1663 84.1521 18.4043 84.1054 18.647C84.0681 18.8897 84.0028 19.1557 83.9094 19.445L82.6914 23.519H80.9554Z" fill="white"/>
|
||||
<path d="M71.738 23.701C71.0193 23.701 70.3893 23.5423 69.848 23.225C69.3066 22.9077 68.882 22.4783 68.574 21.937C68.2753 21.3863 68.126 20.761 68.126 20.061C68.126 19.3237 68.28 18.6797 68.588 18.129C68.896 17.5783 69.316 17.149 69.848 16.841C70.3893 16.533 71.0053 16.379 71.696 16.379C72.3773 16.379 72.97 16.533 73.474 16.841C73.978 17.1397 74.3653 17.5597 74.636 18.101C74.916 18.633 75.056 19.2537 75.056 19.963C75.056 20.0563 75.0513 20.1543 75.042 20.257C75.042 20.3597 75.0326 20.4437 75.014 20.509H69.652C69.736 21.041 69.9646 21.4843 70.338 21.839C70.7113 22.1843 71.1873 22.357 71.766 22.357C72.6246 22.357 73.2686 22.063 73.698 21.475L74.706 22.427C74.3886 22.819 73.9733 23.1317 73.46 23.365C72.956 23.589 72.382 23.701 71.738 23.701ZM71.668 17.639C71.1453 17.639 70.702 17.7837 70.338 18.073C69.9833 18.3623 69.7593 18.7823 69.666 19.333H73.544C73.4693 18.8103 73.2733 18.3997 72.956 18.101C72.648 17.793 72.2186 17.639 71.668 17.639Z" fill="white"/>
|
||||
<path d="M63.0262 23.701C62.5596 23.701 62.1349 23.589 61.7522 23.365C61.3789 23.1317 61.0756 22.8143 60.8422 22.413C60.6182 22.0117 60.5062 21.545 60.5062 21.013V16.561H61.9902V20.663C61.9902 21.1857 62.1256 21.601 62.3962 21.909C62.6762 22.217 63.0356 22.371 63.4743 22.371C63.9596 22.371 64.3516 22.217 64.6502 21.909C64.9582 21.601 65.1122 21.1857 65.1122 20.663V16.561H66.5962V23.519H65.1682V22.539H65.0842C64.9069 22.8377 64.6456 23.1083 64.3002 23.351C63.9642 23.5843 63.5396 23.701 63.0262 23.701Z" fill="white"/>
|
||||
<path d="M52.4415 23.519V16.561H53.8695V17.541H53.9535C54.1308 17.2423 54.3875 16.9763 54.7235 16.743C55.0688 16.5003 55.5168 16.379 56.0675 16.379C56.5528 16.379 56.9915 16.491 57.3835 16.715C57.7755 16.939 58.0835 17.2563 58.3075 17.667C58.5408 18.0683 58.6575 18.535 58.6575 19.067V23.519H57.1735V19.417C57.1735 18.8943 57.0335 18.479 56.7535 18.171C56.4735 17.863 56.0955 17.709 55.6195 17.709C55.1062 17.709 54.6955 17.863 54.3875 18.171C54.0795 18.479 53.9255 18.8943 53.9255 19.417V23.519H52.4415Z" fill="white"/>
|
||||
<path d="M49.7108 15.273C49.4495 15.273 49.2302 15.1843 49.0528 15.007C48.8755 14.8297 48.7868 14.6103 48.7868 14.349C48.7868 14.1063 48.8755 13.8963 49.0528 13.719C49.2302 13.5417 49.4495 13.453 49.7108 13.453C49.9815 13.453 50.2008 13.5417 50.3688 13.719C50.5462 13.8963 50.6348 14.1063 50.6348 14.349C50.6348 14.6103 50.5462 14.8297 50.3688 15.007C50.2008 15.1843 49.9815 15.273 49.7108 15.273ZM48.9688 23.519V16.561H50.4528V23.519H48.9688Z" fill="white"/>
|
||||
<path d="M46.4574 23.701C45.7294 23.6917 45.1507 23.4863 44.7214 23.085C44.3014 22.6743 44.0914 22.1143 44.0914 21.405V17.891H42.6214V16.561H44.0914V14.867L45.5754 14.671V16.561H47.7174V17.891H45.5754V21.293C45.5754 21.6663 45.678 21.9463 45.8834 22.133C46.098 22.3103 46.378 22.399 46.7234 22.399C46.8914 22.399 47.0547 22.3943 47.2134 22.385C47.3814 22.3663 47.5494 22.3337 47.7174 22.287V23.561C47.4747 23.617 47.2647 23.6543 47.0874 23.673C46.91 23.6917 46.7 23.701 46.4574 23.701Z" fill="white"/>
|
||||
<path d="M35.3516 23.519V16.561H36.7796V17.541H36.8636C37.041 17.2423 37.2976 16.9763 37.6336 16.743C37.979 16.5003 38.427 16.379 38.9776 16.379C39.463 16.379 39.9016 16.491 40.2936 16.715C40.6856 16.939 40.9936 17.2563 41.2176 17.667C41.451 18.0683 41.5676 18.535 41.5676 19.067V23.519H40.0836V19.417C40.0836 18.8943 39.9436 18.479 39.6636 18.171C39.3836 17.863 39.0056 17.709 38.5296 17.709C38.0163 17.709 37.6056 17.863 37.2976 18.171C36.9896 18.479 36.8356 18.8943 36.8356 19.417V23.519H35.3516Z" fill="white"/>
|
||||
<path d="M30.2298 23.701C29.5392 23.701 28.9232 23.5423 28.3818 23.225C27.8405 22.9077 27.4112 22.4737 27.0938 21.923C26.7858 21.3723 26.6318 20.747 26.6318 20.047C26.6318 19.3377 26.7858 18.7077 27.0938 18.157C27.4112 17.6063 27.8405 17.1723 28.3818 16.855C28.9232 16.5377 29.5392 16.379 30.2298 16.379C30.9205 16.379 31.5365 16.5377 32.0778 16.855C32.6285 17.1723 33.0578 17.6063 33.3658 18.157C33.6738 18.7077 33.8278 19.333 33.8278 20.033C33.8278 20.7423 33.6738 21.3723 33.3658 21.923C33.0578 22.4737 32.6285 22.9077 32.0778 23.225C31.5365 23.5423 30.9205 23.701 30.2298 23.701ZM30.2298 22.343C30.8645 22.343 31.3732 22.1283 31.7558 21.699C32.1478 21.2697 32.3438 20.719 32.3438 20.047C32.3438 19.3657 32.1478 18.8103 31.7558 18.381C31.3732 17.9517 30.8645 17.737 30.2298 17.737C29.5952 17.737 29.0818 17.9517 28.6898 18.381C28.3072 18.8103 28.1158 19.3657 28.1158 20.047C28.1158 20.719 28.3072 21.2697 28.6898 21.699C29.0818 22.1283 29.5952 22.343 30.2298 22.343Z" fill="white"/>
|
||||
<path d="M22.568 23.701C21.8493 23.701 21.182 23.5703 20.566 23.309C19.95 23.0477 19.4133 22.6883 18.956 22.231C18.4987 21.7643 18.1393 21.2277 17.878 20.621C17.626 20.005 17.5 19.3423 17.5 18.633C17.5 17.9237 17.626 17.261 17.878 16.645C18.13 16.029 18.48 15.4877 18.928 15.021C19.3853 14.5543 19.922 14.1903 20.538 13.929C21.154 13.6677 21.8307 13.537 22.568 13.537C23.296 13.537 23.9633 13.663 24.57 13.915C25.186 14.167 25.718 14.559 26.166 15.091L25.06 16.099C24.7333 15.7163 24.3647 15.441 23.954 15.273C23.5433 15.0957 23.0813 15.007 22.568 15.007C21.8773 15.007 21.2707 15.1657 20.748 15.483C20.2253 15.8003 19.8193 16.2343 19.53 16.785C19.2407 17.3263 19.096 17.9423 19.096 18.633C19.096 19.3237 19.2453 19.9397 19.544 20.481C19.852 21.0223 20.2673 21.4517 20.79 21.769C21.322 22.077 21.9287 22.231 22.61 22.231C23.1327 22.231 23.5947 22.1423 23.996 21.965C24.4067 21.7783 24.7707 21.503 25.088 21.139L26.222 22.105C25.8207 22.581 25.3167 22.9683 24.71 23.267C24.1033 23.5563 23.3893 23.701 22.568 23.701Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dddii_2049_5871" x="0" y="0" width="181.307" height="45" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="1" dy="4"/>
|
||||
<feGaussianBlur stdDeviation="4"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2049_5871"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_2049_5871" result="effect2_dropShadow_2049_5871"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="1.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
|
||||
<feBlend mode="normal" in2="effect2_dropShadow_2049_5871" result="effect3_dropShadow_2049_5871"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_2049_5871" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-3"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect4_innerShadow_2049_5871"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="0.2"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="effect4_innerShadow_2049_5871" result="effect5_innerShadow_2049_5871"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg width="189" height="40" viewBox="0 0 189 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M169 0.5H20C9.23045 0.5 0.5 9.23045 0.5 20C0.5 30.7696 9.23045 39.5 20 39.5H169C179.77 39.5 188.5 30.7696 188.5 20C188.5 9.23045 179.77 0.5 169 0.5Z" fill="#131314"/>
|
||||
<path d="M169 0.5H20C9.23045 0.5 0.5 9.23045 0.5 20C0.5 30.7696 9.23045 39.5 20 39.5H169C179.77 39.5 188.5 30.7696 188.5 20C188.5 9.23045 179.77 0.5 169 0.5Z" stroke="#8E918F"/>
|
||||
<path d="M102.289 24.9992V15.1992H103.591V19.0912H103.675C103.862 18.7739 104.123 18.4986 104.459 18.2652C104.795 18.0226 105.229 17.9012 105.761 17.9012C106.265 17.9012 106.708 18.0132 107.091 18.2372C107.483 18.4519 107.786 18.7599 108.001 19.1612C108.225 19.5626 108.337 20.0292 108.337 20.5612V24.9992H107.021V20.8692C107.021 20.3279 106.881 19.8986 106.601 19.5812C106.33 19.2546 105.948 19.0912 105.453 19.0912C104.884 19.0912 104.431 19.2546 104.095 19.5812C103.759 19.8986 103.591 20.3279 103.591 20.8692V24.9992H102.289Z" fill="white"/>
|
||||
<path d="M99.7917 25.181C99.0824 25.1624 98.5271 24.9617 98.1257 24.579C97.7337 24.187 97.5377 23.641 97.5377 22.941V19.259H96.0117V18.083H97.5377V16.403L98.8537 16.207V18.083H101.038V19.259H98.8537V22.857C98.8537 23.6224 99.2504 24.005 100.044 24.005C100.202 24.005 100.361 24.0004 100.52 23.991C100.688 23.9724 100.86 23.9397 101.038 23.893V25.041C100.786 25.097 100.571 25.1344 100.394 25.153C100.216 25.181 100.016 25.1904 99.7917 25.181Z" fill="white"/>
|
||||
<path d="M94.047 16.5872C93.795 16.5872 93.5897 16.5079 93.431 16.3492C93.2817 16.1906 93.207 15.9946 93.207 15.7612C93.207 15.5466 93.2817 15.3599 93.431 15.2012C93.5897 15.0332 93.795 14.9492 94.047 14.9492C94.2804 14.9492 94.4764 15.0332 94.635 15.2012C94.7937 15.3599 94.873 15.5466 94.873 15.7612C94.873 15.9946 94.7937 16.1906 94.635 16.3492C94.4764 16.5079 94.2804 16.5872 94.047 16.5872ZM93.389 25.0012V18.0852H94.691V25.0012H93.389Z" fill="white"/>
|
||||
<path d="M83.5665 25.0019L81.3125 18.0859H82.8105L83.9865 22.2579C84.0612 22.5193 84.1312 22.7899 84.1965 23.0699C84.2618 23.3499 84.3038 23.6019 84.3225 23.8259H84.4205C84.4298 23.6113 84.4625 23.3593 84.5185 23.0699C84.5745 22.7806 84.6445 22.5053 84.7285 22.2439L85.9885 18.0859H87.5285L88.7885 22.2439C88.8725 22.5053 88.9425 22.7806 88.9985 23.0699C89.0638 23.3593 89.1058 23.6113 89.1245 23.8259H89.2225C89.2318 23.6113 89.2692 23.3593 89.3345 23.0699C89.3998 22.7806 89.4652 22.5099 89.5305 22.2579L90.7205 18.0859H92.1485L89.9085 25.0019H88.4105L87.1225 20.8299C87.0198 20.5126 86.9452 20.2279 86.8985 19.9759C86.8518 19.7239 86.8145 19.4859 86.7865 19.2619H86.7025C86.6745 19.4859 86.6372 19.7239 86.5905 19.9759C86.5438 20.2186 86.4738 20.5033 86.3805 20.8299L85.1065 25.0019H83.5665Z" fill="white"/>
|
||||
<path d="M74.363 25.1823C73.6443 25.1823 73.019 25.0283 72.487 24.7203C71.955 24.403 71.5396 23.969 71.241 23.4183C70.9423 22.8677 70.793 22.2423 70.793 21.5423C70.793 20.833 70.9423 20.2077 71.241 19.6663C71.549 19.1157 71.969 18.6863 72.501 18.3783C73.033 18.061 73.6396 17.9023 74.321 17.9023C75.0116 17.9023 75.6043 18.0563 76.099 18.3643C76.603 18.663 76.9903 19.083 77.261 19.6243C77.5316 20.1657 77.667 20.791 77.667 21.5003C77.667 21.5843 77.6623 21.673 77.653 21.7663C77.653 21.8597 77.6436 21.9343 77.625 21.9903H72.137C72.2116 22.5783 72.4543 23.059 72.865 23.4323C73.2756 23.7963 73.7796 23.9783 74.377 23.9783C75.2636 23.9783 75.931 23.675 76.379 23.0683L77.289 23.8943C76.9716 24.305 76.561 24.6223 76.057 24.8463C75.553 25.0703 74.9883 25.1823 74.363 25.1823ZM74.293 19.0643C73.7236 19.0643 73.2476 19.223 72.865 19.5403C72.4823 19.8577 72.2443 20.315 72.151 20.9123H76.323C76.2483 20.3523 76.0383 19.9043 75.693 19.5683C75.357 19.2323 74.8903 19.0643 74.293 19.0643Z" fill="white"/>
|
||||
<path d="M65.8716 25.1839C65.3956 25.1839 64.9662 25.0766 64.5836 24.8619C64.2102 24.6379 63.9116 24.3253 63.6876 23.9239C63.4636 23.5226 63.3516 23.0513 63.3516 22.5099V18.0859H64.6536V22.2159C64.6536 22.7573 64.7936 23.1913 65.0736 23.5179C65.3629 23.8353 65.7316 23.9939 66.1796 23.9939C66.7116 23.9939 67.1409 23.8353 67.4676 23.5179C67.7942 23.1913 67.9576 22.7573 67.9576 22.2159V18.0859H69.2736V25.0019H68.0136V23.9939H67.9296C67.7522 24.3113 67.4956 24.5913 67.1596 24.8339C66.8236 25.0673 66.3942 25.1839 65.8716 25.1839Z" fill="white"/>
|
||||
<path d="M55.4219 25.0003V18.0843H56.6679V19.0923H56.7519C56.9385 18.7843 57.1999 18.509 57.5359 18.2663C57.8719 18.0237 58.3199 17.9023 58.8799 17.9023C59.3745 17.9023 59.8179 18.0143 60.2099 18.2383C60.6019 18.4623 60.9099 18.775 61.1339 19.1763C61.3579 19.5683 61.4699 20.035 61.4699 20.5763V25.0003H60.1539V20.8703C60.1539 20.329 60.0092 19.8997 59.7199 19.5823C59.4399 19.2557 59.0525 19.0923 58.5579 19.0923C57.9979 19.0923 57.5499 19.2557 57.2139 19.5823C56.8872 19.8997 56.7239 20.329 56.7239 20.8703V25.0003H55.4219Z" fill="white"/>
|
||||
<path d="M52.7697 16.5872C52.5177 16.5872 52.3124 16.5079 52.1537 16.3492C52.0044 16.1906 51.9297 15.9946 51.9297 15.7612C51.9297 15.5466 52.0044 15.3599 52.1537 15.2012C52.3124 15.0332 52.5177 14.9492 52.7697 14.9492C53.003 14.9492 53.199 15.0332 53.3577 15.2012C53.5164 15.3599 53.5957 15.5466 53.5957 15.7612C53.5957 15.9946 53.5164 16.1906 53.3577 16.3492C53.199 16.5079 53.003 16.5872 52.7697 16.5872ZM52.1117 25.0012V18.0852H53.4137V25.0012H52.1117Z" fill="white"/>
|
||||
<path d="M49.6159 25.181C48.9066 25.1624 48.3513 24.9617 47.9499 24.579C47.5579 24.187 47.3619 23.641 47.3619 22.941V19.259H45.8359V18.083H47.3619V16.403L48.6779 16.207V18.083H50.8619V19.259H48.6779V22.857C48.6779 23.6224 49.0746 24.005 49.8679 24.005C50.0266 24.005 50.1853 24.0004 50.3439 23.991C50.5119 23.9724 50.6846 23.9397 50.8619 23.893V25.041C50.6099 25.097 50.3953 25.1344 50.2179 25.153C50.0406 25.181 49.8399 25.1904 49.6159 25.181Z" fill="white"/>
|
||||
<path d="M38.7148 25.0003V18.0843H39.9608V19.0923H40.0448C40.2315 18.7843 40.4928 18.509 40.8288 18.2663C41.1648 18.0237 41.6128 17.9023 42.1728 17.9023C42.6675 17.9023 43.1108 18.0143 43.5028 18.2383C43.8948 18.4623 44.2028 18.775 44.4268 19.1763C44.6508 19.5683 44.7628 20.035 44.7628 20.5763V25.0003H43.4468V20.8703C43.4468 20.329 43.3022 19.8997 43.0128 19.5823C42.7328 19.2557 42.3455 19.0923 41.8508 19.0923C41.2908 19.0923 40.8428 19.2557 40.5068 19.5823C40.1802 19.8997 40.0168 20.329 40.0168 20.8703V25.0003H38.7148Z" fill="white"/>
|
||||
<path d="M33.6475 25.1823C32.9661 25.1823 32.3548 25.0283 31.8135 24.7203C31.2815 24.403 30.8615 23.9737 30.5535 23.4323C30.2548 22.8817 30.1055 22.2563 30.1055 21.5563C30.1055 20.847 30.2548 20.2217 30.5535 19.6803C30.8615 19.1297 31.2815 18.6957 31.8135 18.3783C32.3548 18.061 32.9661 17.9023 33.6475 17.9023C34.3288 17.9023 34.9355 18.061 35.4675 18.3783C36.0088 18.6863 36.4288 19.1157 36.7275 19.6663C37.0355 20.2077 37.1895 20.8283 37.1895 21.5283C37.1895 22.2377 37.0355 22.8677 36.7275 23.4183C36.4288 23.969 36.0088 24.403 35.4675 24.7203C34.9355 25.0283 34.3288 25.1823 33.6475 25.1823ZM33.6475 23.9783C34.3195 23.9783 34.8608 23.7497 35.2715 23.2923C35.6821 22.835 35.8875 22.2517 35.8875 21.5423C35.8875 21.0757 35.7941 20.6603 35.6075 20.2963C35.4208 19.9323 35.1595 19.643 34.8235 19.4283C34.4875 19.2137 34.0955 19.1063 33.6475 19.1063C32.9755 19.1063 32.4341 19.335 32.0235 19.7923C31.6221 20.2497 31.4215 20.833 31.4215 21.5423C31.4215 22.009 31.5101 22.429 31.6875 22.8023C31.8741 23.1663 32.1355 23.4557 32.4715 23.6703C32.8075 23.8757 33.1995 23.9783 33.6475 23.9783Z" fill="white"/>
|
||||
<path d="M26.04 25.1835C25.3307 25.1835 24.668 25.0575 24.052 24.8055C23.4453 24.5442 22.9133 24.1849 22.456 23.7275C21.9987 23.2609 21.6393 22.7195 21.378 22.1035C21.126 21.4875 21 20.8249 21 20.1155C21 19.4062 21.126 18.7435 21.378 18.1275C21.63 17.5115 21.98 16.9702 22.428 16.5035C22.8853 16.0369 23.4173 15.6729 24.024 15.4115C24.64 15.1502 25.312 15.0195 26.04 15.0195C26.7773 15.0195 27.4493 15.1455 28.056 15.3975C28.6627 15.6495 29.19 16.0369 29.638 16.5595L28.644 17.4415C28.2987 17.0309 27.9067 16.7369 27.468 16.5595C27.0387 16.3822 26.5627 16.2935 26.04 16.2935C25.3307 16.2935 24.7007 16.4615 24.15 16.7975C23.6087 17.1335 23.184 17.5909 22.876 18.1695C22.568 18.7389 22.414 19.3875 22.414 20.1155C22.414 20.8529 22.5727 21.5062 22.89 22.0755C23.2167 22.6449 23.6553 23.0929 24.206 23.4195C24.766 23.7369 25.3913 23.8955 26.082 23.8955C26.6607 23.8955 27.16 23.8022 27.58 23.6155C28 23.4195 28.378 23.1302 28.714 22.7475L29.708 23.5735C29.2973 24.0775 28.784 24.4742 28.168 24.7635C27.5613 25.0435 26.852 25.1835 26.04 25.1835Z" fill="white"/>
|
||||
<path d="M156.305 17.1995H158.3V18.3395C158.71 17.4595 159.505 17.0195 160.685 17.0195C161.235 17.0195 161.73 17.1495 162.17 17.4095C162.62 17.6595 162.97 18.0195 163.22 18.4895C163.49 17.9995 163.83 17.6345 164.24 17.3945C164.66 17.1445 165.2 17.0195 165.86 17.0195C166.42 17.0195 166.925 17.1545 167.375 17.4245C167.825 17.6845 168.18 18.0595 168.44 18.5495C168.7 19.0295 168.83 19.5895 168.83 20.2295V24.9995H166.805V20.7245C166.805 20.0745 166.665 19.5895 166.385 19.2695C166.105 18.9495 165.725 18.7895 165.245 18.7895C164.735 18.7895 164.34 18.9345 164.06 19.2245C163.79 19.5145 163.655 20.0145 163.655 20.7245V24.9995H161.63V20.7245C161.63 20.0745 161.48 19.5895 161.18 19.2695C160.88 18.9495 160.47 18.7895 159.95 18.7895C159.49 18.7895 159.105 18.9545 158.795 19.2845C158.485 19.6045 158.33 20.0845 158.33 20.7245V24.9995H156.305V17.1995Z" fill="white"/>
|
||||
<path d="M151.253 25.1945C150.463 25.1945 149.753 25.0145 149.123 24.6545C148.493 24.2845 147.998 23.7895 147.638 23.1695C147.278 22.5395 147.098 21.8545 147.098 21.1145C147.098 20.3745 147.278 19.6895 147.638 19.0595C147.998 18.4295 148.493 17.9345 149.123 17.5745C149.753 17.2045 150.463 17.0195 151.253 17.0195C152.043 17.0195 152.753 17.2045 153.383 17.5745C154.013 17.9345 154.508 18.4295 154.868 19.0595C155.228 19.6895 155.408 20.3745 155.408 21.1145C155.408 21.8545 155.228 22.5395 154.868 23.1695C154.508 23.7895 154.013 24.2845 153.383 24.6545C152.753 25.0145 152.043 25.1945 151.253 25.1945ZM149.123 21.1145C149.123 21.5245 149.208 21.8995 149.378 22.2395C149.558 22.5795 149.808 22.8495 150.128 23.0495C150.458 23.2495 150.833 23.3495 151.253 23.3495C151.673 23.3495 152.043 23.2495 152.363 23.0495C152.693 22.8495 152.943 22.5795 153.113 22.2395C153.293 21.8995 153.383 21.5245 153.383 21.1145C153.383 20.7045 153.293 20.3295 153.113 19.9895C152.933 19.6495 152.683 19.3795 152.363 19.1795C152.043 18.9695 151.673 18.8645 151.253 18.8645C150.833 18.8645 150.463 18.9695 150.143 19.1795C149.823 19.3795 149.573 19.6495 149.393 19.9895C149.213 20.3295 149.123 20.7045 149.123 21.1145Z" fill="white"/>
|
||||
<path d="M143.75 25.1939C143.02 25.1939 142.345 25.0139 141.725 24.6539C141.115 24.2839 140.63 23.7839 140.27 23.1539C139.91 22.5239 139.73 21.8389 139.73 21.0989C139.73 20.3489 139.91 19.6639 140.27 19.0439C140.63 18.4139 141.13 17.9189 141.77 17.5589C142.41 17.1889 143.125 17.0039 143.915 17.0039C144.605 17.0039 145.225 17.1339 145.775 17.3939C146.325 17.6439 146.765 17.9989 147.095 18.4589L145.535 19.7339C145.355 19.4639 145.12 19.2489 144.83 19.0889C144.54 18.9289 144.225 18.8489 143.885 18.8489C143.465 18.8489 143.095 18.9539 142.775 19.1639C142.455 19.3639 142.205 19.6339 142.025 19.9739C141.845 20.3139 141.755 20.6889 141.755 21.0989C141.755 21.4989 141.845 21.8739 142.025 22.2239C142.215 22.5639 142.475 22.8389 142.805 23.0489C143.135 23.2489 143.51 23.3489 143.93 23.3489C144.3 23.3489 144.62 23.2689 144.89 23.1089C145.16 22.9489 145.405 22.7189 145.625 22.4189L147.095 23.6939C146.315 24.6939 145.2 25.1939 143.75 25.1939Z" fill="white"/>
|
||||
<path d="M138.065 25.1953C137.685 25.1953 137.365 25.0653 137.105 24.8053C136.845 24.5453 136.715 24.2353 136.715 23.8753C136.715 23.5153 136.845 23.2103 137.105 22.9603C137.365 22.7003 137.685 22.5703 138.065 22.5703C138.445 22.5703 138.76 22.7003 139.01 22.9603C139.27 23.2103 139.4 23.5153 139.4 23.8753C139.4 24.2453 139.27 24.5603 139.01 24.8203C138.76 25.0703 138.445 25.1953 138.065 25.1953Z" fill="white"/>
|
||||
<path d="M133.762 14.0508H135.787V25.0008H133.762V14.0508Z" fill="white"/>
|
||||
<path d="M128.074 25.1952C127.374 25.1952 126.729 25.0152 126.139 24.6552C125.549 24.2852 125.079 23.7902 124.729 23.1702C124.389 22.5502 124.219 21.8702 124.219 21.1302C124.219 20.3902 124.389 19.7102 124.729 19.0902C125.079 18.4602 125.549 17.9602 126.139 17.5902C126.729 17.2202 127.374 17.0352 128.074 17.0352C128.714 17.0352 129.229 17.1552 129.619 17.3952C130.019 17.6352 130.329 17.9802 130.549 18.4302V17.2002H132.559V25.0002H130.594V23.7252C130.364 24.1952 130.049 24.5602 129.649 24.8202C129.259 25.0702 128.734 25.1952 128.074 25.1952ZM126.244 21.1152C126.244 21.5152 126.334 21.8902 126.514 22.2402C126.704 22.5802 126.959 22.8552 127.279 23.0652C127.609 23.2652 127.984 23.3652 128.404 23.3652C128.834 23.3652 129.214 23.2652 129.544 23.0652C129.884 22.8652 130.144 22.5952 130.324 22.2552C130.504 21.9152 130.594 21.5402 130.594 21.1302C130.594 20.7202 130.504 20.3452 130.324 20.0052C130.144 19.6552 129.884 19.3802 129.544 19.1802C129.214 18.9702 128.834 18.8652 128.404 18.8652C127.984 18.8652 127.609 18.9702 127.279 19.1802C126.949 19.3802 126.694 19.6502 126.514 19.9902C126.334 20.3302 126.244 20.7052 126.244 21.1152Z" fill="white"/>
|
||||
<path d="M120.276 25.1947C119.256 25.1947 118.326 24.9547 117.486 24.4747C116.656 23.9847 116.001 23.3247 115.521 22.4947C115.041 21.6647 114.801 20.7547 114.801 19.7647C114.801 18.7647 115.031 17.8497 115.491 17.0197C115.961 16.1797 116.611 15.5197 117.441 15.0397C118.271 14.5497 119.216 14.3047 120.276 14.3047C121.126 14.3047 121.856 14.4397 122.466 14.7097C123.086 14.9697 123.636 15.3847 124.116 15.9547L122.631 17.2447C122.001 16.5847 121.216 16.2547 120.276 16.2547C119.616 16.2547 119.036 16.4147 118.536 16.7347C118.036 17.0447 117.651 17.4697 117.381 18.0097C117.111 18.5397 116.976 19.1247 116.976 19.7647C116.976 20.4047 117.111 20.9897 117.381 21.5197C117.661 22.0497 118.051 22.4697 118.551 22.7797C119.061 23.0897 119.651 23.2447 120.321 23.2447C121.301 23.2447 122.101 22.9047 122.721 22.2247L124.251 23.5297C123.831 24.0397 123.281 24.4447 122.601 24.7447C121.931 25.0447 121.156 25.1947 120.276 25.1947Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg width="189" height="40" viewBox="0 0 189 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4339_81)">
|
||||
<path d="M185 0.5H4C2.067 0.5 0.5 2.067 0.5 4V36C0.5 37.933 2.067 39.5 4 39.5H185C186.933 39.5 188.5 37.933 188.5 36V4C188.5 2.067 186.933 0.5 185 0.5Z" fill="#131314"/>
|
||||
<path d="M185.5 0H4.5C2.567 0 1 1.567 1 3.5V35.5C1 37.433 2.567 39 4.5 39H185.5C187.433 39 189 37.433 189 35.5V3.5C189 1.567 187.433 0 185.5 0Z" stroke="#8E918F"/>
|
||||
<path d="M102.289 24.9992V15.1992H103.591V19.0912H103.675C103.862 18.7739 104.123 18.4986 104.459 18.2652C104.795 18.0226 105.229 17.9012 105.761 17.9012C106.265 17.9012 106.708 18.0132 107.091 18.2372C107.483 18.4519 107.786 18.7599 108.001 19.1612C108.225 19.5626 108.337 20.0292 108.337 20.5612V24.9992H107.021V20.8692C107.021 20.3279 106.881 19.8986 106.601 19.5812C106.33 19.2546 105.948 19.0912 105.453 19.0912C104.884 19.0912 104.431 19.2546 104.095 19.5812C103.759 19.8986 103.591 20.3279 103.591 20.8692V24.9992H102.289Z" fill="white"/>
|
||||
<path d="M99.7917 25.181C99.0824 25.1624 98.5271 24.9617 98.1257 24.579C97.7337 24.187 97.5377 23.641 97.5377 22.941V19.259H96.0117V18.083H97.5377V16.403L98.8537 16.207V18.083H101.038V19.259H98.8537V22.857C98.8537 23.6224 99.2504 24.005 100.044 24.005C100.202 24.005 100.361 24.0004 100.52 23.991C100.688 23.9724 100.86 23.9397 101.038 23.893V25.041C100.786 25.097 100.571 25.1344 100.394 25.153C100.216 25.181 100.016 25.1904 99.7917 25.181Z" fill="white"/>
|
||||
<path d="M94.047 16.5872C93.795 16.5872 93.5897 16.5079 93.431 16.3492C93.2817 16.1906 93.207 15.9946 93.207 15.7612C93.207 15.5466 93.2817 15.3599 93.431 15.2012C93.5897 15.0332 93.795 14.9492 94.047 14.9492C94.2804 14.9492 94.4764 15.0332 94.635 15.2012C94.7937 15.3599 94.873 15.5466 94.873 15.7612C94.873 15.9946 94.7937 16.1906 94.635 16.3492C94.4764 16.5079 94.2804 16.5872 94.047 16.5872ZM93.389 25.0012V18.0852H94.691V25.0012H93.389Z" fill="white"/>
|
||||
<path d="M83.5665 25.0019L81.3125 18.0859H82.8105L83.9865 22.2579C84.0612 22.5193 84.1312 22.7899 84.1965 23.0699C84.2618 23.3499 84.3038 23.6019 84.3225 23.8259H84.4205C84.4298 23.6113 84.4625 23.3593 84.5185 23.0699C84.5745 22.7806 84.6445 22.5053 84.7285 22.2439L85.9885 18.0859H87.5285L88.7885 22.2439C88.8725 22.5053 88.9425 22.7806 88.9985 23.0699C89.0638 23.3593 89.1058 23.6113 89.1245 23.8259H89.2225C89.2318 23.6113 89.2692 23.3593 89.3345 23.0699C89.3998 22.7806 89.4652 22.5099 89.5305 22.2579L90.7205 18.0859H92.1485L89.9085 25.0019H88.4105L87.1225 20.8299C87.0198 20.5126 86.9452 20.2279 86.8985 19.9759C86.8518 19.7239 86.8145 19.4859 86.7865 19.2619H86.7025C86.6745 19.4859 86.6372 19.7239 86.5905 19.9759C86.5438 20.2186 86.4738 20.5033 86.3805 20.8299L85.1065 25.0019H83.5665Z" fill="white"/>
|
||||
<path d="M74.363 25.1823C73.6443 25.1823 73.019 25.0283 72.487 24.7203C71.955 24.403 71.5396 23.969 71.241 23.4183C70.9423 22.8677 70.793 22.2423 70.793 21.5423C70.793 20.833 70.9423 20.2077 71.241 19.6663C71.549 19.1157 71.969 18.6863 72.501 18.3783C73.033 18.061 73.6396 17.9023 74.321 17.9023C75.0116 17.9023 75.6043 18.0563 76.099 18.3643C76.603 18.663 76.9903 19.083 77.261 19.6243C77.5316 20.1657 77.667 20.791 77.667 21.5003C77.667 21.5843 77.6623 21.673 77.653 21.7663C77.653 21.8597 77.6436 21.9343 77.625 21.9903H72.137C72.2116 22.5783 72.4543 23.059 72.865 23.4323C73.2756 23.7963 73.7796 23.9783 74.377 23.9783C75.2636 23.9783 75.931 23.675 76.379 23.0683L77.289 23.8943C76.9716 24.305 76.561 24.6223 76.057 24.8463C75.553 25.0703 74.9883 25.1823 74.363 25.1823ZM74.293 19.0643C73.7236 19.0643 73.2476 19.223 72.865 19.5403C72.4823 19.8577 72.2443 20.315 72.151 20.9123H76.323C76.2483 20.3523 76.0383 19.9043 75.693 19.5683C75.357 19.2323 74.8903 19.0643 74.293 19.0643Z" fill="white"/>
|
||||
<path d="M65.8716 25.1839C65.3956 25.1839 64.9662 25.0766 64.5836 24.8619C64.2102 24.6379 63.9116 24.3253 63.6876 23.9239C63.4636 23.5226 63.3516 23.0513 63.3516 22.5099V18.0859H64.6536V22.2159C64.6536 22.7573 64.7936 23.1913 65.0736 23.5179C65.3629 23.8353 65.7316 23.9939 66.1796 23.9939C66.7116 23.9939 67.1409 23.8353 67.4676 23.5179C67.7942 23.1913 67.9576 22.7573 67.9576 22.2159V18.0859H69.2736V25.0019H68.0136V23.9939H67.9296C67.7522 24.3113 67.4956 24.5913 67.1596 24.8339C66.8236 25.0673 66.3942 25.1839 65.8716 25.1839Z" fill="white"/>
|
||||
<path d="M55.4219 25.0003V18.0843H56.6679V19.0923H56.7519C56.9385 18.7843 57.1999 18.509 57.5359 18.2663C57.8719 18.0237 58.3199 17.9023 58.8799 17.9023C59.3745 17.9023 59.8179 18.0143 60.2099 18.2383C60.6019 18.4623 60.9099 18.775 61.1339 19.1763C61.3579 19.5683 61.4699 20.035 61.4699 20.5763V25.0003H60.1539V20.8703C60.1539 20.329 60.0092 19.8997 59.7199 19.5823C59.4399 19.2557 59.0525 19.0923 58.5579 19.0923C57.9979 19.0923 57.5499 19.2557 57.2139 19.5823C56.8872 19.8997 56.7239 20.329 56.7239 20.8703V25.0003H55.4219Z" fill="white"/>
|
||||
<path d="M52.7697 16.5872C52.5177 16.5872 52.3124 16.5079 52.1537 16.3492C52.0044 16.1906 51.9297 15.9946 51.9297 15.7612C51.9297 15.5466 52.0044 15.3599 52.1537 15.2012C52.3124 15.0332 52.5177 14.9492 52.7697 14.9492C53.003 14.9492 53.199 15.0332 53.3577 15.2012C53.5164 15.3599 53.5957 15.5466 53.5957 15.7612C53.5957 15.9946 53.5164 16.1906 53.3577 16.3492C53.199 16.5079 53.003 16.5872 52.7697 16.5872ZM52.1117 25.0012V18.0852H53.4137V25.0012H52.1117Z" fill="white"/>
|
||||
<path d="M49.6159 25.181C48.9066 25.1624 48.3513 24.9617 47.9499 24.579C47.5579 24.187 47.3619 23.641 47.3619 22.941V19.259H45.8359V18.083H47.3619V16.403L48.6779 16.207V18.083H50.8619V19.259H48.6779V22.857C48.6779 23.6224 49.0746 24.005 49.8679 24.005C50.0266 24.005 50.1853 24.0004 50.3439 23.991C50.5119 23.9724 50.6846 23.9397 50.8619 23.893V25.041C50.6099 25.097 50.3953 25.1344 50.2179 25.153C50.0406 25.181 49.8399 25.1904 49.6159 25.181Z" fill="white"/>
|
||||
<path d="M38.7148 25.0003V18.0843H39.9608V19.0923H40.0448C40.2315 18.7843 40.4928 18.509 40.8288 18.2663C41.1648 18.0237 41.6128 17.9023 42.1728 17.9023C42.6675 17.9023 43.1108 18.0143 43.5028 18.2383C43.8948 18.4623 44.2028 18.775 44.4268 19.1763C44.6508 19.5683 44.7628 20.035 44.7628 20.5763V25.0003H43.4468V20.8703C43.4468 20.329 43.3022 19.8997 43.0128 19.5823C42.7328 19.2557 42.3455 19.0923 41.8508 19.0923C41.2908 19.0923 40.8428 19.2557 40.5068 19.5823C40.1802 19.8997 40.0168 20.329 40.0168 20.8703V25.0003H38.7148Z" fill="white"/>
|
||||
<path d="M33.6475 25.1823C32.9661 25.1823 32.3548 25.0283 31.8135 24.7203C31.2815 24.403 30.8615 23.9737 30.5535 23.4323C30.2548 22.8817 30.1055 22.2563 30.1055 21.5563C30.1055 20.847 30.2548 20.2217 30.5535 19.6803C30.8615 19.1297 31.2815 18.6957 31.8135 18.3783C32.3548 18.061 32.9661 17.9023 33.6475 17.9023C34.3288 17.9023 34.9355 18.061 35.4675 18.3783C36.0088 18.6863 36.4288 19.1157 36.7275 19.6663C37.0355 20.2077 37.1895 20.8283 37.1895 21.5283C37.1895 22.2377 37.0355 22.8677 36.7275 23.4183C36.4288 23.969 36.0088 24.403 35.4675 24.7203C34.9355 25.0283 34.3288 25.1823 33.6475 25.1823ZM33.6475 23.9783C34.3195 23.9783 34.8608 23.7497 35.2715 23.2923C35.6821 22.835 35.8875 22.2517 35.8875 21.5423C35.8875 21.0757 35.7941 20.6603 35.6075 20.2963C35.4208 19.9323 35.1595 19.643 34.8235 19.4283C34.4875 19.2137 34.0955 19.1063 33.6475 19.1063C32.9755 19.1063 32.4341 19.335 32.0235 19.7923C31.6221 20.2497 31.4215 20.833 31.4215 21.5423C31.4215 22.009 31.5101 22.429 31.6875 22.8023C31.8741 23.1663 32.1355 23.4557 32.4715 23.6703C32.8075 23.8757 33.1995 23.9783 33.6475 23.9783Z" fill="white"/>
|
||||
<path d="M26.04 25.1835C25.3307 25.1835 24.668 25.0575 24.052 24.8055C23.4453 24.5442 22.9133 24.1849 22.456 23.7275C21.9987 23.2609 21.6393 22.7195 21.378 22.1035C21.126 21.4875 21 20.8249 21 20.1155C21 19.4062 21.126 18.7435 21.378 18.1275C21.63 17.5115 21.98 16.9702 22.428 16.5035C22.8853 16.0369 23.4173 15.6729 24.024 15.4115C24.64 15.1502 25.312 15.0195 26.04 15.0195C26.7773 15.0195 27.4493 15.1455 28.056 15.3975C28.6627 15.6495 29.19 16.0369 29.638 16.5595L28.644 17.4415C28.2987 17.0309 27.9067 16.7369 27.468 16.5595C27.0387 16.3822 26.5627 16.2935 26.04 16.2935C25.3307 16.2935 24.7007 16.4615 24.15 16.7975C23.6087 17.1335 23.184 17.5909 22.876 18.1695C22.568 18.7389 22.414 19.3875 22.414 20.1155C22.414 20.8529 22.5727 21.5062 22.89 22.0755C23.2167 22.6449 23.6553 23.0929 24.206 23.4195C24.766 23.7369 25.3913 23.8955 26.082 23.8955C26.6607 23.8955 27.16 23.8022 27.58 23.6155C28 23.4195 28.378 23.1302 28.714 22.7475L29.708 23.5735C29.2973 24.0775 28.784 24.4742 28.168 24.7635C27.5613 25.0435 26.852 25.1835 26.04 25.1835Z" fill="white"/>
|
||||
<path d="M156.305 17.1995H158.3V18.3395C158.71 17.4595 159.505 17.0195 160.685 17.0195C161.235 17.0195 161.73 17.1495 162.17 17.4095C162.62 17.6595 162.97 18.0195 163.22 18.4895C163.49 17.9995 163.83 17.6345 164.24 17.3945C164.66 17.1445 165.2 17.0195 165.86 17.0195C166.42 17.0195 166.925 17.1545 167.375 17.4245C167.825 17.6845 168.18 18.0595 168.44 18.5495C168.7 19.0295 168.83 19.5895 168.83 20.2295V24.9995H166.805V20.7245C166.805 20.0745 166.665 19.5895 166.385 19.2695C166.105 18.9495 165.725 18.7895 165.245 18.7895C164.735 18.7895 164.34 18.9345 164.06 19.2245C163.79 19.5145 163.655 20.0145 163.655 20.7245V24.9995H161.63V20.7245C161.63 20.0745 161.48 19.5895 161.18 19.2695C160.88 18.9495 160.47 18.7895 159.95 18.7895C159.49 18.7895 159.105 18.9545 158.795 19.2845C158.485 19.6045 158.33 20.0845 158.33 20.7245V24.9995H156.305V17.1995Z" fill="white"/>
|
||||
<path d="M151.253 25.1945C150.463 25.1945 149.753 25.0145 149.123 24.6545C148.493 24.2845 147.998 23.7895 147.638 23.1695C147.278 22.5395 147.098 21.8545 147.098 21.1145C147.098 20.3745 147.278 19.6895 147.638 19.0595C147.998 18.4295 148.493 17.9345 149.123 17.5745C149.753 17.2045 150.463 17.0195 151.253 17.0195C152.043 17.0195 152.753 17.2045 153.383 17.5745C154.013 17.9345 154.508 18.4295 154.868 19.0595C155.228 19.6895 155.408 20.3745 155.408 21.1145C155.408 21.8545 155.228 22.5395 154.868 23.1695C154.508 23.7895 154.013 24.2845 153.383 24.6545C152.753 25.0145 152.043 25.1945 151.253 25.1945ZM149.123 21.1145C149.123 21.5245 149.208 21.8995 149.378 22.2395C149.558 22.5795 149.808 22.8495 150.128 23.0495C150.458 23.2495 150.833 23.3495 151.253 23.3495C151.673 23.3495 152.043 23.2495 152.363 23.0495C152.693 22.8495 152.943 22.5795 153.113 22.2395C153.293 21.8995 153.383 21.5245 153.383 21.1145C153.383 20.7045 153.293 20.3295 153.113 19.9895C152.933 19.6495 152.683 19.3795 152.363 19.1795C152.043 18.9695 151.673 18.8645 151.253 18.8645C150.833 18.8645 150.463 18.9695 150.143 19.1795C149.823 19.3795 149.573 19.6495 149.393 19.9895C149.213 20.3295 149.123 20.7045 149.123 21.1145Z" fill="white"/>
|
||||
<path d="M143.75 25.1939C143.02 25.1939 142.345 25.0139 141.725 24.6539C141.115 24.2839 140.63 23.7839 140.27 23.1539C139.91 22.5239 139.73 21.8389 139.73 21.0989C139.73 20.3489 139.91 19.6639 140.27 19.0439C140.63 18.4139 141.13 17.9189 141.77 17.5589C142.41 17.1889 143.125 17.0039 143.915 17.0039C144.605 17.0039 145.225 17.1339 145.775 17.3939C146.325 17.6439 146.765 17.9989 147.095 18.4589L145.535 19.7339C145.355 19.4639 145.12 19.2489 144.83 19.0889C144.54 18.9289 144.225 18.8489 143.885 18.8489C143.465 18.8489 143.095 18.9539 142.775 19.1639C142.455 19.3639 142.205 19.6339 142.025 19.9739C141.845 20.3139 141.755 20.6889 141.755 21.0989C141.755 21.4989 141.845 21.8739 142.025 22.2239C142.215 22.5639 142.475 22.8389 142.805 23.0489C143.135 23.2489 143.51 23.3489 143.93 23.3489C144.3 23.3489 144.62 23.2689 144.89 23.1089C145.16 22.9489 145.405 22.7189 145.625 22.4189L147.095 23.6939C146.315 24.6939 145.2 25.1939 143.75 25.1939Z" fill="white"/>
|
||||
<path d="M138.065 25.1953C137.685 25.1953 137.365 25.0653 137.105 24.8053C136.845 24.5453 136.715 24.2353 136.715 23.8753C136.715 23.5153 136.845 23.2103 137.105 22.9603C137.365 22.7003 137.685 22.5703 138.065 22.5703C138.445 22.5703 138.76 22.7003 139.01 22.9603C139.27 23.2103 139.4 23.5153 139.4 23.8753C139.4 24.2453 139.27 24.5603 139.01 24.8203C138.76 25.0703 138.445 25.1953 138.065 25.1953Z" fill="white"/>
|
||||
<path d="M133.762 14.0508H135.787V25.0008H133.762V14.0508Z" fill="white"/>
|
||||
<path d="M128.074 25.1952C127.374 25.1952 126.729 25.0152 126.139 24.6552C125.549 24.2852 125.079 23.7902 124.729 23.1702C124.389 22.5502 124.219 21.8702 124.219 21.1302C124.219 20.3902 124.389 19.7102 124.729 19.0902C125.079 18.4602 125.549 17.9602 126.139 17.5902C126.729 17.2202 127.374 17.0352 128.074 17.0352C128.714 17.0352 129.229 17.1552 129.619 17.3952C130.019 17.6352 130.329 17.9802 130.549 18.4302V17.2002H132.559V25.0002H130.594V23.7252C130.364 24.1952 130.049 24.5602 129.649 24.8202C129.259 25.0702 128.734 25.1952 128.074 25.1952ZM126.244 21.1152C126.244 21.5152 126.334 21.8902 126.514 22.2402C126.704 22.5802 126.959 22.8552 127.279 23.0652C127.609 23.2652 127.984 23.3652 128.404 23.3652C128.834 23.3652 129.214 23.2652 129.544 23.0652C129.884 22.8652 130.144 22.5952 130.324 22.2552C130.504 21.9152 130.594 21.5402 130.594 21.1302C130.594 20.7202 130.504 20.3452 130.324 20.0052C130.144 19.6552 129.884 19.3802 129.544 19.1802C129.214 18.9702 128.834 18.8652 128.404 18.8652C127.984 18.8652 127.609 18.9702 127.279 19.1802C126.949 19.3802 126.694 19.6502 126.514 19.9902C126.334 20.3302 126.244 20.7052 126.244 21.1152Z" fill="white"/>
|
||||
<path d="M120.276 25.1947C119.256 25.1947 118.326 24.9547 117.486 24.4747C116.656 23.9847 116.001 23.3247 115.521 22.4947C115.041 21.6647 114.801 20.7547 114.801 19.7647C114.801 18.7647 115.031 17.8497 115.491 17.0197C115.961 16.1797 116.611 15.5197 117.441 15.0397C118.271 14.5497 119.216 14.3047 120.276 14.3047C121.126 14.3047 121.856 14.4397 122.466 14.7097C123.086 14.9697 123.636 15.3847 124.116 15.9547L122.631 17.2447C122.001 16.5847 121.216 16.2547 120.276 16.2547C119.616 16.2547 119.036 16.4147 118.536 16.7347C118.036 17.0447 117.651 17.4697 117.381 18.0097C117.111 18.5397 116.976 19.1247 116.976 19.7647C116.976 20.4047 117.111 20.9897 117.381 21.5197C117.661 22.0497 118.051 22.4697 118.551 22.7797C119.061 23.0897 119.651 23.2447 120.321 23.2447C121.301 23.2447 122.101 22.9047 122.721 22.2247L124.251 23.5297C123.831 24.0397 123.281 24.4447 122.601 24.7447C121.931 25.0447 121.156 25.1947 120.276 25.1947Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4339_81">
|
||||
<rect width="189" height="40" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg width="189" height="40" viewBox="0 0 189 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M169 0.5H20C9.23045 0.5 0.5 9.23045 0.5 20C0.5 30.7696 9.23045 39.5 20 39.5H169C179.77 39.5 188.5 30.7696 188.5 20C188.5 9.23045 179.77 0.5 169 0.5Z" fill="white"/>
|
||||
<path d="M169 0.5H20C9.23045 0.5 0.5 9.23045 0.5 20C0.5 30.7696 9.23045 39.5 20 39.5H169C179.77 39.5 188.5 30.7696 188.5 20C188.5 9.23045 179.77 0.5 169 0.5Z" stroke="#747775"/>
|
||||
<path d="M102.289 24.9992V15.1992H103.591V19.0912H103.675C103.862 18.7739 104.123 18.4986 104.459 18.2652C104.795 18.0226 105.229 17.9012 105.761 17.9012C106.265 17.9012 106.708 18.0132 107.091 18.2372C107.483 18.4519 107.786 18.7599 108.001 19.1612C108.225 19.5626 108.337 20.0292 108.337 20.5612V24.9992H107.021V20.8692C107.021 20.3279 106.881 19.8986 106.601 19.5812C106.33 19.2546 105.948 19.0912 105.453 19.0912C104.884 19.0912 104.431 19.2546 104.095 19.5812C103.759 19.8986 103.591 20.3279 103.591 20.8692V24.9992H102.289Z" fill="black"/>
|
||||
<path d="M99.7917 25.181C99.0824 25.1624 98.5271 24.9617 98.1257 24.579C97.7337 24.187 97.5377 23.641 97.5377 22.941V19.259H96.0117V18.083H97.5377V16.403L98.8537 16.207V18.083H101.038V19.259H98.8537V22.857C98.8537 23.6224 99.2504 24.005 100.044 24.005C100.202 24.005 100.361 24.0004 100.52 23.991C100.688 23.9724 100.86 23.9397 101.038 23.893V25.041C100.786 25.097 100.571 25.1344 100.394 25.153C100.216 25.181 100.016 25.1904 99.7917 25.181Z" fill="black"/>
|
||||
<path d="M94.047 16.5872C93.795 16.5872 93.5897 16.5079 93.431 16.3492C93.2817 16.1906 93.207 15.9946 93.207 15.7612C93.207 15.5466 93.2817 15.3599 93.431 15.2012C93.5897 15.0332 93.795 14.9492 94.047 14.9492C94.2804 14.9492 94.4764 15.0332 94.635 15.2012C94.7937 15.3599 94.873 15.5466 94.873 15.7612C94.873 15.9946 94.7937 16.1906 94.635 16.3492C94.4764 16.5079 94.2804 16.5872 94.047 16.5872ZM93.389 25.0012V18.0852H94.691V25.0012H93.389Z" fill="black"/>
|
||||
<path d="M83.5665 25.0019L81.3125 18.0859H82.8105L83.9865 22.2579C84.0612 22.5193 84.1312 22.7899 84.1965 23.0699C84.2618 23.3499 84.3038 23.6019 84.3225 23.8259H84.4205C84.4298 23.6113 84.4625 23.3593 84.5185 23.0699C84.5745 22.7806 84.6445 22.5053 84.7285 22.2439L85.9885 18.0859H87.5285L88.7885 22.2439C88.8725 22.5053 88.9425 22.7806 88.9985 23.0699C89.0638 23.3593 89.1058 23.6113 89.1245 23.8259H89.2225C89.2318 23.6113 89.2692 23.3593 89.3345 23.0699C89.3998 22.7806 89.4652 22.5099 89.5305 22.2579L90.7205 18.0859H92.1485L89.9085 25.0019H88.4105L87.1225 20.8299C87.0198 20.5126 86.9452 20.2279 86.8985 19.9759C86.8518 19.7239 86.8145 19.4859 86.7865 19.2619H86.7025C86.6745 19.4859 86.6372 19.7239 86.5905 19.9759C86.5438 20.2186 86.4738 20.5033 86.3805 20.8299L85.1065 25.0019H83.5665Z" fill="black"/>
|
||||
<path d="M74.363 25.1823C73.6443 25.1823 73.019 25.0283 72.487 24.7203C71.955 24.403 71.5396 23.969 71.241 23.4183C70.9423 22.8677 70.793 22.2423 70.793 21.5423C70.793 20.833 70.9423 20.2077 71.241 19.6663C71.549 19.1157 71.969 18.6863 72.501 18.3783C73.033 18.061 73.6396 17.9023 74.321 17.9023C75.0116 17.9023 75.6043 18.0563 76.099 18.3643C76.603 18.663 76.9903 19.083 77.261 19.6243C77.5316 20.1657 77.667 20.791 77.667 21.5003C77.667 21.5843 77.6623 21.673 77.653 21.7663C77.653 21.8597 77.6436 21.9343 77.625 21.9903H72.137C72.2116 22.5783 72.4543 23.059 72.865 23.4323C73.2756 23.7963 73.7796 23.9783 74.377 23.9783C75.2636 23.9783 75.931 23.675 76.379 23.0683L77.289 23.8943C76.9716 24.305 76.561 24.6223 76.057 24.8463C75.553 25.0703 74.9883 25.1823 74.363 25.1823ZM74.293 19.0643C73.7236 19.0643 73.2476 19.223 72.865 19.5403C72.4823 19.8577 72.2443 20.315 72.151 20.9123H76.323C76.2483 20.3523 76.0383 19.9043 75.693 19.5683C75.357 19.2323 74.8903 19.0643 74.293 19.0643Z" fill="black"/>
|
||||
<path d="M65.8716 25.1839C65.3956 25.1839 64.9662 25.0766 64.5836 24.8619C64.2102 24.6379 63.9116 24.3253 63.6876 23.9239C63.4636 23.5226 63.3516 23.0513 63.3516 22.5099V18.0859H64.6536V22.2159C64.6536 22.7573 64.7936 23.1913 65.0736 23.5179C65.3629 23.8353 65.7316 23.9939 66.1796 23.9939C66.7116 23.9939 67.1409 23.8353 67.4676 23.5179C67.7942 23.1913 67.9576 22.7573 67.9576 22.2159V18.0859H69.2736V25.0019H68.0136V23.9939H67.9296C67.7522 24.3113 67.4956 24.5913 67.1596 24.8339C66.8236 25.0673 66.3942 25.1839 65.8716 25.1839Z" fill="black"/>
|
||||
<path d="M55.4219 25.0003V18.0843H56.6679V19.0923H56.7519C56.9385 18.7843 57.1999 18.509 57.5359 18.2663C57.8719 18.0237 58.3199 17.9023 58.8799 17.9023C59.3745 17.9023 59.8179 18.0143 60.2099 18.2383C60.6019 18.4623 60.9099 18.775 61.1339 19.1763C61.3579 19.5683 61.4699 20.035 61.4699 20.5763V25.0003H60.1539V20.8703C60.1539 20.329 60.0092 19.8997 59.7199 19.5823C59.4399 19.2557 59.0525 19.0923 58.5579 19.0923C57.9979 19.0923 57.5499 19.2557 57.2139 19.5823C56.8872 19.8997 56.7239 20.329 56.7239 20.8703V25.0003H55.4219Z" fill="black"/>
|
||||
<path d="M52.7697 16.5872C52.5177 16.5872 52.3124 16.5079 52.1537 16.3492C52.0044 16.1906 51.9297 15.9946 51.9297 15.7612C51.9297 15.5466 52.0044 15.3599 52.1537 15.2012C52.3124 15.0332 52.5177 14.9492 52.7697 14.9492C53.003 14.9492 53.199 15.0332 53.3577 15.2012C53.5164 15.3599 53.5957 15.5466 53.5957 15.7612C53.5957 15.9946 53.5164 16.1906 53.3577 16.3492C53.199 16.5079 53.003 16.5872 52.7697 16.5872ZM52.1117 25.0012V18.0852H53.4137V25.0012H52.1117Z" fill="black"/>
|
||||
<path d="M49.6159 25.181C48.9066 25.1624 48.3513 24.9617 47.9499 24.579C47.5579 24.187 47.3619 23.641 47.3619 22.941V19.259H45.8359V18.083H47.3619V16.403L48.6779 16.207V18.083H50.8619V19.259H48.6779V22.857C48.6779 23.6224 49.0746 24.005 49.8679 24.005C50.0266 24.005 50.1853 24.0004 50.3439 23.991C50.5119 23.9724 50.6846 23.9397 50.8619 23.893V25.041C50.6099 25.097 50.3953 25.1344 50.2179 25.153C50.0406 25.181 49.8399 25.1904 49.6159 25.181Z" fill="black"/>
|
||||
<path d="M38.7148 25.0003V18.0843H39.9608V19.0923H40.0448C40.2315 18.7843 40.4928 18.509 40.8288 18.2663C41.1648 18.0237 41.6128 17.9023 42.1728 17.9023C42.6675 17.9023 43.1108 18.0143 43.5028 18.2383C43.8948 18.4623 44.2028 18.775 44.4268 19.1763C44.6508 19.5683 44.7628 20.035 44.7628 20.5763V25.0003H43.4468V20.8703C43.4468 20.329 43.3022 19.8997 43.0128 19.5823C42.7328 19.2557 42.3455 19.0923 41.8508 19.0923C41.2908 19.0923 40.8428 19.2557 40.5068 19.5823C40.1802 19.8997 40.0168 20.329 40.0168 20.8703V25.0003H38.7148Z" fill="black"/>
|
||||
<path d="M33.6475 25.1823C32.9661 25.1823 32.3548 25.0283 31.8135 24.7203C31.2815 24.403 30.8615 23.9737 30.5535 23.4323C30.2548 22.8817 30.1055 22.2563 30.1055 21.5563C30.1055 20.847 30.2548 20.2217 30.5535 19.6803C30.8615 19.1297 31.2815 18.6957 31.8135 18.3783C32.3548 18.061 32.9661 17.9023 33.6475 17.9023C34.3288 17.9023 34.9355 18.061 35.4675 18.3783C36.0088 18.6863 36.4288 19.1157 36.7275 19.6663C37.0355 20.2077 37.1895 20.8283 37.1895 21.5283C37.1895 22.2377 37.0355 22.8677 36.7275 23.4183C36.4288 23.969 36.0088 24.403 35.4675 24.7203C34.9355 25.0283 34.3288 25.1823 33.6475 25.1823ZM33.6475 23.9783C34.3195 23.9783 34.8608 23.7497 35.2715 23.2923C35.6821 22.835 35.8875 22.2517 35.8875 21.5423C35.8875 21.0757 35.7941 20.6603 35.6075 20.2963C35.4208 19.9323 35.1595 19.643 34.8235 19.4283C34.4875 19.2137 34.0955 19.1063 33.6475 19.1063C32.9755 19.1063 32.4341 19.335 32.0235 19.7923C31.6221 20.2497 31.4215 20.833 31.4215 21.5423C31.4215 22.009 31.5101 22.429 31.6875 22.8023C31.8741 23.1663 32.1355 23.4557 32.4715 23.6703C32.8075 23.8757 33.1995 23.9783 33.6475 23.9783Z" fill="black"/>
|
||||
<path d="M26.04 25.1835C25.3307 25.1835 24.668 25.0575 24.052 24.8055C23.4453 24.5442 22.9133 24.1849 22.456 23.7275C21.9987 23.2609 21.6393 22.7195 21.378 22.1035C21.126 21.4875 21 20.8249 21 20.1155C21 19.4062 21.126 18.7435 21.378 18.1275C21.63 17.5115 21.98 16.9702 22.428 16.5035C22.8853 16.0369 23.4173 15.6729 24.024 15.4115C24.64 15.1502 25.312 15.0195 26.04 15.0195C26.7773 15.0195 27.4493 15.1455 28.056 15.3975C28.6627 15.6495 29.19 16.0369 29.638 16.5595L28.644 17.4415C28.2987 17.0309 27.9067 16.7369 27.468 16.5595C27.0387 16.3822 26.5627 16.2935 26.04 16.2935C25.3307 16.2935 24.7007 16.4615 24.15 16.7975C23.6087 17.1335 23.184 17.5909 22.876 18.1695C22.568 18.7389 22.414 19.3875 22.414 20.1155C22.414 20.8529 22.5727 21.5062 22.89 22.0755C23.2167 22.6449 23.6553 23.0929 24.206 23.4195C24.766 23.7369 25.3913 23.8955 26.082 23.8955C26.6607 23.8955 27.16 23.8022 27.58 23.6155C28 23.4195 28.378 23.1302 28.714 22.7475L29.708 23.5735C29.2973 24.0775 28.784 24.4742 28.168 24.7635C27.5613 25.0435 26.852 25.1835 26.04 25.1835Z" fill="black"/>
|
||||
<path d="M156.305 17.1995H158.3V18.3395C158.71 17.4595 159.505 17.0195 160.685 17.0195C161.235 17.0195 161.73 17.1495 162.17 17.4095C162.62 17.6595 162.97 18.0195 163.22 18.4895C163.49 17.9995 163.83 17.6345 164.24 17.3945C164.66 17.1445 165.2 17.0195 165.86 17.0195C166.42 17.0195 166.925 17.1545 167.375 17.4245C167.825 17.6845 168.18 18.0595 168.44 18.5495C168.7 19.0295 168.83 19.5895 168.83 20.2295V24.9995H166.805V20.7245C166.805 20.0745 166.665 19.5895 166.385 19.2695C166.105 18.9495 165.725 18.7895 165.245 18.7895C164.735 18.7895 164.34 18.9345 164.06 19.2245C163.79 19.5145 163.655 20.0145 163.655 20.7245V24.9995H161.63V20.7245C161.63 20.0745 161.48 19.5895 161.18 19.2695C160.88 18.9495 160.47 18.7895 159.95 18.7895C159.49 18.7895 159.105 18.9545 158.795 19.2845C158.485 19.6045 158.33 20.0845 158.33 20.7245V24.9995H156.305V17.1995Z" fill="black"/>
|
||||
<path d="M151.253 25.1945C150.463 25.1945 149.753 25.0145 149.123 24.6545C148.493 24.2845 147.998 23.7895 147.638 23.1695C147.278 22.5395 147.098 21.8545 147.098 21.1145C147.098 20.3745 147.278 19.6895 147.638 19.0595C147.998 18.4295 148.493 17.9345 149.123 17.5745C149.753 17.2045 150.463 17.0195 151.253 17.0195C152.043 17.0195 152.753 17.2045 153.383 17.5745C154.013 17.9345 154.508 18.4295 154.868 19.0595C155.228 19.6895 155.408 20.3745 155.408 21.1145C155.408 21.8545 155.228 22.5395 154.868 23.1695C154.508 23.7895 154.013 24.2845 153.383 24.6545C152.753 25.0145 152.043 25.1945 151.253 25.1945ZM149.123 21.1145C149.123 21.5245 149.208 21.8995 149.378 22.2395C149.558 22.5795 149.808 22.8495 150.128 23.0495C150.458 23.2495 150.833 23.3495 151.253 23.3495C151.673 23.3495 152.043 23.2495 152.363 23.0495C152.693 22.8495 152.943 22.5795 153.113 22.2395C153.293 21.8995 153.383 21.5245 153.383 21.1145C153.383 20.7045 153.293 20.3295 153.113 19.9895C152.933 19.6495 152.683 19.3795 152.363 19.1795C152.043 18.9695 151.673 18.8645 151.253 18.8645C150.833 18.8645 150.463 18.9695 150.143 19.1795C149.823 19.3795 149.573 19.6495 149.393 19.9895C149.213 20.3295 149.123 20.7045 149.123 21.1145Z" fill="black"/>
|
||||
<path d="M143.75 25.1939C143.02 25.1939 142.345 25.0139 141.725 24.6539C141.115 24.2839 140.63 23.7839 140.27 23.1539C139.91 22.5239 139.73 21.8389 139.73 21.0989C139.73 20.3489 139.91 19.6639 140.27 19.0439C140.63 18.4139 141.13 17.9189 141.77 17.5589C142.41 17.1889 143.125 17.0039 143.915 17.0039C144.605 17.0039 145.225 17.1339 145.775 17.3939C146.325 17.6439 146.765 17.9989 147.095 18.4589L145.535 19.7339C145.355 19.4639 145.12 19.2489 144.83 19.0889C144.54 18.9289 144.225 18.8489 143.885 18.8489C143.465 18.8489 143.095 18.9539 142.775 19.1639C142.455 19.3639 142.205 19.6339 142.025 19.9739C141.845 20.3139 141.755 20.6889 141.755 21.0989C141.755 21.4989 141.845 21.8739 142.025 22.2239C142.215 22.5639 142.475 22.8389 142.805 23.0489C143.135 23.2489 143.51 23.3489 143.93 23.3489C144.3 23.3489 144.62 23.2689 144.89 23.1089C145.16 22.9489 145.405 22.7189 145.625 22.4189L147.095 23.6939C146.315 24.6939 145.2 25.1939 143.75 25.1939Z" fill="black"/>
|
||||
<path d="M138.065 25.1953C137.685 25.1953 137.365 25.0653 137.105 24.8053C136.845 24.5453 136.715 24.2353 136.715 23.8753C136.715 23.5153 136.845 23.2103 137.105 22.9603C137.365 22.7003 137.685 22.5703 138.065 22.5703C138.445 22.5703 138.76 22.7003 139.01 22.9603C139.27 23.2103 139.4 23.5153 139.4 23.8753C139.4 24.2453 139.27 24.5603 139.01 24.8203C138.76 25.0703 138.445 25.1953 138.065 25.1953Z" fill="black"/>
|
||||
<path d="M133.762 14.0508H135.787V25.0008H133.762V14.0508Z" fill="black"/>
|
||||
<path d="M128.074 25.1952C127.374 25.1952 126.729 25.0152 126.139 24.6552C125.549 24.2852 125.079 23.7902 124.729 23.1702C124.389 22.5502 124.219 21.8702 124.219 21.1302C124.219 20.3902 124.389 19.7102 124.729 19.0902C125.079 18.4602 125.549 17.9602 126.139 17.5902C126.729 17.2202 127.374 17.0352 128.074 17.0352C128.714 17.0352 129.229 17.1552 129.619 17.3952C130.019 17.6352 130.329 17.9802 130.549 18.4302V17.2002H132.559V25.0002H130.594V23.7252C130.364 24.1952 130.049 24.5602 129.649 24.8202C129.259 25.0702 128.734 25.1952 128.074 25.1952ZM126.244 21.1152C126.244 21.5152 126.334 21.8902 126.514 22.2402C126.704 22.5802 126.959 22.8552 127.279 23.0652C127.609 23.2652 127.984 23.3652 128.404 23.3652C128.834 23.3652 129.214 23.2652 129.544 23.0652C129.884 22.8652 130.144 22.5952 130.324 22.2552C130.504 21.9152 130.594 21.5402 130.594 21.1302C130.594 20.7202 130.504 20.3452 130.324 20.0052C130.144 19.6552 129.884 19.3802 129.544 19.1802C129.214 18.9702 128.834 18.8652 128.404 18.8652C127.984 18.8652 127.609 18.9702 127.279 19.1802C126.949 19.3802 126.694 19.6502 126.514 19.9902C126.334 20.3302 126.244 20.7052 126.244 21.1152Z" fill="black"/>
|
||||
<path d="M120.276 25.1947C119.256 25.1947 118.326 24.9547 117.486 24.4747C116.656 23.9847 116.001 23.3247 115.521 22.4947C115.041 21.6647 114.801 20.7547 114.801 19.7647C114.801 18.7647 115.031 17.8497 115.491 17.0197C115.961 16.1797 116.611 15.5197 117.441 15.0397C118.271 14.5497 119.216 14.3047 120.276 14.3047C121.126 14.3047 121.856 14.4397 122.466 14.7097C123.086 14.9697 123.636 15.3847 124.116 15.9547L122.631 17.2447C122.001 16.5847 121.216 16.2547 120.276 16.2547C119.616 16.2547 119.036 16.4147 118.536 16.7347C118.036 17.0447 117.651 17.4697 117.381 18.0097C117.111 18.5397 116.976 19.1247 116.976 19.7647C116.976 20.4047 117.111 20.9897 117.381 21.5197C117.661 22.0497 118.051 22.4697 118.551 22.7797C119.061 23.0897 119.651 23.2447 120.321 23.2447C121.301 23.2447 122.101 22.9047 122.721 22.2247L124.251 23.5297C123.831 24.0397 123.281 24.4447 122.601 24.7447C121.931 25.0447 121.156 25.1947 120.276 25.1947Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg width="189" height="40" viewBox="0 0 189 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M185 0.5H4C2.067 0.5 0.5 2.067 0.5 4V36C0.5 37.933 2.067 39.5 4 39.5H185C186.933 39.5 188.5 37.933 188.5 36V4C188.5 2.067 186.933 0.5 185 0.5Z" fill="white"/>
|
||||
<path d="M102.289 24.9992V15.1992H103.591V19.0912H103.675C103.862 18.7739 104.123 18.4986 104.459 18.2652C104.795 18.0226 105.229 17.9012 105.761 17.9012C106.265 17.9012 106.708 18.0132 107.091 18.2372C107.483 18.4519 107.786 18.7599 108.001 19.1612C108.225 19.5626 108.337 20.0292 108.337 20.5612V24.9992H107.021V20.8692C107.021 20.3279 106.881 19.8986 106.601 19.5812C106.33 19.2546 105.948 19.0912 105.453 19.0912C104.884 19.0912 104.431 19.2546 104.095 19.5812C103.759 19.8986 103.591 20.3279 103.591 20.8692V24.9992H102.289Z" fill="black"/>
|
||||
<path d="M99.7917 25.181C99.0824 25.1624 98.5271 24.9617 98.1257 24.579C97.7337 24.187 97.5377 23.641 97.5377 22.941V19.259H96.0117V18.083H97.5377V16.403L98.8537 16.207V18.083H101.038V19.259H98.8537V22.857C98.8537 23.6224 99.2504 24.005 100.044 24.005C100.202 24.005 100.361 24.0004 100.52 23.991C100.688 23.9724 100.86 23.9397 101.038 23.893V25.041C100.786 25.097 100.571 25.1344 100.394 25.153C100.216 25.181 100.016 25.1904 99.7917 25.181Z" fill="black"/>
|
||||
<path d="M94.047 16.5872C93.795 16.5872 93.5897 16.5079 93.431 16.3492C93.2817 16.1906 93.207 15.9946 93.207 15.7612C93.207 15.5466 93.2817 15.3599 93.431 15.2012C93.5897 15.0332 93.795 14.9492 94.047 14.9492C94.2804 14.9492 94.4764 15.0332 94.635 15.2012C94.7937 15.3599 94.873 15.5466 94.873 15.7612C94.873 15.9946 94.7937 16.1906 94.635 16.3492C94.4764 16.5079 94.2804 16.5872 94.047 16.5872ZM93.389 25.0012V18.0852H94.691V25.0012H93.389Z" fill="black"/>
|
||||
<path d="M83.5665 25.0019L81.3125 18.0859H82.8105L83.9865 22.2579C84.0612 22.5193 84.1312 22.7899 84.1965 23.0699C84.2618 23.3499 84.3038 23.6019 84.3225 23.8259H84.4205C84.4298 23.6113 84.4625 23.3593 84.5185 23.0699C84.5745 22.7806 84.6445 22.5053 84.7285 22.2439L85.9885 18.0859H87.5285L88.7885 22.2439C88.8725 22.5053 88.9425 22.7806 88.9985 23.0699C89.0638 23.3593 89.1058 23.6113 89.1245 23.8259H89.2225C89.2318 23.6113 89.2692 23.3593 89.3345 23.0699C89.3998 22.7806 89.4652 22.5099 89.5305 22.2579L90.7205 18.0859H92.1485L89.9085 25.0019H88.4105L87.1225 20.8299C87.0198 20.5126 86.9452 20.2279 86.8985 19.9759C86.8518 19.7239 86.8145 19.4859 86.7865 19.2619H86.7025C86.6745 19.4859 86.6372 19.7239 86.5905 19.9759C86.5438 20.2186 86.4738 20.5033 86.3805 20.8299L85.1065 25.0019H83.5665Z" fill="black"/>
|
||||
<path d="M74.363 25.1823C73.6443 25.1823 73.019 25.0283 72.487 24.7203C71.955 24.403 71.5396 23.969 71.241 23.4183C70.9423 22.8677 70.793 22.2423 70.793 21.5423C70.793 20.833 70.9423 20.2077 71.241 19.6663C71.549 19.1157 71.969 18.6863 72.501 18.3783C73.033 18.061 73.6396 17.9023 74.321 17.9023C75.0116 17.9023 75.6043 18.0563 76.099 18.3643C76.603 18.663 76.9903 19.083 77.261 19.6243C77.5316 20.1657 77.667 20.791 77.667 21.5003C77.667 21.5843 77.6623 21.673 77.653 21.7663C77.653 21.8597 77.6436 21.9343 77.625 21.9903H72.137C72.2116 22.5783 72.4543 23.059 72.865 23.4323C73.2756 23.7963 73.7796 23.9783 74.377 23.9783C75.2636 23.9783 75.931 23.675 76.379 23.0683L77.289 23.8943C76.9716 24.305 76.561 24.6223 76.057 24.8463C75.553 25.0703 74.9883 25.1823 74.363 25.1823ZM74.293 19.0643C73.7236 19.0643 73.2476 19.223 72.865 19.5403C72.4823 19.8577 72.2443 20.315 72.151 20.9123H76.323C76.2483 20.3523 76.0383 19.9043 75.693 19.5683C75.357 19.2323 74.8903 19.0643 74.293 19.0643Z" fill="black"/>
|
||||
<path d="M65.8716 25.1839C65.3956 25.1839 64.9662 25.0766 64.5836 24.8619C64.2102 24.6379 63.9116 24.3253 63.6876 23.9239C63.4636 23.5226 63.3516 23.0513 63.3516 22.5099V18.0859H64.6536V22.2159C64.6536 22.7573 64.7936 23.1913 65.0736 23.5179C65.3629 23.8353 65.7316 23.9939 66.1796 23.9939C66.7116 23.9939 67.1409 23.8353 67.4676 23.5179C67.7942 23.1913 67.9576 22.7573 67.9576 22.2159V18.0859H69.2736V25.0019H68.0136V23.9939H67.9296C67.7522 24.3113 67.4956 24.5913 67.1596 24.8339C66.8236 25.0673 66.3942 25.1839 65.8716 25.1839Z" fill="black"/>
|
||||
<path d="M55.4219 25.0003V18.0843H56.6679V19.0923H56.7519C56.9385 18.7843 57.1999 18.509 57.5359 18.2663C57.8719 18.0237 58.3199 17.9023 58.8799 17.9023C59.3745 17.9023 59.8179 18.0143 60.2099 18.2383C60.6019 18.4623 60.9099 18.775 61.1339 19.1763C61.3579 19.5683 61.4699 20.035 61.4699 20.5763V25.0003H60.1539V20.8703C60.1539 20.329 60.0092 19.8997 59.7199 19.5823C59.4399 19.2557 59.0525 19.0923 58.5579 19.0923C57.9979 19.0923 57.5499 19.2557 57.2139 19.5823C56.8872 19.8997 56.7239 20.329 56.7239 20.8703V25.0003H55.4219Z" fill="black"/>
|
||||
<path d="M52.7697 16.5872C52.5177 16.5872 52.3124 16.5079 52.1537 16.3492C52.0044 16.1906 51.9297 15.9946 51.9297 15.7612C51.9297 15.5466 52.0044 15.3599 52.1537 15.2012C52.3124 15.0332 52.5177 14.9492 52.7697 14.9492C53.003 14.9492 53.199 15.0332 53.3577 15.2012C53.5164 15.3599 53.5957 15.5466 53.5957 15.7612C53.5957 15.9946 53.5164 16.1906 53.3577 16.3492C53.199 16.5079 53.003 16.5872 52.7697 16.5872ZM52.1117 25.0012V18.0852H53.4137V25.0012H52.1117Z" fill="black"/>
|
||||
<path d="M49.6159 25.181C48.9066 25.1624 48.3513 24.9617 47.9499 24.579C47.5579 24.187 47.3619 23.641 47.3619 22.941V19.259H45.8359V18.083H47.3619V16.403L48.6779 16.207V18.083H50.8619V19.259H48.6779V22.857C48.6779 23.6224 49.0746 24.005 49.8679 24.005C50.0266 24.005 50.1853 24.0004 50.3439 23.991C50.5119 23.9724 50.6846 23.9397 50.8619 23.893V25.041C50.6099 25.097 50.3953 25.1344 50.2179 25.153C50.0406 25.181 49.8399 25.1904 49.6159 25.181Z" fill="black"/>
|
||||
<path d="M38.7148 25.0003V18.0843H39.9608V19.0923H40.0448C40.2315 18.7843 40.4928 18.509 40.8288 18.2663C41.1648 18.0237 41.6128 17.9023 42.1728 17.9023C42.6675 17.9023 43.1108 18.0143 43.5028 18.2383C43.8948 18.4623 44.2028 18.775 44.4268 19.1763C44.6508 19.5683 44.7628 20.035 44.7628 20.5763V25.0003H43.4468V20.8703C43.4468 20.329 43.3022 19.8997 43.0128 19.5823C42.7328 19.2557 42.3455 19.0923 41.8508 19.0923C41.2908 19.0923 40.8428 19.2557 40.5068 19.5823C40.1802 19.8997 40.0168 20.329 40.0168 20.8703V25.0003H38.7148Z" fill="black"/>
|
||||
<path d="M33.6475 25.1823C32.9661 25.1823 32.3548 25.0283 31.8135 24.7203C31.2815 24.403 30.8615 23.9737 30.5535 23.4323C30.2548 22.8817 30.1055 22.2563 30.1055 21.5563C30.1055 20.847 30.2548 20.2217 30.5535 19.6803C30.8615 19.1297 31.2815 18.6957 31.8135 18.3783C32.3548 18.061 32.9661 17.9023 33.6475 17.9023C34.3288 17.9023 34.9355 18.061 35.4675 18.3783C36.0088 18.6863 36.4288 19.1157 36.7275 19.6663C37.0355 20.2077 37.1895 20.8283 37.1895 21.5283C37.1895 22.2377 37.0355 22.8677 36.7275 23.4183C36.4288 23.969 36.0088 24.403 35.4675 24.7203C34.9355 25.0283 34.3288 25.1823 33.6475 25.1823ZM33.6475 23.9783C34.3195 23.9783 34.8608 23.7497 35.2715 23.2923C35.6821 22.835 35.8875 22.2517 35.8875 21.5423C35.8875 21.0757 35.7941 20.6603 35.6075 20.2963C35.4208 19.9323 35.1595 19.643 34.8235 19.4283C34.4875 19.2137 34.0955 19.1063 33.6475 19.1063C32.9755 19.1063 32.4341 19.335 32.0235 19.7923C31.6221 20.2497 31.4215 20.833 31.4215 21.5423C31.4215 22.009 31.5101 22.429 31.6875 22.8023C31.8741 23.1663 32.1355 23.4557 32.4715 23.6703C32.8075 23.8757 33.1995 23.9783 33.6475 23.9783Z" fill="black"/>
|
||||
<path d="M26.04 25.1835C25.3307 25.1835 24.668 25.0575 24.052 24.8055C23.4453 24.5442 22.9133 24.1849 22.456 23.7275C21.9987 23.2609 21.6393 22.7195 21.378 22.1035C21.126 21.4875 21 20.8249 21 20.1155C21 19.4062 21.126 18.7435 21.378 18.1275C21.63 17.5115 21.98 16.9702 22.428 16.5035C22.8853 16.0369 23.4173 15.6729 24.024 15.4115C24.64 15.1502 25.312 15.0195 26.04 15.0195C26.7773 15.0195 27.4493 15.1455 28.056 15.3975C28.6627 15.6495 29.19 16.0369 29.638 16.5595L28.644 17.4415C28.2987 17.0309 27.9067 16.7369 27.468 16.5595C27.0387 16.3822 26.5627 16.2935 26.04 16.2935C25.3307 16.2935 24.7007 16.4615 24.15 16.7975C23.6087 17.1335 23.184 17.5909 22.876 18.1695C22.568 18.7389 22.414 19.3875 22.414 20.1155C22.414 20.8529 22.5727 21.5062 22.89 22.0755C23.2167 22.6449 23.6553 23.0929 24.206 23.4195C24.766 23.7369 25.3913 23.8955 26.082 23.8955C26.6607 23.8955 27.16 23.8022 27.58 23.6155C28 23.4195 28.378 23.1302 28.714 22.7475L29.708 23.5735C29.2973 24.0775 28.784 24.4742 28.168 24.7635C27.5613 25.0435 26.852 25.1835 26.04 25.1835Z" fill="black"/>
|
||||
<path d="M156.305 17.1995H158.3V18.3395C158.71 17.4595 159.505 17.0195 160.685 17.0195C161.235 17.0195 161.73 17.1495 162.17 17.4095C162.62 17.6595 162.97 18.0195 163.22 18.4895C163.49 17.9995 163.83 17.6345 164.24 17.3945C164.66 17.1445 165.2 17.0195 165.86 17.0195C166.42 17.0195 166.925 17.1545 167.375 17.4245C167.825 17.6845 168.18 18.0595 168.44 18.5495C168.7 19.0295 168.83 19.5895 168.83 20.2295V24.9995H166.805V20.7245C166.805 20.0745 166.665 19.5895 166.385 19.2695C166.105 18.9495 165.725 18.7895 165.245 18.7895C164.735 18.7895 164.34 18.9345 164.06 19.2245C163.79 19.5145 163.655 20.0145 163.655 20.7245V24.9995H161.63V20.7245C161.63 20.0745 161.48 19.5895 161.18 19.2695C160.88 18.9495 160.47 18.7895 159.95 18.7895C159.49 18.7895 159.105 18.9545 158.795 19.2845C158.485 19.6045 158.33 20.0845 158.33 20.7245V24.9995H156.305V17.1995Z" fill="black"/>
|
||||
<path d="M151.253 25.1945C150.463 25.1945 149.753 25.0145 149.123 24.6545C148.493 24.2845 147.998 23.7895 147.638 23.1695C147.278 22.5395 147.098 21.8545 147.098 21.1145C147.098 20.3745 147.278 19.6895 147.638 19.0595C147.998 18.4295 148.493 17.9345 149.123 17.5745C149.753 17.2045 150.463 17.0195 151.253 17.0195C152.043 17.0195 152.753 17.2045 153.383 17.5745C154.013 17.9345 154.508 18.4295 154.868 19.0595C155.228 19.6895 155.408 20.3745 155.408 21.1145C155.408 21.8545 155.228 22.5395 154.868 23.1695C154.508 23.7895 154.013 24.2845 153.383 24.6545C152.753 25.0145 152.043 25.1945 151.253 25.1945ZM149.123 21.1145C149.123 21.5245 149.208 21.8995 149.378 22.2395C149.558 22.5795 149.808 22.8495 150.128 23.0495C150.458 23.2495 150.833 23.3495 151.253 23.3495C151.673 23.3495 152.043 23.2495 152.363 23.0495C152.693 22.8495 152.943 22.5795 153.113 22.2395C153.293 21.8995 153.383 21.5245 153.383 21.1145C153.383 20.7045 153.293 20.3295 153.113 19.9895C152.933 19.6495 152.683 19.3795 152.363 19.1795C152.043 18.9695 151.673 18.8645 151.253 18.8645C150.833 18.8645 150.463 18.9695 150.143 19.1795C149.823 19.3795 149.573 19.6495 149.393 19.9895C149.213 20.3295 149.123 20.7045 149.123 21.1145Z" fill="black"/>
|
||||
<path d="M143.75 25.1939C143.02 25.1939 142.345 25.0139 141.725 24.6539C141.115 24.2839 140.63 23.7839 140.27 23.1539C139.91 22.5239 139.73 21.8389 139.73 21.0989C139.73 20.3489 139.91 19.6639 140.27 19.0439C140.63 18.4139 141.13 17.9189 141.77 17.5589C142.41 17.1889 143.125 17.0039 143.915 17.0039C144.605 17.0039 145.225 17.1339 145.775 17.3939C146.325 17.6439 146.765 17.9989 147.095 18.4589L145.535 19.7339C145.355 19.4639 145.12 19.2489 144.83 19.0889C144.54 18.9289 144.225 18.8489 143.885 18.8489C143.465 18.8489 143.095 18.9539 142.775 19.1639C142.455 19.3639 142.205 19.6339 142.025 19.9739C141.845 20.3139 141.755 20.6889 141.755 21.0989C141.755 21.4989 141.845 21.8739 142.025 22.2239C142.215 22.5639 142.475 22.8389 142.805 23.0489C143.135 23.2489 143.51 23.3489 143.93 23.3489C144.3 23.3489 144.62 23.2689 144.89 23.1089C145.16 22.9489 145.405 22.7189 145.625 22.4189L147.095 23.6939C146.315 24.6939 145.2 25.1939 143.75 25.1939Z" fill="black"/>
|
||||
<path d="M138.065 25.1953C137.685 25.1953 137.365 25.0653 137.105 24.8053C136.845 24.5453 136.715 24.2353 136.715 23.8753C136.715 23.5153 136.845 23.2103 137.105 22.9603C137.365 22.7003 137.685 22.5703 138.065 22.5703C138.445 22.5703 138.76 22.7003 139.01 22.9603C139.27 23.2103 139.4 23.5153 139.4 23.8753C139.4 24.2453 139.27 24.5603 139.01 24.8203C138.76 25.0703 138.445 25.1953 138.065 25.1953Z" fill="black"/>
|
||||
<path d="M133.762 14.0508H135.787V25.0008H133.762V14.0508Z" fill="black"/>
|
||||
<path d="M128.074 25.1952C127.374 25.1952 126.729 25.0152 126.139 24.6552C125.549 24.2852 125.079 23.7902 124.729 23.1702C124.389 22.5502 124.219 21.8702 124.219 21.1302C124.219 20.3902 124.389 19.7102 124.729 19.0902C125.079 18.4602 125.549 17.9602 126.139 17.5902C126.729 17.2202 127.374 17.0352 128.074 17.0352C128.714 17.0352 129.229 17.1552 129.619 17.3952C130.019 17.6352 130.329 17.9802 130.549 18.4302V17.2002H132.559V25.0002H130.594V23.7252C130.364 24.1952 130.049 24.5602 129.649 24.8202C129.259 25.0702 128.734 25.1952 128.074 25.1952ZM126.244 21.1152C126.244 21.5152 126.334 21.8902 126.514 22.2402C126.704 22.5802 126.959 22.8552 127.279 23.0652C127.609 23.2652 127.984 23.3652 128.404 23.3652C128.834 23.3652 129.214 23.2652 129.544 23.0652C129.884 22.8652 130.144 22.5952 130.324 22.2552C130.504 21.9152 130.594 21.5402 130.594 21.1302C130.594 20.7202 130.504 20.3452 130.324 20.0052C130.144 19.6552 129.884 19.3802 129.544 19.1802C129.214 18.9702 128.834 18.8652 128.404 18.8652C127.984 18.8652 127.609 18.9702 127.279 19.1802C126.949 19.3802 126.694 19.6502 126.514 19.9902C126.334 20.3302 126.244 20.7052 126.244 21.1152Z" fill="black"/>
|
||||
<path d="M120.276 25.1947C119.256 25.1947 118.326 24.9547 117.486 24.4747C116.656 23.9847 116.001 23.3247 115.521 22.4947C115.041 21.6647 114.801 20.7547 114.801 19.7647C114.801 18.7647 115.031 17.8497 115.491 17.0197C115.961 16.1797 116.611 15.5197 117.441 15.0397C118.271 14.5497 119.216 14.3047 120.276 14.3047C121.126 14.3047 121.856 14.4397 122.466 14.7097C123.086 14.9697 123.636 15.3847 124.116 15.9547L122.631 17.2447C122.001 16.5847 121.216 16.2547 120.276 16.2547C119.616 16.2547 119.036 16.4147 118.536 16.7347C118.036 17.0447 117.651 17.4697 117.381 18.0097C117.111 18.5397 116.976 19.1247 116.976 19.7647C116.976 20.4047 117.111 20.9897 117.381 21.5197C117.661 22.0497 118.051 22.4697 118.551 22.7797C119.061 23.0897 119.651 23.2447 120.321 23.2447C121.301 23.2447 122.101 22.9047 122.721 22.2247L124.251 23.5297C123.831 24.0397 123.281 24.4447 122.601 24.7447C121.931 25.0447 121.156 25.1947 120.276 25.1947Z" fill="black"/>
|
||||
<path d="M185 0.5H4C2.067 0.5 0.5 2.067 0.5 4V36C0.5 37.933 2.067 39.5 4 39.5H185C186.933 39.5 188.5 37.933 188.5 36V4C188.5 2.067 186.933 0.5 185 0.5Z" stroke="#747775"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg width="189" height="40" viewBox="0 0 189 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M169 0H20C8.9543 0 0 8.9543 0 20C0 31.0457 8.9543 40 20 40H169C180.046 40 189 31.0457 189 20C189 8.9543 180.046 0 169 0Z" fill="#F2F2F2"/>
|
||||
<path d="M101.289 24.9484V15.1484H102.591V19.0404H102.675C102.862 18.7231 103.123 18.4478 103.459 18.2144C103.795 17.9718 104.229 17.8504 104.761 17.8504C105.265 17.8504 105.708 17.9624 106.091 18.1864C106.483 18.4011 106.786 18.7091 107.001 19.1104C107.225 19.5118 107.337 19.9784 107.337 20.5104V24.9484H106.021V20.8184C106.021 20.2771 105.881 19.8478 105.601 19.5304C105.33 19.2038 104.948 19.0404 104.453 19.0404C103.884 19.0404 103.431 19.2038 103.095 19.5304C102.759 19.8478 102.591 20.2771 102.591 20.8184V24.9484H101.289Z" fill="black"/>
|
||||
<path d="M98.7917 25.1303C98.0824 25.1116 97.5271 24.9109 97.1257 24.5282C96.7337 24.1362 96.5377 23.5903 96.5377 22.8903V19.2083H95.0117V18.0322H96.5377V16.3523L97.8537 16.1562V18.0322H100.038V19.2083H97.8537V22.8063C97.8537 23.5716 98.2504 23.9542 99.0437 23.9542C99.2024 23.9542 99.3611 23.9496 99.5197 23.9402C99.6877 23.9216 99.8604 23.8889 100.038 23.8423V24.9902C99.7857 25.0462 99.5711 25.0836 99.3937 25.1023C99.2164 25.1303 99.0157 25.1396 98.7917 25.1303Z" fill="black"/>
|
||||
<path d="M93.047 16.5364C92.795 16.5364 92.5897 16.4571 92.431 16.2984C92.2817 16.1398 92.207 15.9438 92.207 15.7104C92.207 15.4958 92.2817 15.3091 92.431 15.1504C92.5897 14.9824 92.795 14.8984 93.047 14.8984C93.2804 14.8984 93.4764 14.9824 93.635 15.1504C93.7937 15.3091 93.873 15.4958 93.873 15.7104C93.873 15.9438 93.7937 16.1398 93.635 16.2984C93.4764 16.4571 93.2804 16.5364 93.047 16.5364ZM92.389 24.9504V18.0344H93.691V24.9504H92.389Z" fill="black"/>
|
||||
<path d="M82.5665 24.9512L80.3125 18.0352H81.8105L82.9865 22.2072C83.0612 22.4685 83.1312 22.7392 83.1965 23.0192C83.2618 23.2992 83.3038 23.5512 83.3225 23.7752H83.4205C83.4298 23.5605 83.4625 23.3085 83.5185 23.0192C83.5745 22.7298 83.6445 22.4545 83.7285 22.1932L84.9885 18.0352H86.5285L87.7885 22.1932C87.8725 22.4545 87.9425 22.7298 87.9985 23.0192C88.0638 23.3085 88.1058 23.5605 88.1245 23.7752H88.2225C88.2318 23.5605 88.2692 23.3085 88.3345 23.0192C88.3998 22.7298 88.4652 22.4592 88.5305 22.2072L89.7205 18.0352H91.1485L88.9085 24.9512H87.4105L86.1225 20.7792C86.0198 20.4618 85.9452 20.1772 85.8985 19.9252C85.8518 19.6732 85.8145 19.4352 85.7865 19.2112H85.7025C85.6745 19.4352 85.6372 19.6732 85.5905 19.9252C85.5438 20.1678 85.4738 20.4525 85.3805 20.7792L84.1065 24.9512H82.5665Z" fill="black"/>
|
||||
<path d="M73.363 25.1316C72.6443 25.1316 72.019 24.9776 71.487 24.6696C70.955 24.3522 70.5396 23.9182 70.241 23.3676C69.9423 22.8169 69.793 22.1916 69.793 21.4916C69.793 20.7822 69.9423 20.1569 70.241 19.6156C70.549 19.0649 70.969 18.6356 71.501 18.3276C72.033 18.0102 72.6396 17.8516 73.321 17.8516C74.0116 17.8516 74.6043 18.0056 75.099 18.3136C75.603 18.6122 75.9903 19.0322 76.261 19.5736C76.5316 20.1149 76.667 20.7402 76.667 21.4496C76.667 21.5336 76.6623 21.6222 76.653 21.7156C76.653 21.8089 76.6436 21.8836 76.625 21.9396H71.137C71.2116 22.5276 71.4543 23.0082 71.865 23.3816C72.2756 23.7456 72.7796 23.9276 73.377 23.9276C74.2636 23.9276 74.931 23.6242 75.379 23.0176L76.289 23.8436C75.9716 24.2542 75.561 24.5716 75.057 24.7956C74.553 25.0196 73.9883 25.1316 73.363 25.1316ZM73.293 19.0136C72.7236 19.0136 72.2476 19.1722 71.865 19.4896C71.4823 19.8069 71.2443 20.2642 71.151 20.8616H75.323C75.2483 20.3016 75.0383 19.8536 74.693 19.5176C74.357 19.1816 73.8903 19.0136 73.293 19.0136Z" fill="black"/>
|
||||
<path d="M64.8716 25.1332C64.3956 25.1332 63.9662 25.0258 63.5836 24.8112C63.2102 24.5872 62.9116 24.2745 62.6876 23.8732C62.4636 23.4718 62.3516 23.0005 62.3516 22.4592V18.0352H63.6536V22.1652C63.6536 22.7065 63.7936 23.1405 64.0736 23.4672C64.3629 23.7845 64.7316 23.9432 65.1796 23.9432C65.7116 23.9432 66.1409 23.7845 66.4676 23.4672C66.7942 23.1405 66.9576 22.7065 66.9576 22.1652V18.0352H68.2736V24.9512H67.0136V23.9432H66.9296C66.7522 24.2605 66.4956 24.5405 66.1596 24.7832C65.8236 25.0165 65.3942 25.1332 64.8716 25.1332Z" fill="black"/>
|
||||
<path d="M54.4219 24.9496V18.0336H55.6679V19.0416H55.7519C55.9385 18.7336 56.1999 18.4582 56.5359 18.2156C56.8719 17.9729 57.3199 17.8516 57.8799 17.8516C58.3745 17.8516 58.8179 17.9636 59.2099 18.1876C59.6019 18.4116 59.9099 18.7242 60.1339 19.1256C60.3579 19.5176 60.4699 19.9842 60.4699 20.5256V24.9496H59.1539V20.8196C59.1539 20.2782 59.0092 19.8489 58.7199 19.5316C58.4399 19.2049 58.0525 19.0416 57.5579 19.0416C56.9979 19.0416 56.5499 19.2049 56.2139 19.5316C55.8872 19.8489 55.7239 20.2782 55.7239 20.8196V24.9496H54.4219Z" fill="black"/>
|
||||
<path d="M51.7697 16.5364C51.5177 16.5364 51.3124 16.4571 51.1537 16.2984C51.0044 16.1398 50.9297 15.9438 50.9297 15.7104C50.9297 15.4958 51.0044 15.3091 51.1537 15.1504C51.3124 14.9824 51.5177 14.8984 51.7697 14.8984C52.003 14.8984 52.199 14.9824 52.3577 15.1504C52.5164 15.3091 52.5957 15.4958 52.5957 15.7104C52.5957 15.9438 52.5164 16.1398 52.3577 16.2984C52.199 16.4571 52.003 16.5364 51.7697 16.5364ZM51.1117 24.9504V18.0344H52.4137V24.9504H51.1117Z" fill="black"/>
|
||||
<path d="M48.6159 25.1303C47.9066 25.1116 47.3513 24.9109 46.9499 24.5282C46.5579 24.1362 46.3619 23.5903 46.3619 22.8903V19.2083H44.8359V18.0322H46.3619V16.3523L47.6779 16.1562V18.0322H49.8619V19.2083H47.6779V22.8063C47.6779 23.5716 48.0746 23.9542 48.8679 23.9542C49.0266 23.9542 49.1853 23.9496 49.3439 23.9402C49.5119 23.9216 49.6846 23.8889 49.8619 23.8423V24.9902C49.6099 25.0462 49.3953 25.0836 49.2179 25.1023C49.0406 25.1303 48.8399 25.1396 48.6159 25.1303Z" fill="black"/>
|
||||
<path d="M37.7148 24.9496V18.0336H38.9608V19.0416H39.0448C39.2315 18.7336 39.4928 18.4582 39.8288 18.2156C40.1648 17.9729 40.6128 17.8516 41.1728 17.8516C41.6675 17.8516 42.1108 17.9636 42.5028 18.1876C42.8948 18.4116 43.2028 18.7242 43.4268 19.1256C43.6508 19.5176 43.7628 19.9842 43.7628 20.5256V24.9496H42.4468V20.8196C42.4468 20.2782 42.3022 19.8489 42.0128 19.5316C41.7328 19.2049 41.3455 19.0416 40.8508 19.0416C40.2908 19.0416 39.8428 19.2049 39.5068 19.5316C39.1802 19.8489 39.0168 20.2782 39.0168 20.8196V24.9496H37.7148Z" fill="black"/>
|
||||
<path d="M32.6475 25.1316C31.9661 25.1316 31.3548 24.9776 30.8135 24.6696C30.2815 24.3522 29.8615 23.9229 29.5535 23.3816C29.2548 22.8309 29.1055 22.2056 29.1055 21.5056C29.1055 20.7962 29.2548 20.1709 29.5535 19.6296C29.8615 19.0789 30.2815 18.6449 30.8135 18.3276C31.3548 18.0102 31.9661 17.8516 32.6475 17.8516C33.3288 17.8516 33.9355 18.0102 34.4675 18.3276C35.0088 18.6356 35.4288 19.0649 35.7275 19.6156C36.0355 20.1569 36.1895 20.7776 36.1895 21.4776C36.1895 22.1869 36.0355 22.8169 35.7275 23.3676C35.4288 23.9182 35.0088 24.3522 34.4675 24.6696C33.9355 24.9776 33.3288 25.1316 32.6475 25.1316ZM32.6475 23.9276C33.3195 23.9276 33.8608 23.6989 34.2715 23.2416C34.6821 22.7842 34.8875 22.2009 34.8875 21.4916C34.8875 21.0249 34.7941 20.6096 34.6075 20.2456C34.4208 19.8816 34.1595 19.5922 33.8235 19.3776C33.4875 19.1629 33.0955 19.0556 32.6475 19.0556C31.9755 19.0556 31.4341 19.2842 31.0235 19.7416C30.6221 20.1989 30.4215 20.7822 30.4215 21.4916C30.4215 21.9582 30.5101 22.3782 30.6875 22.7516C30.8741 23.1156 31.1355 23.4049 31.4715 23.6196C31.8075 23.8249 32.1995 23.9276 32.6475 23.9276Z" fill="black"/>
|
||||
<path d="M25.04 25.1328C24.3307 25.1328 23.668 25.0068 23.052 24.7548C22.4453 24.4934 21.9133 24.1341 21.456 23.6768C20.9987 23.2101 20.6393 22.6688 20.378 22.0528C20.126 21.4368 20 20.7741 20 20.0648C20 19.3554 20.126 18.6928 20.378 18.0768C20.63 17.4608 20.98 16.9194 21.428 16.4528C21.8853 15.9861 22.4173 15.6221 23.024 15.3607C23.64 15.0994 24.312 14.9688 25.04 14.9688C25.7773 14.9688 26.4493 15.0948 27.056 15.3468C27.6627 15.5988 28.19 15.9861 28.638 16.5088L27.644 17.3908C27.2987 16.9801 26.9067 16.6861 26.468 16.5088C26.0387 16.3314 25.5627 16.2428 25.04 16.2428C24.3307 16.2428 23.7007 16.4108 23.15 16.7468C22.6087 17.0828 22.184 17.5401 21.876 18.1188C21.568 18.6881 21.414 19.3368 21.414 20.0648C21.414 20.8021 21.5727 21.4554 21.89 22.0248C22.2167 22.5941 22.6553 23.0421 23.206 23.3688C23.766 23.6861 24.3913 23.8448 25.082 23.8448C25.6607 23.8448 26.16 23.7514 26.58 23.5648C27 23.3688 27.378 23.0794 27.714 22.6968L28.708 23.5228C28.2973 24.0268 27.784 24.4234 27.168 24.7128C26.5613 24.9928 25.852 25.1328 25.04 25.1328Z" fill="black"/>
|
||||
<path d="M155.305 17.1487H157.3V18.2887C157.71 17.4087 158.505 16.9688 159.685 16.9688C160.235 16.9688 160.73 17.0987 161.17 17.3587C161.62 17.6087 161.97 17.9687 162.22 18.4387C162.49 17.9487 162.83 17.5837 163.24 17.3437C163.66 17.0937 164.2 16.9688 164.86 16.9688C165.42 16.9688 165.925 17.1038 166.375 17.3738C166.825 17.6337 167.18 18.0087 167.44 18.4988C167.7 18.9787 167.83 19.5387 167.83 20.1787V24.9488H165.805V20.6737C165.805 20.0237 165.665 19.5387 165.385 19.2187C165.105 18.8987 164.725 18.7387 164.245 18.7387C163.735 18.7387 163.34 18.8837 163.06 19.1737C162.79 19.4637 162.655 19.9637 162.655 20.6737V24.9488H160.63V20.6737C160.63 20.0237 160.48 19.5387 160.18 19.2187C159.88 18.8987 159.47 18.7387 158.95 18.7387C158.49 18.7387 158.105 18.9037 157.795 19.2337C157.485 19.5537 157.33 20.0337 157.33 20.6737V24.9488H155.305V17.1487Z" fill="black"/>
|
||||
<path d="M150.253 25.1437C149.463 25.1437 148.753 24.9637 148.123 24.6037C147.493 24.2337 146.998 23.7388 146.638 23.1188C146.278 22.4888 146.098 21.8037 146.098 21.0637C146.098 20.3237 146.278 19.6387 146.638 19.0087C146.998 18.3787 147.493 17.8837 148.123 17.5237C148.753 17.1537 149.463 16.9688 150.253 16.9688C151.043 16.9688 151.753 17.1537 152.383 17.5237C153.013 17.8837 153.508 18.3787 153.868 19.0087C154.228 19.6387 154.408 20.3237 154.408 21.0637C154.408 21.8037 154.228 22.4888 153.868 23.1188C153.508 23.7388 153.013 24.2337 152.383 24.6037C151.753 24.9637 151.043 25.1437 150.253 25.1437ZM148.123 21.0637C148.123 21.4737 148.208 21.8487 148.378 22.1887C148.558 22.5287 148.808 22.7988 149.128 22.9988C149.458 23.1988 149.833 23.2987 150.253 23.2987C150.673 23.2987 151.043 23.1988 151.363 22.9988C151.693 22.7988 151.943 22.5287 152.113 22.1887C152.293 21.8487 152.383 21.4737 152.383 21.0637C152.383 20.6537 152.293 20.2787 152.113 19.9387C151.933 19.5987 151.683 19.3288 151.363 19.1288C151.043 18.9188 150.673 18.8137 150.253 18.8137C149.833 18.8137 149.463 18.9188 149.143 19.1288C148.823 19.3288 148.573 19.5987 148.393 19.9387C148.213 20.2787 148.123 20.6537 148.123 21.0637Z" fill="black"/>
|
||||
<path d="M142.75 25.1431C142.02 25.1431 141.345 24.9631 140.725 24.6031C140.115 24.2331 139.63 23.7331 139.27 23.1031C138.91 22.4731 138.73 21.7881 138.73 21.0481C138.73 20.2981 138.91 19.6131 139.27 18.9931C139.63 18.3631 140.13 17.8681 140.77 17.5081C141.41 17.1381 142.125 16.9531 142.915 16.9531C143.605 16.9531 144.225 17.0831 144.775 17.3431C145.325 17.5931 145.765 17.9481 146.095 18.4081L144.535 19.6831C144.355 19.4131 144.12 19.1981 143.83 19.0381C143.54 18.8781 143.225 18.7981 142.885 18.7981C142.465 18.7981 142.095 18.9031 141.775 19.1131C141.455 19.3131 141.205 19.5831 141.025 19.9231C140.845 20.2631 140.755 20.6381 140.755 21.0481C140.755 21.4481 140.845 21.8231 141.025 22.1731C141.215 22.5131 141.475 22.7881 141.805 22.9981C142.135 23.1981 142.51 23.2981 142.93 23.2981C143.3 23.2981 143.62 23.2181 143.89 23.0581C144.16 22.8981 144.405 22.6681 144.625 22.3681L146.095 23.6431C145.315 24.6431 144.2 25.1431 142.75 25.1431Z" fill="black"/>
|
||||
<path d="M137.065 25.1445C136.685 25.1445 136.365 25.0145 136.105 24.7545C135.845 24.4945 135.715 24.1845 135.715 23.8245C135.715 23.4645 135.845 23.1595 136.105 22.9095C136.365 22.6495 136.685 22.5195 137.065 22.5195C137.445 22.5195 137.76 22.6495 138.01 22.9095C138.27 23.1595 138.4 23.4645 138.4 23.8245C138.4 24.1945 138.27 24.5095 138.01 24.7695C137.76 25.0195 137.445 25.1445 137.065 25.1445Z" fill="black"/>
|
||||
<path d="M132.762 14H134.787V24.95H132.762V14Z" fill="black"/>
|
||||
<path d="M127.074 25.1444C126.374 25.1444 125.729 24.9644 125.139 24.6044C124.549 24.2344 124.079 23.7394 123.729 23.1194C123.389 22.4994 123.219 21.8194 123.219 21.0794C123.219 20.3394 123.389 19.6594 123.729 19.0394C124.079 18.4094 124.549 17.9094 125.139 17.5394C125.729 17.1694 126.374 16.9844 127.074 16.9844C127.714 16.9844 128.229 17.1044 128.619 17.3444C129.019 17.5844 129.329 17.9294 129.549 18.3794V17.1494H131.559V24.9494H129.594V23.6744C129.364 24.1444 129.049 24.5094 128.649 24.7694C128.259 25.0194 127.734 25.1444 127.074 25.1444ZM125.244 21.0644C125.244 21.4644 125.334 21.8394 125.514 22.1894C125.704 22.5294 125.959 22.8044 126.279 23.0144C126.609 23.2144 126.984 23.3144 127.404 23.3144C127.834 23.3144 128.214 23.2144 128.544 23.0144C128.884 22.8144 129.144 22.5444 129.324 22.2044C129.504 21.8644 129.594 21.4894 129.594 21.0794C129.594 20.6694 129.504 20.2944 129.324 19.9544C129.144 19.6044 128.884 19.3294 128.544 19.1294C128.214 18.9194 127.834 18.8144 127.404 18.8144C126.984 18.8144 126.609 18.9194 126.279 19.1294C125.949 19.3294 125.694 19.5994 125.514 19.9394C125.334 20.2794 125.244 20.6544 125.244 21.0644Z" fill="black"/>
|
||||
<path d="M119.276 25.1439C118.256 25.1439 117.326 24.9039 116.486 24.4239C115.656 23.9339 115.001 23.2739 114.521 22.4439C114.041 21.6139 113.801 20.7039 113.801 19.7139C113.801 18.7139 114.031 17.7989 114.491 16.9689C114.961 16.1289 115.611 15.4689 116.441 14.9889C117.271 14.4989 118.216 14.2539 119.276 14.2539C120.126 14.2539 120.856 14.3889 121.466 14.6589C122.086 14.9189 122.636 15.3339 123.116 15.9039L121.631 17.1939C121.001 16.5339 120.216 16.2039 119.276 16.2039C118.616 16.2039 118.036 16.3639 117.536 16.6839C117.036 16.9939 116.651 17.4189 116.381 17.9589C116.111 18.4889 115.976 19.0739 115.976 19.7139C115.976 20.3539 116.111 20.9389 116.381 21.4689C116.661 21.9989 117.051 22.4189 117.551 22.7289C118.061 23.0389 118.651 23.1939 119.321 23.1939C120.301 23.1939 121.101 22.8539 121.721 22.1739L123.251 23.4789C122.831 23.9889 122.281 24.3939 121.601 24.6939C120.931 24.9939 120.156 25.1439 119.276 25.1439Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg width="189" height="40" viewBox="0 0 189 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M185 0H4C1.79086 0 0 1.79086 0 4V36C0 38.2091 1.79086 40 4 40H185C187.209 40 189 38.2091 189 36V4C189 1.79086 187.209 0 185 0Z" fill="#F2F2F2"/>
|
||||
<path d="M102.289 24.9992V15.1992H103.591V19.0912H103.675C103.862 18.7739 104.123 18.4986 104.459 18.2652C104.795 18.0226 105.229 17.9012 105.761 17.9012C106.265 17.9012 106.708 18.0132 107.091 18.2372C107.483 18.4519 107.786 18.7599 108.001 19.1612C108.225 19.5626 108.337 20.0292 108.337 20.5612V24.9992H107.021V20.8692C107.021 20.3279 106.881 19.8986 106.601 19.5812C106.33 19.2546 105.948 19.0912 105.453 19.0912C104.884 19.0912 104.431 19.2546 104.095 19.5812C103.759 19.8986 103.591 20.3279 103.591 20.8692V24.9992H102.289Z" fill="black"/>
|
||||
<path d="M99.7917 25.181C99.0824 25.1624 98.5271 24.9617 98.1257 24.579C97.7337 24.187 97.5377 23.641 97.5377 22.941V19.259H96.0117V18.083H97.5377V16.403L98.8537 16.207V18.083H101.038V19.259H98.8537V22.857C98.8537 23.6224 99.2504 24.005 100.044 24.005C100.202 24.005 100.361 24.0004 100.52 23.991C100.688 23.9724 100.86 23.9397 101.038 23.893V25.041C100.786 25.097 100.571 25.1344 100.394 25.153C100.216 25.181 100.016 25.1904 99.7917 25.181Z" fill="black"/>
|
||||
<path d="M94.047 16.5872C93.795 16.5872 93.5897 16.5079 93.431 16.3492C93.2817 16.1906 93.207 15.9946 93.207 15.7612C93.207 15.5466 93.2817 15.3599 93.431 15.2012C93.5897 15.0332 93.795 14.9492 94.047 14.9492C94.2804 14.9492 94.4764 15.0332 94.635 15.2012C94.7937 15.3599 94.873 15.5466 94.873 15.7612C94.873 15.9946 94.7937 16.1906 94.635 16.3492C94.4764 16.5079 94.2804 16.5872 94.047 16.5872ZM93.389 25.0012V18.0852H94.691V25.0012H93.389Z" fill="black"/>
|
||||
<path d="M83.5665 25.0019L81.3125 18.0859H82.8105L83.9865 22.2579C84.0612 22.5193 84.1312 22.7899 84.1965 23.0699C84.2618 23.3499 84.3038 23.6019 84.3225 23.8259H84.4205C84.4298 23.6113 84.4625 23.3593 84.5185 23.0699C84.5745 22.7806 84.6445 22.5053 84.7285 22.2439L85.9885 18.0859H87.5285L88.7885 22.2439C88.8725 22.5053 88.9425 22.7806 88.9985 23.0699C89.0638 23.3593 89.1058 23.6113 89.1245 23.8259H89.2225C89.2318 23.6113 89.2692 23.3593 89.3345 23.0699C89.3998 22.7806 89.4652 22.5099 89.5305 22.2579L90.7205 18.0859H92.1485L89.9085 25.0019H88.4105L87.1225 20.8299C87.0198 20.5126 86.9452 20.2279 86.8985 19.9759C86.8518 19.7239 86.8145 19.4859 86.7865 19.2619H86.7025C86.6745 19.4859 86.6372 19.7239 86.5905 19.9759C86.5438 20.2186 86.4738 20.5033 86.3805 20.8299L85.1065 25.0019H83.5665Z" fill="black"/>
|
||||
<path d="M74.363 25.1823C73.6443 25.1823 73.019 25.0283 72.487 24.7203C71.955 24.403 71.5396 23.969 71.241 23.4183C70.9423 22.8677 70.793 22.2423 70.793 21.5423C70.793 20.833 70.9423 20.2077 71.241 19.6663C71.549 19.1157 71.969 18.6863 72.501 18.3783C73.033 18.061 73.6396 17.9023 74.321 17.9023C75.0116 17.9023 75.6043 18.0563 76.099 18.3643C76.603 18.663 76.9903 19.083 77.261 19.6243C77.5316 20.1657 77.667 20.791 77.667 21.5003C77.667 21.5843 77.6623 21.673 77.653 21.7663C77.653 21.8597 77.6436 21.9343 77.625 21.9903H72.137C72.2116 22.5783 72.4543 23.059 72.865 23.4323C73.2756 23.7963 73.7796 23.9783 74.377 23.9783C75.2636 23.9783 75.931 23.675 76.379 23.0683L77.289 23.8943C76.9716 24.305 76.561 24.6223 76.057 24.8463C75.553 25.0703 74.9883 25.1823 74.363 25.1823ZM74.293 19.0643C73.7236 19.0643 73.2476 19.223 72.865 19.5403C72.4823 19.8577 72.2443 20.315 72.151 20.9123H76.323C76.2483 20.3523 76.0383 19.9043 75.693 19.5683C75.357 19.2323 74.8903 19.0643 74.293 19.0643Z" fill="black"/>
|
||||
<path d="M65.8716 25.1839C65.3956 25.1839 64.9662 25.0766 64.5836 24.8619C64.2102 24.6379 63.9116 24.3253 63.6876 23.9239C63.4636 23.5226 63.3516 23.0513 63.3516 22.5099V18.0859H64.6536V22.2159C64.6536 22.7573 64.7936 23.1913 65.0736 23.5179C65.3629 23.8353 65.7316 23.9939 66.1796 23.9939C66.7116 23.9939 67.1409 23.8353 67.4676 23.5179C67.7942 23.1913 67.9576 22.7573 67.9576 22.2159V18.0859H69.2736V25.0019H68.0136V23.9939H67.9296C67.7522 24.3113 67.4956 24.5913 67.1596 24.8339C66.8236 25.0673 66.3942 25.1839 65.8716 25.1839Z" fill="black"/>
|
||||
<path d="M55.4219 25.0003V18.0843H56.6679V19.0923H56.7519C56.9385 18.7843 57.1999 18.509 57.5359 18.2663C57.8719 18.0237 58.3199 17.9023 58.8799 17.9023C59.3745 17.9023 59.8179 18.0143 60.2099 18.2383C60.6019 18.4623 60.9099 18.775 61.1339 19.1763C61.3579 19.5683 61.4699 20.035 61.4699 20.5763V25.0003H60.1539V20.8703C60.1539 20.329 60.0092 19.8997 59.7199 19.5823C59.4399 19.2557 59.0525 19.0923 58.5579 19.0923C57.9979 19.0923 57.5499 19.2557 57.2139 19.5823C56.8872 19.8997 56.7239 20.329 56.7239 20.8703V25.0003H55.4219Z" fill="black"/>
|
||||
<path d="M52.7697 16.5872C52.5177 16.5872 52.3124 16.5079 52.1537 16.3492C52.0044 16.1906 51.9297 15.9946 51.9297 15.7612C51.9297 15.5466 52.0044 15.3599 52.1537 15.2012C52.3124 15.0332 52.5177 14.9492 52.7697 14.9492C53.003 14.9492 53.199 15.0332 53.3577 15.2012C53.5164 15.3599 53.5957 15.5466 53.5957 15.7612C53.5957 15.9946 53.5164 16.1906 53.3577 16.3492C53.199 16.5079 53.003 16.5872 52.7697 16.5872ZM52.1117 25.0012V18.0852H53.4137V25.0012H52.1117Z" fill="black"/>
|
||||
<path d="M49.6159 25.181C48.9066 25.1624 48.3513 24.9617 47.9499 24.579C47.5579 24.187 47.3619 23.641 47.3619 22.941V19.259H45.8359V18.083H47.3619V16.403L48.6779 16.207V18.083H50.8619V19.259H48.6779V22.857C48.6779 23.6224 49.0746 24.005 49.8679 24.005C50.0266 24.005 50.1853 24.0004 50.3439 23.991C50.5119 23.9724 50.6846 23.9397 50.8619 23.893V25.041C50.6099 25.097 50.3953 25.1344 50.2179 25.153C50.0406 25.181 49.8399 25.1904 49.6159 25.181Z" fill="black"/>
|
||||
<path d="M38.7148 25.0003V18.0843H39.9608V19.0923H40.0448C40.2315 18.7843 40.4928 18.509 40.8288 18.2663C41.1648 18.0237 41.6128 17.9023 42.1728 17.9023C42.6675 17.9023 43.1108 18.0143 43.5028 18.2383C43.8948 18.4623 44.2028 18.775 44.4268 19.1763C44.6508 19.5683 44.7628 20.035 44.7628 20.5763V25.0003H43.4468V20.8703C43.4468 20.329 43.3022 19.8997 43.0128 19.5823C42.7328 19.2557 42.3455 19.0923 41.8508 19.0923C41.2908 19.0923 40.8428 19.2557 40.5068 19.5823C40.1802 19.8997 40.0168 20.329 40.0168 20.8703V25.0003H38.7148Z" fill="black"/>
|
||||
<path d="M33.6475 25.1823C32.9661 25.1823 32.3548 25.0283 31.8135 24.7203C31.2815 24.403 30.8615 23.9737 30.5535 23.4323C30.2548 22.8817 30.1055 22.2563 30.1055 21.5563C30.1055 20.847 30.2548 20.2217 30.5535 19.6803C30.8615 19.1297 31.2815 18.6957 31.8135 18.3783C32.3548 18.061 32.9661 17.9023 33.6475 17.9023C34.3288 17.9023 34.9355 18.061 35.4675 18.3783C36.0088 18.6863 36.4288 19.1157 36.7275 19.6663C37.0355 20.2077 37.1895 20.8283 37.1895 21.5283C37.1895 22.2377 37.0355 22.8677 36.7275 23.4183C36.4288 23.969 36.0088 24.403 35.4675 24.7203C34.9355 25.0283 34.3288 25.1823 33.6475 25.1823ZM33.6475 23.9783C34.3195 23.9783 34.8608 23.7497 35.2715 23.2923C35.6821 22.835 35.8875 22.2517 35.8875 21.5423C35.8875 21.0757 35.7941 20.6603 35.6075 20.2963C35.4208 19.9323 35.1595 19.643 34.8235 19.4283C34.4875 19.2137 34.0955 19.1063 33.6475 19.1063C32.9755 19.1063 32.4341 19.335 32.0235 19.7923C31.6221 20.2497 31.4215 20.833 31.4215 21.5423C31.4215 22.009 31.5101 22.429 31.6875 22.8023C31.8741 23.1663 32.1355 23.4557 32.4715 23.6703C32.8075 23.8757 33.1995 23.9783 33.6475 23.9783Z" fill="black"/>
|
||||
<path d="M26.04 25.1835C25.3307 25.1835 24.668 25.0575 24.052 24.8055C23.4453 24.5442 22.9133 24.1849 22.456 23.7275C21.9987 23.2609 21.6393 22.7195 21.378 22.1035C21.126 21.4875 21 20.8249 21 20.1155C21 19.4062 21.126 18.7435 21.378 18.1275C21.63 17.5115 21.98 16.9702 22.428 16.5035C22.8853 16.0369 23.4173 15.6729 24.024 15.4115C24.64 15.1502 25.312 15.0195 26.04 15.0195C26.7773 15.0195 27.4493 15.1455 28.056 15.3975C28.6627 15.6495 29.19 16.0369 29.638 16.5595L28.644 17.4415C28.2987 17.0309 27.9067 16.7369 27.468 16.5595C27.0387 16.3822 26.5627 16.2935 26.04 16.2935C25.3307 16.2935 24.7007 16.4615 24.15 16.7975C23.6087 17.1335 23.184 17.5909 22.876 18.1695C22.568 18.7389 22.414 19.3875 22.414 20.1155C22.414 20.8529 22.5727 21.5062 22.89 22.0755C23.2167 22.6449 23.6553 23.0929 24.206 23.4195C24.766 23.7369 25.3913 23.8955 26.082 23.8955C26.6607 23.8955 27.16 23.8022 27.58 23.6155C28 23.4195 28.378 23.1302 28.714 22.7475L29.708 23.5735C29.2973 24.0775 28.784 24.4742 28.168 24.7635C27.5613 25.0435 26.852 25.1835 26.04 25.1835Z" fill="black"/>
|
||||
<path d="M156.305 17.1995H158.3V18.3395C158.71 17.4595 159.505 17.0195 160.685 17.0195C161.235 17.0195 161.73 17.1495 162.17 17.4095C162.62 17.6595 162.97 18.0195 163.22 18.4895C163.49 17.9995 163.83 17.6345 164.24 17.3945C164.66 17.1445 165.2 17.0195 165.86 17.0195C166.42 17.0195 166.925 17.1545 167.375 17.4245C167.825 17.6845 168.18 18.0595 168.44 18.5495C168.7 19.0295 168.83 19.5895 168.83 20.2295V24.9995H166.805V20.7245C166.805 20.0745 166.665 19.5895 166.385 19.2695C166.105 18.9495 165.725 18.7895 165.245 18.7895C164.735 18.7895 164.34 18.9345 164.06 19.2245C163.79 19.5145 163.655 20.0145 163.655 20.7245V24.9995H161.63V20.7245C161.63 20.0745 161.48 19.5895 161.18 19.2695C160.88 18.9495 160.47 18.7895 159.95 18.7895C159.49 18.7895 159.105 18.9545 158.795 19.2845C158.485 19.6045 158.33 20.0845 158.33 20.7245V24.9995H156.305V17.1995Z" fill="black"/>
|
||||
<path d="M151.253 25.1945C150.463 25.1945 149.753 25.0145 149.123 24.6545C148.493 24.2845 147.998 23.7895 147.638 23.1695C147.278 22.5395 147.098 21.8545 147.098 21.1145C147.098 20.3745 147.278 19.6895 147.638 19.0595C147.998 18.4295 148.493 17.9345 149.123 17.5745C149.753 17.2045 150.463 17.0195 151.253 17.0195C152.043 17.0195 152.753 17.2045 153.383 17.5745C154.013 17.9345 154.508 18.4295 154.868 19.0595C155.228 19.6895 155.408 20.3745 155.408 21.1145C155.408 21.8545 155.228 22.5395 154.868 23.1695C154.508 23.7895 154.013 24.2845 153.383 24.6545C152.753 25.0145 152.043 25.1945 151.253 25.1945ZM149.123 21.1145C149.123 21.5245 149.208 21.8995 149.378 22.2395C149.558 22.5795 149.808 22.8495 150.128 23.0495C150.458 23.2495 150.833 23.3495 151.253 23.3495C151.673 23.3495 152.043 23.2495 152.363 23.0495C152.693 22.8495 152.943 22.5795 153.113 22.2395C153.293 21.8995 153.383 21.5245 153.383 21.1145C153.383 20.7045 153.293 20.3295 153.113 19.9895C152.933 19.6495 152.683 19.3795 152.363 19.1795C152.043 18.9695 151.673 18.8645 151.253 18.8645C150.833 18.8645 150.463 18.9695 150.143 19.1795C149.823 19.3795 149.573 19.6495 149.393 19.9895C149.213 20.3295 149.123 20.7045 149.123 21.1145Z" fill="black"/>
|
||||
<path d="M143.75 25.1939C143.02 25.1939 142.345 25.0139 141.725 24.6539C141.115 24.2839 140.63 23.7839 140.27 23.1539C139.91 22.5239 139.73 21.8389 139.73 21.0989C139.73 20.3489 139.91 19.6639 140.27 19.0439C140.63 18.4139 141.13 17.9189 141.77 17.5589C142.41 17.1889 143.125 17.0039 143.915 17.0039C144.605 17.0039 145.225 17.1339 145.775 17.3939C146.325 17.6439 146.765 17.9989 147.095 18.4589L145.535 19.7339C145.355 19.4639 145.12 19.2489 144.83 19.0889C144.54 18.9289 144.225 18.8489 143.885 18.8489C143.465 18.8489 143.095 18.9539 142.775 19.1639C142.455 19.3639 142.205 19.6339 142.025 19.9739C141.845 20.3139 141.755 20.6889 141.755 21.0989C141.755 21.4989 141.845 21.8739 142.025 22.2239C142.215 22.5639 142.475 22.8389 142.805 23.0489C143.135 23.2489 143.51 23.3489 143.93 23.3489C144.3 23.3489 144.62 23.2689 144.89 23.1089C145.16 22.9489 145.405 22.7189 145.625 22.4189L147.095 23.6939C146.315 24.6939 145.2 25.1939 143.75 25.1939Z" fill="black"/>
|
||||
<path d="M138.065 25.1953C137.685 25.1953 137.365 25.0653 137.105 24.8053C136.845 24.5453 136.715 24.2353 136.715 23.8753C136.715 23.5153 136.845 23.2103 137.105 22.9603C137.365 22.7003 137.685 22.5703 138.065 22.5703C138.445 22.5703 138.76 22.7003 139.01 22.9603C139.27 23.2103 139.4 23.5153 139.4 23.8753C139.4 24.2453 139.27 24.5603 139.01 24.8203C138.76 25.0703 138.445 25.1953 138.065 25.1953Z" fill="black"/>
|
||||
<path d="M133.762 14.0508H135.787V25.0008H133.762V14.0508Z" fill="black"/>
|
||||
<path d="M128.074 25.1952C127.374 25.1952 126.729 25.0152 126.139 24.6552C125.549 24.2852 125.079 23.7902 124.729 23.1702C124.389 22.5502 124.219 21.8702 124.219 21.1302C124.219 20.3902 124.389 19.7102 124.729 19.0902C125.079 18.4602 125.549 17.9602 126.139 17.5902C126.729 17.2202 127.374 17.0352 128.074 17.0352C128.714 17.0352 129.229 17.1552 129.619 17.3952C130.019 17.6352 130.329 17.9802 130.549 18.4302V17.2002H132.559V25.0002H130.594V23.7252C130.364 24.1952 130.049 24.5602 129.649 24.8202C129.259 25.0702 128.734 25.1952 128.074 25.1952ZM126.244 21.1152C126.244 21.5152 126.334 21.8902 126.514 22.2402C126.704 22.5802 126.959 22.8552 127.279 23.0652C127.609 23.2652 127.984 23.3652 128.404 23.3652C128.834 23.3652 129.214 23.2652 129.544 23.0652C129.884 22.8652 130.144 22.5952 130.324 22.2552C130.504 21.9152 130.594 21.5402 130.594 21.1302C130.594 20.7202 130.504 20.3452 130.324 20.0052C130.144 19.6552 129.884 19.3802 129.544 19.1802C129.214 18.9702 128.834 18.8652 128.404 18.8652C127.984 18.8652 127.609 18.9702 127.279 19.1802C126.949 19.3802 126.694 19.6502 126.514 19.9902C126.334 20.3302 126.244 20.7052 126.244 21.1152Z" fill="black"/>
|
||||
<path d="M120.276 25.1947C119.256 25.1947 118.326 24.9547 117.486 24.4747C116.656 23.9847 116.001 23.3247 115.521 22.4947C115.041 21.6647 114.801 20.7547 114.801 19.7647C114.801 18.7647 115.031 17.8497 115.491 17.0197C115.961 16.1797 116.611 15.5197 117.441 15.0397C118.271 14.5497 119.216 14.3047 120.276 14.3047C121.126 14.3047 121.856 14.4397 122.466 14.7097C123.086 14.9697 123.636 15.3847 124.116 15.9547L122.631 17.2447C122.001 16.5847 121.216 16.2547 120.276 16.2547C119.616 16.2547 119.036 16.4147 118.536 16.7347C118.036 17.0447 117.651 17.4697 117.381 18.0097C117.111 18.5397 116.976 19.1247 116.976 19.7647C116.976 20.4047 117.111 20.9897 117.381 21.5197C117.661 22.0497 118.051 22.4697 118.551 22.7797C119.061 23.0897 119.651 23.2447 120.321 23.2447C121.301 23.2447 122.101 22.9047 122.721 22.2247L124.251 23.5297C123.831 24.0397 123.281 24.4447 122.601 24.7447C121.931 25.0447 121.156 25.1947 120.276 25.1947Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -1346,6 +1346,11 @@
|
||||
"confirm_delete_api_key": "Revoke this API key",
|
||||
"disable_rescheduling": "Disable Rescheduling",
|
||||
"description_disable_rescheduling": "Guests and Organizer can no longer reschedule the event with calendar invite or email. <0>Learn more</0>",
|
||||
"minimum_reschedule_notice": "Minimum Reschedule Notice",
|
||||
"minimum_reschedule_notice_description": "Prevent rescheduling within this many minutes before the event starts. Leave empty to allow rescheduling at any time.",
|
||||
"when_less_than_minutes_before_meeting": "When less than <0></0> minutes before meeting",
|
||||
"reschedule_disabled_minimum_notice": "Rescheduling is disabled within the minimum notice period before the event",
|
||||
"rescheduling_is_disabled": "Rescheduling is disabled for this event",
|
||||
"allow_rescheduling_cancelled_bookings": "Allow booking through reschedule link",
|
||||
"description_allow_rescheduling_cancelled_bookings": "When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking",
|
||||
"disable_cancelling": "Disable Cancelling",
|
||||
@@ -4105,12 +4110,49 @@
|
||||
"booking_response": "Booking Response",
|
||||
"list_view": "List view",
|
||||
"calendar_view": "Calendar view",
|
||||
"holidays": "Holidays",
|
||||
"holidays_description": "We will automatically mark you as unavailable for the selected holidays",
|
||||
"country_for_holidays": "Country for holidays",
|
||||
"select_country": "Select country",
|
||||
"no_holidays": "No holidays",
|
||||
"holidays_list": "Holidays",
|
||||
"no_holidays_selected": "No holidays selected",
|
||||
"select_country_to_see_holidays": "Select a country to see available holidays",
|
||||
"holiday_settings_updated": "Holiday settings updated",
|
||||
"holiday_no_availability": "No availability on this holiday",
|
||||
"holiday_booking_conflict_warning_one": "You have {{count}} booking on holidays",
|
||||
"holiday_booking_conflict_warning_other": "You have {{count}} bookings on holidays",
|
||||
"holiday_overlap_info": "Holiday notice",
|
||||
"holiday_overlap_message_single": "{{date}} is {{holiday}}, which is already blocked as a holiday",
|
||||
"holiday_overlap_message_multiple": "{{count}} days in this range are holidays ({{holidays}})",
|
||||
"no_holidays_found_for_country": "No holidays found for this country.",
|
||||
"booking_count_one": "{{count}} booking",
|
||||
"booking_count_other": "{{count}} bookings",
|
||||
"and_more_holidays_with_conflicts_one": "... and {{count}} more holiday with conflicts",
|
||||
"and_more_holidays_with_conflicts_other": "... and {{count}} more holidays with conflicts",
|
||||
"assignment_reason": "Assignment Reason",
|
||||
"saved": "Saved",
|
||||
"booking_history": "Booking History",
|
||||
"booking_history_description": "View the history of actions performed on this booking",
|
||||
"audit_action": {
|
||||
"created": "Created"
|
||||
"booking_audit_action": {
|
||||
"created": "Created",
|
||||
"cancelled": "Cancelled",
|
||||
"rescheduled": "Rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
|
||||
"rescheduled_from": "Rescheduled <0>{{oldDate}}</0> -> {{newDate}}",
|
||||
"accepted": "Accepted",
|
||||
"reschedule_requested": "Reschedule Requested",
|
||||
"attendee_added": "Attendee Added",
|
||||
"host_no_show_updated": "Host No-Show Updated",
|
||||
"rejected": "Rejected",
|
||||
"attendee_removed": "Attendee Removed",
|
||||
"reassignment": "Reassignment",
|
||||
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
|
||||
"location_changed": "Location Changed",
|
||||
"location_changed_from_to": "Location changed from {{fromLocation}} to {{toLocation}}",
|
||||
"attendee_no_show_updated": "Attendee No-Show Updated",
|
||||
"type": "Assignment Type",
|
||||
"assignmentType_manual": "Manual Assignment",
|
||||
"assignmentType_roundRobin": "Round Robin Assignment"
|
||||
},
|
||||
"error_loading_booking_logs": "Error loading booking logs",
|
||||
"no_audit_logs_found": "No audit logs found",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { describe, it, beforeEach, vi, expect } from "vitest";
|
||||
|
||||
import * as handleConfirmationModule from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
@@ -92,6 +93,7 @@ describe("confirmHandler", () => {
|
||||
timeZone: organizer.timeZone,
|
||||
username: organizer.username,
|
||||
} as NonNullable<TrpcSessionUser>,
|
||||
traceContext: distributedTracing.createTrace("test_confirm_handler"),
|
||||
};
|
||||
|
||||
const res = await confirmHandler({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { z } from "zod";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
||||
import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/dayjs";
|
||||
import type { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -2287,7 +2288,8 @@ export function getMockedStripePaymentEvent({ paymentIntentId }: { paymentIntent
|
||||
export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId: string }) {
|
||||
let webhookResponse = null;
|
||||
try {
|
||||
await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }));
|
||||
const traceContext = distributedTracing.createTrace("test_stripe_webhook");
|
||||
await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }), traceContext);
|
||||
} catch (e) {
|
||||
log.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
|
||||
webhookResponse = e as HttpError;
|
||||
|
||||
@@ -3,3 +3,26 @@ EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here
|
||||
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here
|
||||
|
||||
# ===========================================
|
||||
# CACHE CONFIGURATION (all values in MINUTES)
|
||||
# ===========================================
|
||||
|
||||
# Default cache stale time for all queries (default: 5 minutes)
|
||||
EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES=5
|
||||
|
||||
# Garbage collection time - how long to keep unused cache (default: 1440 minutes - 24 hours)
|
||||
EXPO_PUBLIC_CACHE_GC_TIME_MINUTES=1440
|
||||
|
||||
# Bookings cache stale time (default: 5 minutes)
|
||||
# After this time, data is considered stale and will refetch in background
|
||||
EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES=5
|
||||
|
||||
# Event Types cache stale time (default: -1 = never stale)
|
||||
# -1 means data only refreshes on mutations (create/update/delete) or manual pull-to-refresh
|
||||
EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES=-1
|
||||
|
||||
# Schedules (Availability) cache stale time (default: -1 = never stale)
|
||||
EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES=-1
|
||||
|
||||
# User Profile cache stale time (default: -1 = never stale)
|
||||
EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES=-1
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"projectId": "f5e97f6f-1e95-44ac-bfa4-f737ef90f198"
|
||||
}
|
||||
},
|
||||
"owner": "calcoms-organization"
|
||||
"owner": "calcoms-organization",
|
||||
"plugins": ["expo-router", "expo-secure-store", "expo-web-browser"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function EventTypesLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="event-types" options={{}} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,78 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import React, { useState, useMemo, Activity } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
TextInput,
|
||||
ActionSheetIOS,
|
||||
Share,
|
||||
Alert,
|
||||
Clipboard,
|
||||
Platform,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
|
||||
import { CalComAPIService, EventType } from "../../services/calcom";
|
||||
import { Header } from "../../components/Header";
|
||||
import { Tooltip } from "../../components/Tooltip";
|
||||
import { FullScreenModal } from "../../components/FullScreenModal";
|
||||
import { slugify } from "../../utils/slugify";
|
||||
import { CalComAPIService, EventType } from "../../../services/calcom";
|
||||
import { Header } from "../../../components/Header";
|
||||
import { FullScreenModal } from "../../../components/FullScreenModal";
|
||||
import { LoadingSpinner } from "../../../components/LoadingSpinner";
|
||||
import { EmptyScreen } from "../../../components/EmptyScreen";
|
||||
import { slugify } from "../../../utils/slugify";
|
||||
import { showErrorAlert } from "../../../utils/alerts";
|
||||
import { EventTypeListItem } from "../../../components/event-type-list-item/EventTypeListItem";
|
||||
import { offlineAwareRefresh } from "../../../utils/network";
|
||||
import { openInAppBrowser } from "../../../utils/browser";
|
||||
import {
|
||||
useEventTypes,
|
||||
useCreateEventType,
|
||||
useDeleteEventType,
|
||||
useDuplicateEventType,
|
||||
useUsername,
|
||||
} from "../../../hooks";
|
||||
import { getEventDuration } from "../../../utils/getEventDuration";
|
||||
import { normalizeMarkdown } from "../../../utils/normalizeMarkdown";
|
||||
|
||||
export default function EventTypes() {
|
||||
console.log("EventTypes component rendering");
|
||||
const router = useRouter();
|
||||
const [eventTypes, setEventTypes] = useState<EventType[]>([]);
|
||||
const [filteredEventTypes, setFilteredEventTypes] = useState<EventType[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// Modal state for creating new event type
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newEventTitle, setNewEventTitle] = useState("");
|
||||
const [newEventSlug, setNewEventSlug] = useState("");
|
||||
const [newEventDescription, setNewEventDescription] = useState("");
|
||||
const [newEventDuration, setNewEventDuration] = useState("15");
|
||||
const [username, setUsername] = useState<string>("");
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
// Use React Query hooks
|
||||
const {
|
||||
data: eventTypes = [],
|
||||
isLoading: loading,
|
||||
isFetching,
|
||||
error: queryError,
|
||||
refetch,
|
||||
} = useEventTypes();
|
||||
|
||||
// Show refresh indicator when fetching
|
||||
const refreshing = isFetching && !loading;
|
||||
|
||||
const { data: username = "" } = useUsername();
|
||||
const { mutate: createEventTypeMutation, isPending: creating } = useCreateEventType();
|
||||
const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType();
|
||||
const { mutate: duplicateEventTypeMutation } = useDuplicateEventType();
|
||||
|
||||
// Convert query error to string
|
||||
// Don't show error UI for authentication errors (user will be redirected to login)
|
||||
// Only show error UI in development mode for other errors
|
||||
const isAuthError =
|
||||
queryError?.message?.includes("Authentication") ||
|
||||
queryError?.message?.includes("sign in") ||
|
||||
queryError?.message?.includes("401");
|
||||
const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null;
|
||||
|
||||
// Modal state for web platform action sheet
|
||||
const [showActionModal, setShowActionModal] = useState(false);
|
||||
const [selectedEventType, setSelectedEventType] = useState<EventType | null>(null);
|
||||
@@ -58,7 +83,6 @@ export default function EventTypes() {
|
||||
// Modal state for delete confirmation
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [eventTypeToDelete, setEventTypeToDelete] = useState<EventType | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Toast state for web platform
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
@@ -78,132 +102,25 @@ export default function EventTypes() {
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
// Handle pull-to-refresh
|
||||
// Handle pull-to-refresh (offline-aware)
|
||||
const onRefresh = () => offlineAwareRefresh(refetch);
|
||||
|
||||
// Fetch username on mount
|
||||
useEffect(() => {
|
||||
const fetchUsername = async () => {
|
||||
try {
|
||||
const fetchedUsername = await CalComAPIService.getUsername();
|
||||
setUsername(fetchedUsername);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch username:", error);
|
||||
// Keep default username if fetch fails
|
||||
}
|
||||
};
|
||||
fetchUsername();
|
||||
}, []);
|
||||
|
||||
const fetchEventTypes = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const data = await CalComAPIService.getEventTypes();
|
||||
|
||||
if (isMountedRef.current) {
|
||||
if (Array.isArray(data)) {
|
||||
setEventTypes(data);
|
||||
setFilteredEventTypes(data);
|
||||
} else {
|
||||
setEventTypes([]);
|
||||
setFilteredEventTypes([]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("🎯 EventTypesScreen: Error fetching event types:", err);
|
||||
if (isMountedRef.current) {
|
||||
setError("Failed to load event types. Please check your API key and try again.");
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
// Filter event types based on search query
|
||||
const filteredEventTypes = useMemo(() => {
|
||||
if (searchQuery.trim() === "") {
|
||||
return eventTypes;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEventTypes();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchEventTypes();
|
||||
};
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return eventTypes.filter(
|
||||
(eventType) =>
|
||||
eventType.title.toLowerCase().includes(searchLower) ||
|
||||
(eventType.description && eventType.description.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}, [eventTypes, searchQuery]);
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
if (query.trim() === "") {
|
||||
setFilteredEventTypes(eventTypes);
|
||||
} else {
|
||||
const filtered = eventTypes.filter(
|
||||
(eventType) =>
|
||||
eventType.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(eventType.description &&
|
||||
eventType.description.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
setFilteredEventTypes(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number | undefined) => {
|
||||
if (!minutes || minutes <= 0) {
|
||||
return "0m";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
const getDuration = (eventType: EventType): number => {
|
||||
// Prefer lengthInMinutes (API field), fallback to length for backwards compatibility
|
||||
return eventType.lengthInMinutes ?? eventType.length ?? 0;
|
||||
};
|
||||
|
||||
const normalizeMarkdown = (text: string): string => {
|
||||
if (!text) return "";
|
||||
|
||||
return (
|
||||
text
|
||||
// Remove HTML tags including <br>, <div>, <p>, etc.
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
// Convert HTML entities
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
// Convert markdown links [text](url) to just text
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
// Remove bold/italic markers **text** or *text*
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/\*([^*]+)\*/g, "$1")
|
||||
// Remove inline code `text`
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
// Remove strikethrough ~~text~~
|
||||
.replace(/~~([^~]+)~~/g, "$1")
|
||||
// Remove heading markers # ## ###
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
// Remove blockquote markers >
|
||||
.replace(/^>\s+/gm, "")
|
||||
// Remove list markers - * +
|
||||
.replace(/^[\s]*[-*+]\s+/gm, "")
|
||||
// Remove numbered list markers 1. 2. etc
|
||||
.replace(/^[\s]*\d+\.\s+/gm, "")
|
||||
// Normalize multiple whitespace/newlines to single space
|
||||
.replace(/\s+/g, " ")
|
||||
// Trim whitespace
|
||||
.trim()
|
||||
);
|
||||
};
|
||||
|
||||
const handleEventTypePress = (eventType: EventType) => {
|
||||
@@ -259,7 +176,7 @@ export default function EventTypes() {
|
||||
const handleCopyLink = async (eventType: EventType) => {
|
||||
try {
|
||||
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
|
||||
Clipboard.setString(link);
|
||||
await Clipboard.setStringAsync(link);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Link copied!", eventType.id);
|
||||
@@ -270,7 +187,7 @@ export default function EventTypes() {
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to copy link");
|
||||
} else {
|
||||
Alert.alert("Error", "Failed to copy link. Please try again.");
|
||||
showErrorAlert("Error", "Failed to copy link. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -283,12 +200,12 @@ export default function EventTypes() {
|
||||
url: link,
|
||||
});
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to share link. Please try again.");
|
||||
showErrorAlert("Error", "Failed to share link. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (eventType: EventType) => {
|
||||
const duration = getDuration(eventType);
|
||||
const duration = getEventDuration(eventType);
|
||||
router.push({
|
||||
pathname: "/event-type-detail",
|
||||
params: {
|
||||
@@ -308,20 +225,11 @@ export default function EventTypes() {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const confirmDelete = () => {
|
||||
if (!eventTypeToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await CalComAPIService.deleteEventType(eventTypeToDelete.id);
|
||||
|
||||
// Only update state if component is still mounted
|
||||
if (isMountedRef.current) {
|
||||
// Remove the deleted event type from local state
|
||||
const updatedEventTypes = eventTypes.filter((et) => et.id !== eventTypeToDelete.id);
|
||||
setEventTypes(updatedEventTypes);
|
||||
setFilteredEventTypes(updatedEventTypes);
|
||||
|
||||
deleteEventTypeMutation(eventTypeToDelete.id, {
|
||||
onSuccess: () => {
|
||||
// Close modal and reset state
|
||||
setShowDeleteModal(false);
|
||||
setEventTypeToDelete(null);
|
||||
@@ -331,76 +239,57 @@ export default function EventTypes() {
|
||||
} else {
|
||||
Alert.alert("Success", "Event type deleted successfully");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete event type:", error);
|
||||
if (isMountedRef.current) {
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to delete event type:", error);
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to delete event type");
|
||||
} else {
|
||||
Alert.alert("Error", "Failed to delete event type. Please try again.");
|
||||
showErrorAlert("Error", "Failed to delete event type. Please try again.");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicate = async (eventType: EventType) => {
|
||||
try {
|
||||
// Generate a new title and slug for the duplicate
|
||||
const newTitle = `${eventType.title} (copy)`;
|
||||
let newSlug = `${eventType.slug}-copy`;
|
||||
const handleDuplicate = (eventType: EventType) => {
|
||||
duplicateEventTypeMutation(
|
||||
{ eventType, existingEventTypes: eventTypes },
|
||||
{
|
||||
onSuccess: (duplicatedEventType) => {
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Event type duplicated successfully");
|
||||
} else {
|
||||
Alert.alert("Success", "Event type duplicated successfully");
|
||||
}
|
||||
|
||||
// Check if slug already exists and append a number if needed
|
||||
let counter = 1;
|
||||
while (eventTypes.some((et) => et.slug === newSlug)) {
|
||||
newSlug = `${eventType.slug}-copy-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
const duration = getEventDuration(eventType);
|
||||
|
||||
const duration = getDuration(eventType);
|
||||
|
||||
// Create the duplicate event type
|
||||
const duplicatedEventType = await CalComAPIService.createEventType({
|
||||
title: newTitle,
|
||||
slug: newSlug,
|
||||
lengthInMinutes: duration,
|
||||
description: eventType.description || undefined,
|
||||
});
|
||||
|
||||
// Refresh the list
|
||||
await fetchEventTypes();
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Event type duplicated successfully");
|
||||
} else {
|
||||
Alert.alert("Success", "Event type duplicated successfully");
|
||||
}
|
||||
|
||||
// Navigate to edit the newly created duplicate
|
||||
router.push({
|
||||
pathname: "/event-type-detail",
|
||||
params: {
|
||||
id: duplicatedEventType.id.toString(),
|
||||
title: duplicatedEventType.title,
|
||||
description: duplicatedEventType.description || "",
|
||||
duration: (
|
||||
duplicatedEventType.lengthInMinutes ||
|
||||
duplicatedEventType.length ||
|
||||
duration
|
||||
).toString(),
|
||||
slug: duplicatedEventType.slug || "",
|
||||
// Navigate to edit the newly created duplicate
|
||||
router.push({
|
||||
pathname: "/event-type-detail",
|
||||
params: {
|
||||
id: duplicatedEventType.id.toString(),
|
||||
title: duplicatedEventType.title,
|
||||
description: duplicatedEventType.description || "",
|
||||
duration: (
|
||||
duplicatedEventType.lengthInMinutes ||
|
||||
duplicatedEventType.length ||
|
||||
duration
|
||||
).toString(),
|
||||
slug: duplicatedEventType.slug || "",
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to duplicate event type:", error);
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to duplicate event type");
|
||||
} else {
|
||||
showErrorAlert("Error", "Failed to duplicate event type. Please try again.");
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate event type:", error);
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to duplicate event type");
|
||||
} else {
|
||||
Alert.alert("Error", "Failed to duplicate event type. Please try again.");
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handlePreview = async (eventType: EventType) => {
|
||||
@@ -410,15 +299,15 @@ export default function EventTypes() {
|
||||
if (Platform.OS === "web") {
|
||||
window.open(link, "_blank");
|
||||
} else {
|
||||
// For mobile, use Linking
|
||||
await Linking.openURL(link);
|
||||
// For mobile, use in-app browser
|
||||
await openInAppBrowser(link, "event type preview");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open preview:", error);
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to open preview");
|
||||
} else {
|
||||
Alert.alert("Error", "Failed to open preview. Please try again.");
|
||||
showErrorAlert("Error", "Failed to open preview. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -440,7 +329,7 @@ export default function EventTypes() {
|
||||
setIsSlugManuallyEdited(false);
|
||||
};
|
||||
|
||||
const handleCreateEventType = async () => {
|
||||
const handleCreateEventType = () => {
|
||||
if (!newEventTitle.trim()) {
|
||||
Alert.alert("Error", "Please enter a title for your event type");
|
||||
return;
|
||||
@@ -457,141 +346,46 @@ export default function EventTypes() {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
// Create the event type with the form data
|
||||
const newEventType = await CalComAPIService.createEventType({
|
||||
createEventTypeMutation(
|
||||
{
|
||||
title: newEventTitle.trim(),
|
||||
slug: newEventSlug.trim(),
|
||||
lengthInMinutes: duration,
|
||||
description: newEventDescription.trim() || undefined,
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: (newEventType) => {
|
||||
// Close modal and reset form
|
||||
handleCloseCreateModal();
|
||||
|
||||
// Close modal and reset form
|
||||
handleCloseCreateModal();
|
||||
|
||||
// Refresh the list
|
||||
await fetchEventTypes();
|
||||
|
||||
// Navigate to edit the newly created event type
|
||||
router.push({
|
||||
pathname: "/event-type-detail",
|
||||
params: {
|
||||
id: newEventType.id.toString(),
|
||||
title: newEventType.title,
|
||||
description: newEventType.description || "",
|
||||
duration: (newEventType.lengthInMinutes || newEventType.length || 15).toString(),
|
||||
slug: newEventType.slug || "",
|
||||
// Navigate to edit the newly created event type
|
||||
router.push({
|
||||
pathname: "/event-type-detail",
|
||||
params: {
|
||||
id: newEventType.id.toString(),
|
||||
title: newEventType.title,
|
||||
description: newEventType.description || "",
|
||||
duration: (newEventType.lengthInMinutes || newEventType.length || 15).toString(),
|
||||
slug: newEventType.slug || "",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create event type:", error);
|
||||
Alert.alert("Error", "Failed to create event type. Please try again.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEventType = ({ item, index }: { item: EventType; index: number }) => {
|
||||
const duration = getDuration(item);
|
||||
const isLast = index === filteredEventTypes.length - 1;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className={`bg-white active:bg-[#F8F9FA] ${!isLast ? "border-b border-[#E5E5EA]" : ""}`}
|
||||
onPress={() => handleEventTypePress(item)}
|
||||
onLongPress={() => handleEventTypeLongPress(item)}
|
||||
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<View className="mb-1 flex-row items-center">
|
||||
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title}</Text>
|
||||
</View>
|
||||
{item.description && (
|
||||
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
|
||||
{normalizeMarkdown(item.description)}
|
||||
</Text>
|
||||
)}
|
||||
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
|
||||
<Ionicons name="time-outline" size={14} color="#000" />
|
||||
<Text className="ml-1.5 text-xs font-semibold text-black">
|
||||
{formatDuration(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
|
||||
<View className="mt-2 flex-row items-center gap-3">
|
||||
{item.price != null && item.price > 0 && (
|
||||
<Text className="text-sm font-medium text-[#34C759]">
|
||||
{item.currency || "$"}
|
||||
{item.price}
|
||||
</Text>
|
||||
)}
|
||||
{item.requiresConfirmation && (
|
||||
<View className="rounded bg-[#FF9500] px-2 py-0.5">
|
||||
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="flex-row">
|
||||
<Tooltip text="Preview">
|
||||
<TouchableOpacity
|
||||
className="items-center justify-center rounded-l-lg border border-r-0 border-[#E5E5EA]"
|
||||
style={{ width: 32, height: 32 }}
|
||||
onPress={() => handlePreview(item)}
|
||||
>
|
||||
<Ionicons name="open-outline" size={18} color="#3C3F44" />
|
||||
</TouchableOpacity>
|
||||
</Tooltip>
|
||||
<Tooltip text={copiedEventTypeId === item.id ? "Copied!" : "Copy link"}>
|
||||
<TouchableOpacity
|
||||
className="items-center justify-center border border-r-0 border-[#E5E5EA]"
|
||||
style={{ width: 32, height: 32 }}
|
||||
onPress={() => handleCopyLink(item)}
|
||||
>
|
||||
{copiedEventTypeId === item.id ? (
|
||||
<Ionicons name="checkmark" size={18} color="#10B981" />
|
||||
) : (
|
||||
<Svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3C3F44"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<Path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<Path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Tooltip>
|
||||
<Tooltip text="More">
|
||||
<TouchableOpacity
|
||||
className="items-center justify-center rounded-r-lg border border-[#E5E5EA]"
|
||||
style={{ width: 32, height: 32 }}
|
||||
onPress={() => handleEventTypeLongPress(item)}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
|
||||
</TouchableOpacity>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
onError: (error) => {
|
||||
console.error("Failed to create event type:", error);
|
||||
showErrorAlert("Error", "Failed to create event type. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Header />
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
</Activity>
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<ActivityIndicator size="large" color="#000000" />
|
||||
<Text className="mt-4 text-base text-gray-500">Loading event types...</Text>
|
||||
<LoadingSpinner size="large" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -600,14 +394,16 @@ export default function EventTypes() {
|
||||
if (error) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Header />
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
</Activity>
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
|
||||
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
|
||||
Unable to load event types
|
||||
</Text>
|
||||
<Text className="mb-6 text-center text-base text-gray-500">{error}</Text>
|
||||
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={fetchEventTypes}>
|
||||
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={() => refetch()}>
|
||||
<Text className="text-base font-semibold text-white">Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -618,25 +414,17 @@ export default function EventTypes() {
|
||||
if (eventTypes.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-2 py-2 md:px-4">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search event types"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
</Activity>
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<Ionicons name="calendar-outline" size={64} color="#666" />
|
||||
<Text className="mb-2 mt-4 text-xl font-bold text-gray-800">No event types found</Text>
|
||||
<Text className="text-center text-base text-gray-500">
|
||||
Create your first event type in Cal.com
|
||||
</Text>
|
||||
<EmptyScreen
|
||||
icon="link-outline"
|
||||
headline="Create your first event type"
|
||||
description="Event types enable you to share links that show available times on your calendar and allow people to make bookings with you."
|
||||
buttonText="New"
|
||||
onButtonPress={handleCreateNew}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -645,8 +433,79 @@ export default function EventTypes() {
|
||||
if (filteredEventTypes.length === 0 && searchQuery.trim() !== "") {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search event types"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Activity>
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<EmptyScreen
|
||||
icon="search-outline"
|
||||
headline={`No results found for "${searchQuery}"`}
|
||||
description="Try searching with different keywords"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Event Types",
|
||||
headerLargeTitleEnabled: true,
|
||||
headerStyle: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search event types",
|
||||
barTintColor: "#fff",
|
||||
obscureBackground: false,
|
||||
onChangeText: (e) => {
|
||||
handleSearch(e.nativeEvent.text);
|
||||
},
|
||||
},
|
||||
unstable_headerRightItems: () => [
|
||||
{
|
||||
type: "button",
|
||||
label: "New",
|
||||
labelStyle: {
|
||||
// style if needed
|
||||
},
|
||||
variant: "prominent",
|
||||
tintColor: "#000",
|
||||
// icon: {
|
||||
// name: "plus",
|
||||
// type: "sfSymbol",
|
||||
// },
|
||||
onPress: handleCreateNew,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-2 py-2 md:px-4">
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search event types"
|
||||
@@ -657,49 +516,40 @@ export default function EventTypes() {
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<Ionicons name="search-outline" size={64} color="#666" />
|
||||
<Text className="mb-2 mt-4 text-xl font-bold text-gray-800">No results found</Text>
|
||||
<Text className="text-center text-base text-gray-500">
|
||||
Try searching with different keywords
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
</Activity>
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search event types"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={{ backgroundColor: "white" }}
|
||||
contentContainerStyle={{ paddingBottom: 90 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
<View className="px-2 pt-4 md:px-4">
|
||||
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
{filteredEventTypes.map((item, index) => (
|
||||
<View key={item.id.toString()}>{renderEventType({ item, index })}</View>
|
||||
<EventTypeListItem
|
||||
key={item.id.toString()}
|
||||
item={item}
|
||||
index={index}
|
||||
filteredEventTypes={filteredEventTypes}
|
||||
copiedEventTypeId={copiedEventTypeId}
|
||||
handleEventTypePress={handleEventTypePress}
|
||||
handleEventTypeLongPress={handleEventTypeLongPress}
|
||||
handleCopyLink={handleCopyLink}
|
||||
handlePreview={handlePreview}
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
@@ -914,8 +764,8 @@ export default function EventTypes() {
|
||||
if (eventType) handleDelete(eventType);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#EF4444" />
|
||||
<Text className="ml-3 text-base text-red-500">Delete</Text>
|
||||
<Ionicons name="trash-outline" size={20} color="#800000" />
|
||||
<Text className="ml-3 text-base text-[#800000]">Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1007,7 +857,7 @@ export default function EventTypes() {
|
||||
<View className="flex-row">
|
||||
{/* Danger icon */}
|
||||
<View className="mr-3 self-start rounded-full bg-red-50 p-2">
|
||||
<Ionicons name="alert-circle" size={20} color="#DC2626" />
|
||||
<Ionicons name="alert-circle" size={20} color="#800000" />
|
||||
</View>
|
||||
|
||||
{/* Title and description */}
|
||||
@@ -1060,6 +910,6 @@ export default function EventTypes() {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Tabs } from "expo-router";
|
||||
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
@@ -12,24 +12,24 @@ export default function TabLayout() {
|
||||
if (supportsLiquidGlass) {
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="event-types">
|
||||
<Icon sf="link" />
|
||||
<Label>Event Types</Label>
|
||||
<NativeTabs.Trigger name="(event-types)">
|
||||
<NativeTabs.Trigger.Icon sf="link" />
|
||||
<NativeTabs.Trigger.Label>Event Types</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="bookings">
|
||||
<Icon sf="calendar" />
|
||||
<Label>Bookings</Label>
|
||||
<NativeTabs.Trigger.Icon sf="calendar" />
|
||||
<NativeTabs.Trigger.Label>Bookings</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="availability">
|
||||
<Icon sf="clock" />
|
||||
<Label>Availability</Label>
|
||||
<NativeTabs.Trigger.Icon sf="clock" />
|
||||
<NativeTabs.Trigger.Label>Availability</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="more">
|
||||
<Icon sf="ellipsis" />
|
||||
<Label>More</Label>
|
||||
<NativeTabs.Trigger.Icon sf="ellipsis" />
|
||||
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter, useFocusEffect } from "expo-router";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
ActionSheetIOS,
|
||||
@@ -14,83 +13,76 @@ import {
|
||||
Modal,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
|
||||
import { CalComAPIService, Schedule } from "../../services/calcom";
|
||||
import { Header } from "../../components/Header";
|
||||
import { FullScreenModal } from "../../components/FullScreenModal";
|
||||
import { LoadingSpinner } from "../../components/LoadingSpinner";
|
||||
import { EmptyScreen } from "../../components/EmptyScreen";
|
||||
import { showErrorAlert } from "../../utils/alerts";
|
||||
import { offlineAwareRefresh } from "../../utils/network";
|
||||
import {
|
||||
useSchedules,
|
||||
useCreateSchedule,
|
||||
useDeleteSchedule,
|
||||
useDuplicateSchedule,
|
||||
useSetScheduleAsDefault,
|
||||
} from "../../hooks";
|
||||
|
||||
export default function Availability() {
|
||||
const router = useRouter();
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [filteredSchedules, setFilteredSchedules] = useState<Schedule[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newScheduleName, setNewScheduleName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [showActionsModal, setShowActionsModal] = useState(false);
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchSchedules = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
// Use React Query hooks
|
||||
const {
|
||||
data: schedules = [],
|
||||
isLoading: loading,
|
||||
isFetching,
|
||||
error: queryError,
|
||||
refetch,
|
||||
} = useSchedules();
|
||||
|
||||
// Fetch all schedules
|
||||
const allSchedules = await CalComAPIService.getSchedules();
|
||||
// Show refresh indicator when fetching
|
||||
const refreshing = isFetching && !loading;
|
||||
|
||||
// Sort schedules: default first, then by name
|
||||
const sortedSchedules = allSchedules.sort((a, b) => {
|
||||
// Default schedule first
|
||||
if (a.isDefault && !b.isDefault) return -1;
|
||||
if (!a.isDefault && b.isDefault) return 1;
|
||||
// Then sort by name alphabetically
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const { mutate: createScheduleMutation, isPending: creating } = useCreateSchedule();
|
||||
const { mutate: deleteScheduleMutation, isPending: deleting } = useDeleteSchedule();
|
||||
const { mutate: duplicateScheduleMutation } = useDuplicateSchedule();
|
||||
const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault();
|
||||
|
||||
setSchedules(sortedSchedules);
|
||||
setFilteredSchedules(sortedSchedules);
|
||||
} catch (err) {
|
||||
setError("Failed to load availability. Please check your API key and try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
// Convert query error to string
|
||||
// Don't show error UI for authentication errors (user will be redirected to login)
|
||||
// Only show error UI in development mode for other errors
|
||||
const isAuthError =
|
||||
queryError?.message?.includes("Authentication") ||
|
||||
queryError?.message?.includes("sign in") ||
|
||||
queryError?.message?.includes("401");
|
||||
const error = queryError && !isAuthError && __DEV__ ? "Failed to load availability." : null;
|
||||
|
||||
// Filter schedules based on search query
|
||||
const filteredSchedules = useMemo(() => {
|
||||
if (searchQuery.trim() === "") {
|
||||
return schedules;
|
||||
}
|
||||
};
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return schedules.filter((schedule) => schedule.name.toLowerCase().includes(searchLower));
|
||||
}, [schedules, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, []);
|
||||
// Note: We don't use useFocusEffect here because schedules have Infinity stale time.
|
||||
// Data only refreshes on mutations (create/update/delete) or manual pull-to-refresh.
|
||||
|
||||
// Refresh schedules when screen comes into focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Only refresh if not currently loading (to avoid duplicate calls)
|
||||
if (!loading && !refreshing) {
|
||||
fetchSchedules();
|
||||
}
|
||||
}, [loading, refreshing])
|
||||
);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchSchedules();
|
||||
};
|
||||
// Handle pull-to-refresh (offline-aware)
|
||||
const onRefresh = () => offlineAwareRefresh(refetch);
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
if (query.trim() === "") {
|
||||
setFilteredSchedules(schedules);
|
||||
} else {
|
||||
const filtered = schedules.filter((schedule) =>
|
||||
schedule.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
setFilteredSchedules(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleLongPress = (schedule: Schedule) => {
|
||||
@@ -151,22 +143,20 @@ export default function Availability() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSetAsDefault = async (schedule: Schedule) => {
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(schedule.id, { isDefault: true });
|
||||
await fetchSchedules();
|
||||
} catch (err) {
|
||||
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
|
||||
}
|
||||
const handleSetAsDefault = (schedule: Schedule) => {
|
||||
setAsDefaultMutation(schedule.id, {
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicate = async (schedule: Schedule) => {
|
||||
try {
|
||||
await CalComAPIService.duplicateSchedule(schedule.id);
|
||||
await fetchSchedules();
|
||||
} catch (err) {
|
||||
Alert.alert("Error", "Failed to duplicate schedule. Please try again.");
|
||||
}
|
||||
const handleDuplicate = (schedule: Schedule) => {
|
||||
duplicateScheduleMutation(schedule.id, {
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to duplicate schedule. Please try again.");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (schedule: Schedule) => {
|
||||
@@ -180,32 +170,30 @@ export default function Availability() {
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await CalComAPIService.deleteSchedule(schedule.id);
|
||||
await fetchSchedules();
|
||||
} catch (err) {
|
||||
Alert.alert("Error", "Failed to delete schedule. Please try again.");
|
||||
}
|
||||
onPress: () => {
|
||||
deleteScheduleMutation(schedule.id, {
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const confirmDelete = () => {
|
||||
if (!selectedSchedule) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
await CalComAPIService.deleteSchedule(selectedSchedule.id);
|
||||
setShowDeleteModal(false);
|
||||
setSelectedSchedule(null);
|
||||
await fetchSchedules();
|
||||
} catch (err) {
|
||||
Alert.alert("Error", "Failed to delete schedule. Please try again.");
|
||||
setDeleting(false);
|
||||
}
|
||||
deleteScheduleMutation(selectedSchedule.id, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteModal(false);
|
||||
setSelectedSchedule(null);
|
||||
},
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSchedulePress = (schedule: Schedule) => {
|
||||
@@ -226,22 +214,20 @@ export default function Availability() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's timezone (default to America/New_York if not available)
|
||||
let userTimezone = "America/New_York";
|
||||
try {
|
||||
setCreating(true);
|
||||
|
||||
// Get user's timezone (default to America/New_York if not available)
|
||||
let userTimezone = "America/New_York";
|
||||
try {
|
||||
const userProfile = await CalComAPIService.getUserProfile();
|
||||
if (userProfile.timeZone) {
|
||||
userTimezone = userProfile.timeZone;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Could not get user timezone, using default");
|
||||
const userProfile = await CalComAPIService.getUserProfile();
|
||||
if (userProfile.timeZone) {
|
||||
userTimezone = userProfile.timeZone;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Could not get user timezone, using default");
|
||||
}
|
||||
|
||||
// Create schedule with Monday-Friday 9 AM - 5 PM default
|
||||
const newSchedule = await CalComAPIService.createSchedule({
|
||||
// Create schedule with Monday-Friday 9 AM - 5 PM default
|
||||
createScheduleMutation(
|
||||
{
|
||||
name: newScheduleName.trim(),
|
||||
timeZone: userTimezone,
|
||||
isDefault: false,
|
||||
@@ -252,27 +238,26 @@ export default function Availability() {
|
||||
endTime: "17:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: (newSchedule) => {
|
||||
setShowCreateModal(false);
|
||||
setNewScheduleName("");
|
||||
|
||||
setShowCreateModal(false);
|
||||
setNewScheduleName("");
|
||||
|
||||
// Navigate to edit the newly created schedule
|
||||
router.push({
|
||||
pathname: "/availability-detail",
|
||||
params: {
|
||||
id: newSchedule.id.toString(),
|
||||
// Navigate to edit the newly created schedule
|
||||
router.push({
|
||||
pathname: "/availability-detail",
|
||||
params: {
|
||||
id: newSchedule.id.toString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh the list
|
||||
fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error("Failed to create schedule:", error);
|
||||
Alert.alert("Error", "Failed to create schedule. Please try again.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
onError: (error) => {
|
||||
console.error("Failed to create schedule:", error);
|
||||
showErrorAlert("Error", "Failed to create schedule. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderSchedule = ({ item: schedule, index }: { item: Schedule; index: number }) => {
|
||||
@@ -339,8 +324,7 @@ export default function Availability() {
|
||||
<View className="flex-1 bg-[#f8f9fa]">
|
||||
<Header />
|
||||
<View className="flex-1 items-center justify-center p-5">
|
||||
<ActivityIndicator size="large" color="#000000" />
|
||||
<Text className="mt-4 text-base text-[#666]">Loading availability...</Text>
|
||||
<LoadingSpinner size="large" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -356,7 +340,7 @@ export default function Availability() {
|
||||
Unable to load availability
|
||||
</Text>
|
||||
<Text className="mb-6 text-center text-base text-[#666]">{error}</Text>
|
||||
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={fetchSchedules}>
|
||||
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={() => refetch()}>
|
||||
<Text className="text-base font-semibold text-white">Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -364,108 +348,100 @@ export default function Availability() {
|
||||
);
|
||||
}
|
||||
|
||||
if (schedules.length === 0 && !loading) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search schedules"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<Ionicons name="calendar-outline" size={64} color="#666" />
|
||||
<Text className="mb-2 mt-4 text-xl font-bold text-[#333]">No schedules found</Text>
|
||||
<Text className="text-center text-base text-[#666]">
|
||||
Create your availability schedule in Cal.com
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredSchedules.length === 0 && searchQuery.trim() !== "") {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search schedules"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<Ionicons name="search-outline" size={64} color="#666" />
|
||||
<Text className="mb-2 mt-4 text-xl font-bold text-[#333]">No results found</Text>
|
||||
<Text className="text-center text-base text-[#666]">
|
||||
Try searching with different keywords
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Determine what content to show
|
||||
const showEmptyState = schedules.length === 0 && !loading;
|
||||
const showSearchEmptyState =
|
||||
filteredSchedules.length === 0 && searchQuery.trim() !== "" && !showEmptyState;
|
||||
const showList = !showEmptyState && !showSearchEmptyState;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search schedules"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex-1 px-2 pt-4 md:px-4">
|
||||
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<FlatList
|
||||
data={filteredSchedules}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={renderSchedule}
|
||||
contentContainerStyle={{ paddingBottom: 90 }}
|
||||
|
||||
{/* Empty state - no schedules */}
|
||||
{showEmptyState && (
|
||||
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
}}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
>
|
||||
<EmptyScreen
|
||||
icon="time-outline"
|
||||
headline="Create an availability schedule"
|
||||
description="Creating availability schedules allows you to manage availability across event types. They can be applied to one or more event types."
|
||||
buttonText="New"
|
||||
onButtonPress={handleCreateNew}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Search bar and content for non-empty states */}
|
||||
{!showEmptyState && (
|
||||
<>
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search schedules"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Search empty state */}
|
||||
{showSearchEmptyState && (
|
||||
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
}}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
<EmptyScreen
|
||||
icon="search-outline"
|
||||
headline={`No results found for "${searchQuery}"`}
|
||||
description="Try searching with different keywords"
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Schedules list */}
|
||||
{showList && (
|
||||
<View className="flex-1 px-2 pt-4 md:px-4">
|
||||
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<FlatList
|
||||
data={filteredSchedules}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={renderSchedule}
|
||||
contentContainerStyle={{ paddingBottom: 90 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create Schedule Modal */}
|
||||
<FullScreenModal
|
||||
@@ -616,8 +592,8 @@ export default function Availability() {
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#EF4444" />
|
||||
<Text className="ml-3 text-base text-red-500">Delete</Text>
|
||||
<Ionicons name="trash-outline" size={20} color="#800000" />
|
||||
<Text className="ml-3 text-base text-[#800000]">Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -645,7 +621,7 @@ export default function Availability() {
|
||||
{/* Icon */}
|
||||
<View className="mb-4 items-center">
|
||||
<View className="h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<Ionicons name="trash-outline" size={24} color="#EF4444" />
|
||||
<Ionicons name="trash-outline" size={24} color="#800000" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, ScrollView, Linking, Alert } from "react-native";
|
||||
import { View, Text, TouchableOpacity, ScrollView, Alert } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Header } from "../../components/Header";
|
||||
import { LogoutButton } from "../../components/LogoutButton";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { showErrorAlert } from "../../utils/alerts";
|
||||
import { openInAppBrowser } from "../../utils/browser";
|
||||
|
||||
interface MoreMenuItem {
|
||||
name: string;
|
||||
@@ -15,19 +17,24 @@ interface MoreMenuItem {
|
||||
|
||||
export default function More() {
|
||||
const router = useRouter();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const openExternalLink = async (url: string, fallbackMessage: string) => {
|
||||
try {
|
||||
const supported = await Linking.canOpenURL(url);
|
||||
if (supported) {
|
||||
await Linking.openURL(url);
|
||||
} else {
|
||||
Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to open ${url}:`, error);
|
||||
Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`);
|
||||
}
|
||||
const handleSignOut = () => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
showErrorAlert("Error", "Failed to sign out. Please try again.");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const menuItems: MoreMenuItem[] = [
|
||||
@@ -36,37 +43,37 @@ export default function More() {
|
||||
icon: "person-outline",
|
||||
isExternal: true,
|
||||
onPress: () =>
|
||||
openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page"),
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"),
|
||||
},
|
||||
{
|
||||
name: "Apps",
|
||||
icon: "grid-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://app.cal.com/apps", "Apps page"),
|
||||
onPress: () => openInAppBrowser("https://app.cal.com/apps", "Apps page"),
|
||||
},
|
||||
{
|
||||
name: "Routing",
|
||||
icon: "git-branch-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://app.cal.com/routing", "Routing page"),
|
||||
onPress: () => openInAppBrowser("https://app.cal.com/routing", "Routing page"),
|
||||
},
|
||||
{
|
||||
name: "Workflows",
|
||||
icon: "flash-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://app.cal.com/workflows", "Workflows page"),
|
||||
onPress: () => openInAppBrowser("https://app.cal.com/workflows", "Workflows page"),
|
||||
},
|
||||
{
|
||||
name: "Insights",
|
||||
icon: "bar-chart-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://app.cal.com/insights", "Insights page"),
|
||||
onPress: () => openInAppBrowser("https://app.cal.com/insights", "Insights page"),
|
||||
},
|
||||
{
|
||||
name: "Support",
|
||||
icon: "help-circle-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://go.cal.com/support", "Support"),
|
||||
onPress: () => openInAppBrowser("https://go.cal.com/support", "Support"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -99,14 +106,27 @@ export default function More() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Authentication Info and Logout */}
|
||||
<View className="py-4">
|
||||
<LogoutButton className="w-full bg-transparent text-gray-900" />
|
||||
{/* Sign Out Button */}
|
||||
<View className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<TouchableOpacity
|
||||
onPress={handleSignOut}
|
||||
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50"
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={20} color="#800000" />
|
||||
<Text className="ml-2 text-base font-medium text-[#800000]">Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text className="mt-4 text-sm text-gray-500">
|
||||
We view the companion as an extension of the web application. If you are performing any
|
||||
complicated actions, please refer back to the web application.
|
||||
{/* Footer Note */}
|
||||
<Text className="mt-6 px-1 text-center text-xs text-gray-400">
|
||||
The companion app is an extension of the web application.{"\n"}
|
||||
For advanced features, visit{" "}
|
||||
<Text
|
||||
className="text-gray-800"
|
||||
onPress={() => openInAppBrowser("https://app.cal.com", "Cal.com")}
|
||||
>
|
||||
app.cal.com
|
||||
</Text>
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform, View, StatusBar } from "react-native";
|
||||
import { AuthProvider, useAuth } from "../contexts/AuthContext";
|
||||
import { QueryProvider } from "../contexts/QueryContext";
|
||||
import { NetworkStatusBanner } from "../components/NetworkStatusBanner";
|
||||
import LoginScreen from "../components/LoginScreen";
|
||||
import "../global.css";
|
||||
|
||||
@@ -35,14 +37,17 @@ function RootLayoutContent() {
|
||||
<View style={containerStyle} className={containerClass}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
|
||||
{content}
|
||||
<NetworkStatusBanner />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<RootLayoutContent />
|
||||
</AuthProvider>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<RootLayoutContent />
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CalComAPIService, Schedule } from "../services/calcom";
|
||||
import { ScheduleAvailability } from "../services/types";
|
||||
import { FullScreenModal } from "../components/FullScreenModal";
|
||||
import { showErrorAlert } from "../utils/alerts";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
@@ -220,7 +221,7 @@ export default function AvailabilityDetail() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching schedule:", error);
|
||||
Alert.alert("Error", "Failed to load schedule. Please try again.");
|
||||
showErrorAlert("Error", "Failed to load schedule. Please try again.");
|
||||
router.back();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -348,7 +349,7 @@ export default function AvailabilityDetail() {
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to update schedule. Please try again.");
|
||||
showErrorAlert("Error", "Failed to update schedule. Please try again.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -362,7 +363,7 @@ export default function AvailabilityDetail() {
|
||||
setIsDefault(true);
|
||||
Alert.alert("Success", "Schedule set as default successfully");
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
|
||||
showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -379,7 +380,7 @@ export default function AvailabilityDetail() {
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to delete schedule. Please try again.");
|
||||
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
TextInput,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CalComAPIService, Booking } from "../services/calcom";
|
||||
import { showErrorAlert } from "../utils/alerts";
|
||||
import { openInAppBrowser } from "../utils/browser";
|
||||
import { SvgImage } from "../components/SvgImage";
|
||||
import { FullScreenModal } from "../components/FullScreenModal";
|
||||
import { BookingActionsModal } from "../components/BookingActionsModal";
|
||||
import { getAppIconUrl } from "../utils/getAppIconUrl";
|
||||
import { getDefaultLocationIconUrl, defaultLocations } from "../utils/defaultLocations";
|
||||
import { formatAppIdToDisplayName } from "../utils/formatters";
|
||||
|
||||
// Format date: "Tuesday, November 25, 2025"
|
||||
const formatDateFull = (dateString: string): string => {
|
||||
@@ -137,14 +140,6 @@ const getLocationProvider = (location: string | undefined, metadata?: Record<str
|
||||
const iconUrl = getAppIconUrl("", appId);
|
||||
|
||||
if (iconUrl) {
|
||||
// Format appId to display name (e.g., "cal-video" -> "Cal Video")
|
||||
const formatAppIdToDisplayName = (id: string): string => {
|
||||
return id
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
return {
|
||||
label: formatAppIdToDisplayName(appId),
|
||||
iconUrl: iconUrl,
|
||||
@@ -181,6 +176,8 @@ export default function BookingDetail() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showActionsModal, setShowActionsModal] = useState(false);
|
||||
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
|
||||
const [rescheduleDate, setRescheduleDate] = useState("");
|
||||
const [rescheduleTime, setRescheduleTime] = useState("");
|
||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||
const [rescheduling, setRescheduling] = useState(false);
|
||||
|
||||
@@ -208,9 +205,13 @@ export default function BookingDetail() {
|
||||
} catch (err) {
|
||||
console.error("Error fetching booking:", err);
|
||||
setError("Failed to load booking. Please try again.");
|
||||
Alert.alert("Error", "Failed to load booking. Please try again.", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
if (__DEV__) {
|
||||
Alert.alert("Error", "Failed to load booking. Please try again.", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -221,31 +222,70 @@ export default function BookingDetail() {
|
||||
|
||||
const provider = getLocationProvider(booking.location);
|
||||
if (provider?.url) {
|
||||
Linking.openURL(provider.url);
|
||||
openInAppBrowser(provider.url, "meeting link");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReschedule = async () => {
|
||||
const openRescheduleModal = () => {
|
||||
if (!booking) return;
|
||||
|
||||
// Pre-fill with the current booking date/time (using local timezone consistently)
|
||||
const currentDate = new Date(booking.startTime);
|
||||
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`;
|
||||
const timeStr = `${String(currentDate.getHours()).padStart(2, "0")}:${String(currentDate.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
setRescheduleDate(dateStr);
|
||||
setRescheduleTime(timeStr);
|
||||
setRescheduleReason("");
|
||||
setShowRescheduleModal(true);
|
||||
};
|
||||
|
||||
const handleReschedule = async () => {
|
||||
if (!booking || !rescheduleDate || !rescheduleTime) {
|
||||
showErrorAlert("Error", "Please enter both date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the date and time
|
||||
const dateTimeStr = `${rescheduleDate}T${rescheduleTime}:00`;
|
||||
const newDateTime = new Date(dateTimeStr);
|
||||
|
||||
// Validate the date
|
||||
if (isNaN(newDateTime.getTime())) {
|
||||
showErrorAlert(
|
||||
"Error",
|
||||
"Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the new time is in the future
|
||||
if (newDateTime <= new Date()) {
|
||||
showErrorAlert("Error", "Please select a future date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to UTC ISO string
|
||||
const startUtc = newDateTime.toISOString();
|
||||
|
||||
setRescheduling(true);
|
||||
try {
|
||||
// For now, we'll use the current start time
|
||||
// In a full implementation, you'd show a date/time picker
|
||||
await CalComAPIService.rescheduleBooking(booking.uid, {
|
||||
start: booking.startTime,
|
||||
start: startUtc,
|
||||
reschedulingReason: rescheduleReason.trim() || undefined,
|
||||
});
|
||||
|
||||
Alert.alert("Success", "Reschedule request sent successfully");
|
||||
Alert.alert("Success", "Booking rescheduled successfully");
|
||||
setShowRescheduleModal(false);
|
||||
setRescheduleDate("");
|
||||
setRescheduleTime("");
|
||||
setRescheduleReason("");
|
||||
|
||||
// Refresh booking data
|
||||
await fetchBooking();
|
||||
} catch (error) {
|
||||
console.error("Failed to reschedule booking:", error);
|
||||
Alert.alert("Error", "Failed to send reschedule request. Please try again.");
|
||||
showErrorAlert("Error", "Failed to reschedule booking. Please try again.");
|
||||
} finally {
|
||||
setRescheduling(false);
|
||||
}
|
||||
@@ -470,193 +510,51 @@ export default function BookingDetail() {
|
||||
</View>
|
||||
|
||||
{/* Booking Actions Modal */}
|
||||
<FullScreenModal
|
||||
<BookingActionsModal
|
||||
visible={showActionsModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowActionsModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowActionsModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="border-b border-gray-200 p-6">
|
||||
<Text className="text-center text-xl font-semibold text-gray-900">
|
||||
Booking Actions
|
||||
</Text>
|
||||
</View>
|
||||
{/* Actions List */}
|
||||
<View className="p-2">
|
||||
{/* View Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
// TODO: Navigate to booking view page
|
||||
console.log("View booking");
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="eye-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">View Booking</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* Edit event label */}
|
||||
<View className="px-4 py-1">
|
||||
<Text className="text-xs font-medium text-gray-500">Edit event</Text>
|
||||
</View>
|
||||
|
||||
{/* Request Reschedule */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
setShowRescheduleModal(true);
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="send-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Send Reschedule Request</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Edit Location */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
// TODO: Open edit location dialog
|
||||
console.log("Edit location");
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="location-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Edit Location</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Add Guests */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
// TODO: Open add guests dialog
|
||||
console.log("Add guests");
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="person-add-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Add Guests</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* After event label */}
|
||||
<View className="px-4 py-1">
|
||||
<Text className="text-xs font-medium text-gray-500">After event</Text>
|
||||
</View>
|
||||
|
||||
{/* View Recordings */}
|
||||
{locationProvider?.url && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
// TODO: Open view recordings dialog
|
||||
console.log("View recordings");
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="videocam-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">View Recordings</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Meeting Session Details */}
|
||||
{locationProvider?.url && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
// TODO: Open session details dialog
|
||||
console.log("Meeting session details");
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="information-circle-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Meeting Session Details</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Mark as No-Show */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
// TODO: Mark as no-show
|
||||
console.log("Mark as no-show");
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="eye-off-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Mark as No-Show</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* Report Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
// TODO: Open report booking dialog
|
||||
console.log("Report booking");
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="flag-outline" size={20} color="#EF4444" />
|
||||
<Text className="ml-3 text-base text-red-500">Report Booking</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* Cancel Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [
|
||||
{ text: "No", style: "cancel" },
|
||||
{
|
||||
text: "Yes, Cancel",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
// TODO: Cancel booking
|
||||
console.log("Cancel booking");
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="close-circle-outline" size={20} color="#EF4444" />
|
||||
<Text className="ml-3 text-base text-red-500">Cancel Booking</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Cancel button */}
|
||||
<View className="border-t border-gray-200 p-2 md:p-4">
|
||||
<TouchableOpacity
|
||||
className="w-full rounded-lg bg-gray-100 p-3"
|
||||
onPress={() => setShowActionsModal(false)}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
onClose={() => setShowActionsModal(false)}
|
||||
booking={booking}
|
||||
hasLocationUrl={!!locationProvider?.url}
|
||||
isUpcoming={booking ? new Date(booking.startTime) > new Date() : false}
|
||||
isPast={booking ? new Date(booking.startTime) <= new Date() : false}
|
||||
isCancelled={booking?.status?.toUpperCase() === "CANCELLED"}
|
||||
isUnconfirmed={booking?.status?.toUpperCase() === "PENDING"}
|
||||
onReschedule={openRescheduleModal}
|
||||
onEditLocation={() => {
|
||||
Alert.alert("Edit Location", "Edit location functionality coming soon");
|
||||
}}
|
||||
onAddGuests={() => {
|
||||
Alert.alert("Add Guests", "Add guests functionality coming soon");
|
||||
}}
|
||||
onViewRecordings={() => {
|
||||
Alert.alert("View Recordings", "View recordings functionality coming soon");
|
||||
}}
|
||||
onMeetingSessionDetails={() => {
|
||||
Alert.alert(
|
||||
"Meeting Session Details",
|
||||
"Meeting session details functionality coming soon"
|
||||
);
|
||||
}}
|
||||
onMarkNoShow={() => {
|
||||
Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon");
|
||||
}}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality coming soon");
|
||||
}}
|
||||
onCancelBooking={() => {
|
||||
Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [
|
||||
{ text: "No", style: "cancel" },
|
||||
{
|
||||
text: "Yes, Cancel",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
// TODO: Implement cancel booking
|
||||
console.log("Cancel booking");
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Reschedule Modal */}
|
||||
<FullScreenModal
|
||||
@@ -685,37 +583,74 @@ export default function BookingDetail() {
|
||||
<View className="px-8 pb-4 pt-6">
|
||||
<View className="flex-row items-start space-x-3">
|
||||
<View className="h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#F3F4F6]">
|
||||
<Ionicons name="time-outline" size={24} color="#111827" />
|
||||
<Ionicons name="calendar-outline" size={24} color="#111827" />
|
||||
</View>
|
||||
<View className="flex-1 pt-1">
|
||||
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
|
||||
Reschedule request
|
||||
Reschedule Booking
|
||||
</Text>
|
||||
<Text className="text-sm text-[#6B7280]">
|
||||
Send a reschedule request to the organizer of this booking.
|
||||
Select a new date and time for this booking.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="px-8 pb-6">
|
||||
<Text className="mb-2 text-sm font-bold text-[#111827]">
|
||||
Reason for reschedule request
|
||||
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[100px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
|
||||
placeholder="Please let us know why you need to reschedule..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={rescheduleReason}
|
||||
onChangeText={setRescheduleReason}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
editable={!rescheduling}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView className="max-h-[400px] px-8 pb-6">
|
||||
{/* Date Input */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-bold text-[#111827]">
|
||||
New Date
|
||||
<Text className="font-normal text-[#6B7280]"> (YYYY-MM-DD)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
|
||||
placeholder="2024-12-25"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={rescheduleDate}
|
||||
onChangeText={setRescheduleDate}
|
||||
editable={!rescheduling}
|
||||
keyboardType="default"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Input */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-bold text-[#111827]">
|
||||
New Time
|
||||
<Text className="font-normal text-[#6B7280]"> (HH:MM, 24-hour)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
|
||||
placeholder="14:30"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={rescheduleTime}
|
||||
onChangeText={setRescheduleTime}
|
||||
editable={!rescheduling}
|
||||
keyboardType="default"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Reason Input */}
|
||||
<View className="mb-2">
|
||||
<Text className="mb-2 text-sm font-bold text-[#111827]">
|
||||
Reason
|
||||
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={rescheduleReason}
|
||||
onChangeText={setRescheduleReason}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
editable={!rescheduling}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
|
||||
@@ -724,6 +659,8 @@ export default function BookingDetail() {
|
||||
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
|
||||
onPress={() => {
|
||||
setShowRescheduleModal(false);
|
||||
setRescheduleDate("");
|
||||
setRescheduleTime("");
|
||||
setRescheduleReason("");
|
||||
}}
|
||||
disabled={rescheduling}
|
||||
@@ -735,7 +672,11 @@ export default function BookingDetail() {
|
||||
onPress={handleReschedule}
|
||||
disabled={rescheduling}
|
||||
>
|
||||
<Text className="text-base font-medium text-white">Reschedule request</Text>
|
||||
{rescheduling ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Text className="text-base font-medium text-white">Reschedule</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
import React from "react";
|
||||
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface AdvancedTabProps {
|
||||
// Calendar event name
|
||||
calendarEventName: string;
|
||||
setCalendarEventName: (value: string) => void;
|
||||
|
||||
// Add to calendar email
|
||||
addToCalendarEmail: string;
|
||||
setAddToCalendarEmail: (value: string) => void;
|
||||
|
||||
// Layout
|
||||
selectedLayouts: string[];
|
||||
setSelectedLayouts: (layouts: string[]) => void;
|
||||
defaultLayout: string;
|
||||
setDefaultLayout: (layout: string) => void;
|
||||
|
||||
// Confirmation settings
|
||||
requiresConfirmation: boolean;
|
||||
setRequiresConfirmation: (value: boolean) => void;
|
||||
disableCancelling: boolean;
|
||||
setDisableCancelling: (value: boolean) => void;
|
||||
disableRescheduling: boolean;
|
||||
setDisableRescheduling: (value: boolean) => void;
|
||||
|
||||
// Additional settings
|
||||
sendCalVideoTranscription: boolean;
|
||||
setSendCalVideoTranscription: (value: boolean) => void;
|
||||
autoTranslate: boolean;
|
||||
setAutoTranslate: (value: boolean) => void;
|
||||
requiresBookerEmailVerification: boolean;
|
||||
setRequiresBookerEmailVerification: (value: boolean) => void;
|
||||
hideCalendarNotes: boolean;
|
||||
setHideCalendarNotes: (value: boolean) => void;
|
||||
hideCalendarEventDetails: boolean;
|
||||
setHideCalendarEventDetails: (value: boolean) => void;
|
||||
hideOrganizerEmail: boolean;
|
||||
setHideOrganizerEmail: (value: boolean) => void;
|
||||
lockTimezone: boolean;
|
||||
setLockTimezone: (value: boolean) => void;
|
||||
allowReschedulingPastEvents: boolean;
|
||||
setAllowReschedulingPastEvents: (value: boolean) => void;
|
||||
allowBookingThroughRescheduleLink: boolean;
|
||||
setAllowBookingThroughRescheduleLink: (value: boolean) => void;
|
||||
|
||||
// Redirect on booking
|
||||
successRedirectUrl: string;
|
||||
setSuccessRedirectUrl: (value: string) => void;
|
||||
forwardParamsSuccessRedirect: boolean;
|
||||
setForwardParamsSuccessRedirect: (value: boolean) => void;
|
||||
|
||||
// Custom reply-to email
|
||||
customReplyToEmail: string;
|
||||
setCustomReplyToEmail: (value: string) => void;
|
||||
|
||||
// Event type colors
|
||||
eventTypeColorLight: string;
|
||||
setEventTypeColorLight: (value: string) => void;
|
||||
eventTypeColorDark: string;
|
||||
setEventTypeColorDark: (value: string) => void;
|
||||
}
|
||||
|
||||
export function AdvancedTab(props: AdvancedTabProps) {
|
||||
const layoutOptions = [
|
||||
{ id: "MONTH_VIEW", label: "Month", icon: "calendar-outline" },
|
||||
{ id: "WEEK_VIEW", label: "Weekly", icon: "calendar-outline" },
|
||||
{ id: "COLUMN_VIEW", label: "Column", icon: "list-outline" },
|
||||
];
|
||||
|
||||
return (
|
||||
<View className="gap-3">
|
||||
{/* Calendar Event Name Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Calendar event name</Text>
|
||||
<TextInput
|
||||
className="mb-2 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.calendarEventName}
|
||||
onChangeText={props.setCalendarEventName}
|
||||
placeholder="30min between Pro Example and {Scheduler}"
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
<Text className="text-xs text-[#666]">
|
||||
Use variables like {"{"}Scheduler{"}"} for booker name, {"{"}Organizer{"}"} for your name
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Add to Calendar Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Add to calendar</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
We'll display this email address as the organizer, and send confirmation emails here.
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.addToCalendarEmail}
|
||||
onChangeText={props.setAddToCalendarEmail}
|
||||
placeholder="pro@example.com"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Layout Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Layout</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
You can select multiple and your bookers can switch views.
|
||||
</Text>
|
||||
|
||||
{/* Layout Options */}
|
||||
<View className="mb-4 gap-2">
|
||||
{layoutOptions.map((layout) => (
|
||||
<TouchableOpacity
|
||||
key={layout.id}
|
||||
className={`flex-row items-center justify-between rounded-lg border p-3 ${
|
||||
props.selectedLayouts.includes(layout.id)
|
||||
? "border-black bg-[#F0F0F0]"
|
||||
: "border-[#E5E5EA]"
|
||||
}`}
|
||||
onPress={() => {
|
||||
if (props.selectedLayouts.includes(layout.id)) {
|
||||
// Don't allow deselecting if it's the only one
|
||||
if (props.selectedLayouts.length > 1) {
|
||||
props.setSelectedLayouts(props.selectedLayouts.filter((l) => l !== layout.id));
|
||||
// If removing the default, set a new default
|
||||
if (props.defaultLayout === layout.id) {
|
||||
const remaining = props.selectedLayouts.filter((l) => l !== layout.id);
|
||||
props.setDefaultLayout(remaining[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
props.setSelectedLayouts([...props.selectedLayouts, layout.id]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Ionicons name={layout.icon as any} size={20} color="#333" />
|
||||
<Text className="text-base text-[#333]">{layout.label}</Text>
|
||||
</View>
|
||||
{props.selectedLayouts.includes(layout.id) && (
|
||||
<Ionicons name="checkmark-circle" size={20} color="#000" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Default View */}
|
||||
{props.selectedLayouts.length > 1 && (
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">Default view</Text>
|
||||
<View className="gap-2">
|
||||
{props.selectedLayouts.map((layoutId) => {
|
||||
const layout = layoutOptions.find((l) => l.id === layoutId);
|
||||
if (!layout) return null;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={layout.id}
|
||||
className={`flex-row items-center justify-between rounded-lg border p-3 ${
|
||||
props.defaultLayout === layout.id
|
||||
? "border-black bg-[#F0F0F0]"
|
||||
: "border-[#E5E5EA]"
|
||||
}`}
|
||||
onPress={() => props.setDefaultLayout(layout.id)}
|
||||
>
|
||||
<Text className="text-base text-[#333]">{layout.label}</Text>
|
||||
{props.defaultLayout === layout.id && (
|
||||
<Ionicons name="checkmark-circle" size={20} color="#000" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Booking Questions Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Booking questions</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
Customize the questions asked on the booking page.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 md:px-4"
|
||||
onPress={() =>
|
||||
Alert.alert("Coming Soon", "Booking questions customization will be available soon.")
|
||||
}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={20} color="#666" />
|
||||
<Text className="ml-2 text-base text-[#666]">Manage booking questions</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Requires confirmation */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Requires confirmation</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
The booking needs to be manually confirmed before it is pushed to your calendar
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.requiresConfirmation}
|
||||
onValueChange={props.setRequiresConfirmation}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disable Cancelling */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Disable Cancelling</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Guests can no longer cancel the event with calendar invite or email
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.disableCancelling}
|
||||
onValueChange={props.setDisableCancelling}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disable Rescheduling */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Disable Rescheduling</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Guests can no longer reschedule the event with calendar invite or email
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.disableRescheduling}
|
||||
onValueChange={props.setDisableRescheduling}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Send Cal Video Transcription Emails */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Send Cal Video Transcription Emails
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Send emails with the transcription of the Cal Video after the meeting ends
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.sendCalVideoTranscription}
|
||||
onValueChange={props.setSendCalVideoTranscription}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Auto translate */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Auto translate title and description
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Automatically translate titles and descriptions to the visitor's browser language
|
||||
using AI
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.autoTranslate}
|
||||
onValueChange={props.setAutoTranslate}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Requires booker email verification */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Requires booker email verification
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
To ensure booker's email verification before scheduling events
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.requiresBookerEmailVerification}
|
||||
onValueChange={props.setRequiresBookerEmailVerification}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Hide notes in calendar */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Hide notes in calendar</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
For privacy reasons, additional inputs and notes will be hidden in the calendar entry
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.hideCalendarNotes}
|
||||
onValueChange={props.setHideCalendarNotes}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Hide calendar event details */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Hide calendar event details on shared calendars
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
When a calendar is shared, events are visible but details are hidden from those
|
||||
without write access
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.hideCalendarEventDetails}
|
||||
onValueChange={props.setHideCalendarEventDetails}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Hide organizer's email */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Hide organizer's email</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Hide organizer's email address from the booking screen, email notifications, and
|
||||
calendar events
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.hideOrganizerEmail}
|
||||
onValueChange={props.setHideOrganizerEmail}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Lock timezone */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Lock timezone on booking page
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
To lock the timezone on booking page, useful for in-person events
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.lockTimezone}
|
||||
onValueChange={props.setLockTimezone}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Allow rescheduling past events */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Allow rescheduling past events
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Enabling this option allows for past events to be rescheduled
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.allowReschedulingPastEvents}
|
||||
onValueChange={props.setAllowReschedulingPastEvents}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Allow booking through reschedule link */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Allow booking through reschedule link
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
When enabled, users will be able to create a new booking when trying to reschedule a
|
||||
cancelled booking
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.allowBookingThroughRescheduleLink}
|
||||
onValueChange={props.setAllowBookingThroughRescheduleLink}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Redirect on booking Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Redirect on booking</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
Redirect to a custom URL after a successful booking
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mb-3 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.successRedirectUrl}
|
||||
onChangeText={props.setSuccessRedirectUrl}
|
||||
placeholder="https://example.com/thank-you"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="url"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-sm text-[#333]">Forward query parameters</Text>
|
||||
<Switch
|
||||
value={props.forwardParamsSuccessRedirect}
|
||||
onValueChange={props.setForwardParamsSuccessRedirect}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Custom Reply-To Email Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Custom 'Reply-To' email</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
Use a different email address as the replyTo for confirmation emails instead of the
|
||||
organizer's email
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.customReplyToEmail}
|
||||
onChangeText={props.setCustomReplyToEmail}
|
||||
placeholder="reply@example.com"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Event Type Color Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Event type color</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
This is only used for event type & booking differentiation within the app. It is not
|
||||
displayed to bookers.
|
||||
</Text>
|
||||
<View className="gap-3">
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">Light theme color</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View
|
||||
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
|
||||
style={{ backgroundColor: props.eventTypeColorLight }}
|
||||
/>
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.eventTypeColorLight}
|
||||
onChangeText={props.setEventTypeColorLight}
|
||||
placeholder="#292929"
|
||||
placeholderTextColor="#8E8E93"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">Dark theme color</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View
|
||||
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
|
||||
style={{ backgroundColor: props.eventTypeColorDark }}
|
||||
/>
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.eventTypeColorDark}
|
||||
onChangeText={props.setEventTypeColorDark}
|
||||
placeholder="#FAFAFA"
|
||||
placeholderTextColor="#8E8E93"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import React from "react";
|
||||
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface RecurringTabProps {
|
||||
recurringEnabled: boolean;
|
||||
setRecurringEnabled: (value: boolean) => void;
|
||||
recurringInterval: string;
|
||||
setRecurringInterval: (value: string) => void;
|
||||
recurringFrequency: "daily" | "weekly" | "monthly" | "yearly";
|
||||
setRecurringFrequency: (value: "daily" | "weekly" | "monthly" | "yearly") => void;
|
||||
recurringOccurrences: string;
|
||||
setRecurringOccurrences: (value: string) => void;
|
||||
}
|
||||
|
||||
export function RecurringTab({
|
||||
recurringEnabled,
|
||||
setRecurringEnabled,
|
||||
recurringInterval,
|
||||
setRecurringInterval,
|
||||
recurringFrequency,
|
||||
setRecurringFrequency,
|
||||
recurringOccurrences,
|
||||
setRecurringOccurrences,
|
||||
}: RecurringTabProps) {
|
||||
return (
|
||||
<View className="gap-3">
|
||||
{/* Recurring Event Toggle Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Recurring event</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Set up this event type to repeat at regular intervals
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={recurringEnabled}
|
||||
onValueChange={setRecurringEnabled}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Recurring Configuration Card - shown when enabled */}
|
||||
{recurringEnabled && (
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-4 text-base font-semibold text-[#333]">Recurrence pattern</Text>
|
||||
|
||||
{/* Repeats Every */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">Repeats every</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
value={recurringInterval}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
// Don't allow empty or 0 values; fall back to 1 so users can keep editing
|
||||
if (numericValue === "" || numericValue === "0") {
|
||||
setRecurringInterval("1");
|
||||
return;
|
||||
}
|
||||
const num = parseInt(numericValue);
|
||||
if (num >= 1 && num <= 20) {
|
||||
setRecurringInterval(numericValue);
|
||||
}
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="flex-1 flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => {
|
||||
Alert.alert("Select Frequency", "Choose how often this event repeats", [
|
||||
{
|
||||
text: "Daily",
|
||||
onPress: () => setRecurringFrequency("daily"),
|
||||
},
|
||||
{
|
||||
text: "Weekly",
|
||||
onPress: () => setRecurringFrequency("weekly"),
|
||||
},
|
||||
{
|
||||
text: "Monthly",
|
||||
onPress: () => setRecurringFrequency("monthly"),
|
||||
},
|
||||
{
|
||||
text: "Yearly",
|
||||
onPress: () => setRecurringFrequency("yearly"),
|
||||
},
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Text className="text-base capitalize text-black">{recurringFrequency}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Maximum Occurrences */}
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">Maximum number of events</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
className="w-24 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
value={recurringOccurrences}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
// Don't allow empty or 0 values; fall back to 1 so users can keep editing
|
||||
if (numericValue === "" || numericValue === "0") {
|
||||
setRecurringOccurrences("1");
|
||||
return;
|
||||
}
|
||||
const num = parseInt(numericValue);
|
||||
if (num >= 1) {
|
||||
setRecurringOccurrences(numericValue);
|
||||
}
|
||||
}}
|
||||
placeholder="12"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="text-sm text-[#666]">occurrences</Text>
|
||||
</View>
|
||||
<Text className="mt-2 text-xs text-[#666]">
|
||||
The booking will create {recurringOccurrences} events that repeat {recurringFrequency}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Utility functions for Event Type Detail
|
||||
|
||||
export const formatDuration = (minutes: string) => {
|
||||
const mins = parseInt(minutes) || 0;
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remainingMins = mins % 60;
|
||||
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
export const truncateTitle = (text: string, maxLength: number = 20) => {
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
};
|
||||
|
||||
export const formatAppIdToDisplayName = (appId: string): string => {
|
||||
// Convert appId like "google-meet" to "Google Meet"
|
||||
return appId
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
export const displayNameToLocationValue = (
|
||||
displayName: string,
|
||||
defaultLocations: Array<{ label: string; type: string }>
|
||||
): {
|
||||
type: string;
|
||||
integration?: string;
|
||||
address?: string;
|
||||
link?: string;
|
||||
phone?: string;
|
||||
public?: boolean;
|
||||
} | null => {
|
||||
// First check if it's a default location
|
||||
const defaultLocation = defaultLocations.find((loc) => loc.label === displayName);
|
||||
if (defaultLocation) {
|
||||
// Map internal location types to API location types
|
||||
switch (defaultLocation.type) {
|
||||
case "attendeeInPerson":
|
||||
return { type: "attendeeAddress" };
|
||||
case "inPerson":
|
||||
return { type: "address", address: "", public: true };
|
||||
case "link":
|
||||
return { type: "link", link: "", public: true };
|
||||
case "phone":
|
||||
return { type: "attendeePhone" };
|
||||
case "userPhone":
|
||||
return { type: "phone", phone: "", public: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a conferencing app (formatted display name)
|
||||
// e.g., "Google Meet", "Zoom", etc.
|
||||
const appId = displayName.toLowerCase().replace(/\s+/g, "-");
|
||||
return { type: "integration", integration: appId };
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
export default function Index() {
|
||||
return <Redirect href="/(tabs)/event-types" />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { View, Text, ActivityIndicator } from "react-native";
|
||||
import { View, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { useRouter, useLocalSearchParams } from "expo-router";
|
||||
|
||||
export default function OAuthCallback() {
|
||||
@@ -7,6 +7,8 @@ export default function OAuthCallback() {
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
|
||||
// Extract code, state, and error parameters from URL
|
||||
const code = params.code as string;
|
||||
const state = params.state as string;
|
||||
|
||||
|
After Width: | Height: | Size: 4.3 MiB |
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* BookingActionsModal Component
|
||||
*
|
||||
* A reusable modal component for booking actions that can be used in both
|
||||
* the bookings list screen and the booking detail screen.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, Alert } from "react-native";
|
||||
|
||||
import { FullScreenModal } from "./FullScreenModal";
|
||||
import type { Booking } from "../services/calcom";
|
||||
|
||||
export interface BookingActionsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
booking: Booking | null;
|
||||
hasLocationUrl?: boolean;
|
||||
isUpcoming?: boolean; // When true, disables "after event" actions (View Recordings, Session Details, Mark No-Show)
|
||||
isPast?: boolean; // When true, disables "edit event" actions (Reschedule, Edit Location, Add Guests) and Cancel Booking
|
||||
isCancelled?: boolean; // When true, only Report Booking and Mark as No-Show are enabled
|
||||
isUnconfirmed?: boolean; // When true, disables Reschedule, Edit Location, Add Guests
|
||||
onReschedule: () => void;
|
||||
onEditLocation: () => void;
|
||||
onAddGuests: () => void;
|
||||
onViewRecordings: () => void;
|
||||
onMeetingSessionDetails: () => void;
|
||||
onMarkNoShow: () => void;
|
||||
onReportBooking: () => void;
|
||||
onCancelBooking: () => void;
|
||||
}
|
||||
|
||||
// Style constants for easy customization
|
||||
const ICON_SIZE = 16;
|
||||
const ICON_COLOR = "#6B7280";
|
||||
const ICON_COLOR_DANGER = "#800000"; // Maroon
|
||||
const DISABLED_ICON_COLOR = "#D1D5DB";
|
||||
const TEXT_CLASS = "text-lg";
|
||||
const TEXT_COLOR_CLASS = "text-gray-900";
|
||||
const TEXT_COLOR_DANGER_CLASS = "text-[#800000]"; // Maroon
|
||||
const DISABLED_TEXT_COLOR_CLASS = "text-gray-300";
|
||||
|
||||
export function BookingActionsModal({
|
||||
visible,
|
||||
onClose,
|
||||
booking,
|
||||
hasLocationUrl = false,
|
||||
isUpcoming = false,
|
||||
isPast = false,
|
||||
isCancelled = false,
|
||||
isUnconfirmed = false,
|
||||
onReschedule,
|
||||
onEditLocation,
|
||||
onAddGuests,
|
||||
onViewRecordings,
|
||||
onMeetingSessionDetails,
|
||||
onMarkNoShow,
|
||||
onReportBooking,
|
||||
onCancelBooking,
|
||||
}: BookingActionsModalProps) {
|
||||
if (!booking) return null;
|
||||
|
||||
// For cancelled bookings, only Report Booking and Mark as No-Show are enabled
|
||||
// "After event" actions (except Mark as No-Show) are disabled for upcoming, cancelled, or unconfirmed bookings
|
||||
const afterEventActionsDisabled = isUpcoming || isCancelled || isUnconfirmed;
|
||||
// "Edit event" actions (Reschedule, Edit Location, Add Guests) are disabled for past, cancelled, or unconfirmed bookings
|
||||
const editEventActionsDisabled = isPast || isCancelled || isUnconfirmed;
|
||||
// Cancel booking is disabled for past or cancelled bookings (but NOT for unconfirmed - user can still cancel/decline)
|
||||
const cancelBookingDisabled = isPast || isCancelled;
|
||||
|
||||
return (
|
||||
<FullScreenModal visible={visible} animationType="fade" onRequestClose={onClose}>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Actions List */}
|
||||
<View className="p-2">
|
||||
{/* Edit event label */}
|
||||
<View className="px-4 py-1">
|
||||
<Text className="text-xs font-medium text-gray-500">Edit event</Text>
|
||||
</View>
|
||||
|
||||
{/* Reschedule Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (editEventActionsDisabled) return;
|
||||
onClose();
|
||||
onReschedule();
|
||||
}}
|
||||
disabled={editEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={ICON_SIZE}
|
||||
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Reschedule Booking
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Edit Location */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (editEventActionsDisabled) return;
|
||||
onClose();
|
||||
onEditLocation();
|
||||
}}
|
||||
disabled={editEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="location-outline"
|
||||
size={ICON_SIZE}
|
||||
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Edit Location
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Add Guests */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (editEventActionsDisabled) return;
|
||||
onClose();
|
||||
onAddGuests();
|
||||
}}
|
||||
disabled={editEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="person-add-outline"
|
||||
size={ICON_SIZE}
|
||||
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Add Guests
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* After event label */}
|
||||
<View className="px-4 py-1">
|
||||
<Text className="text-xs font-medium text-gray-500">After event</Text>
|
||||
</View>
|
||||
|
||||
{/* View Recordings */}
|
||||
{hasLocationUrl && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (afterEventActionsDisabled) return;
|
||||
onClose();
|
||||
onViewRecordings();
|
||||
}}
|
||||
disabled={afterEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="videocam-outline"
|
||||
size={ICON_SIZE}
|
||||
color={afterEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${afterEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
View Recordings
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Meeting Session Details */}
|
||||
{hasLocationUrl && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (afterEventActionsDisabled) return;
|
||||
onClose();
|
||||
onMeetingSessionDetails();
|
||||
}}
|
||||
disabled={afterEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="information-circle-outline"
|
||||
size={ICON_SIZE}
|
||||
color={afterEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${afterEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Meeting Session Details
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (isUpcoming || isUnconfirmed) return;
|
||||
onClose();
|
||||
onMarkNoShow();
|
||||
}}
|
||||
disabled={isUpcoming || isUnconfirmed}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="eye-off-outline"
|
||||
size={ICON_SIZE}
|
||||
color={isUpcoming || isUnconfirmed ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${isUpcoming || isUnconfirmed ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Mark as No-Show
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* Report Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onReportBooking();
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="flag-outline" size={ICON_SIZE} color={ICON_COLOR_DANGER} />
|
||||
<Text className={`ml-3 ${TEXT_CLASS} ${TEXT_COLOR_DANGER_CLASS}`}>
|
||||
Report Booking
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* Cancel Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (cancelBookingDisabled) return;
|
||||
onClose();
|
||||
onCancelBooking();
|
||||
}}
|
||||
disabled={cancelBookingDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="close-circle-outline"
|
||||
size={ICON_SIZE}
|
||||
color={cancelBookingDisabled ? DISABLED_ICON_COLOR : ICON_COLOR_DANGER}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${cancelBookingDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_DANGER_CLASS}`}
|
||||
>
|
||||
Cancel Event
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Cancel button */}
|
||||
<View className="border-t border-gray-200 p-2 md:p-4">
|
||||
<TouchableOpacity className="w-full rounded-lg bg-gray-100 p-3" onPress={onClose}>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* CacheStatusIndicator Component
|
||||
*
|
||||
* A subtle, non-intrusive component that displays cache status information:
|
||||
* - "Last updated: X minutes ago" when online
|
||||
* - "Offline - showing cached data" when offline
|
||||
*
|
||||
* This component is designed to be placed in screen headers or footers
|
||||
* to provide users with transparency about data freshness.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQueryContext } from "../contexts/QueryContext";
|
||||
|
||||
/**
|
||||
* Props for the CacheStatusIndicator component
|
||||
*/
|
||||
interface CacheStatusIndicatorProps {
|
||||
/** Timestamp when the data was last fetched (from query.dataUpdatedAt) */
|
||||
dataUpdatedAt?: number;
|
||||
/** Whether data is currently being fetched */
|
||||
isFetching?: boolean;
|
||||
/** Optional custom style */
|
||||
style?: object;
|
||||
/** Show compact version (icon only when online) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the time difference in a human-readable way
|
||||
*/
|
||||
const formatTimeAgo = (timestamp: number): string => {
|
||||
const now = Date.now();
|
||||
const diffMs = now - timestamp;
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
|
||||
if (diffSeconds < 10) {
|
||||
return "Just now";
|
||||
}
|
||||
if (diffSeconds < 60) {
|
||||
return `${diffSeconds}s ago`;
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes}m ago`;
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
}
|
||||
return "Over a day ago";
|
||||
};
|
||||
|
||||
/**
|
||||
* CacheStatusIndicator component
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { dataUpdatedAt, isFetching } = useBookings();
|
||||
*
|
||||
* <CacheStatusIndicator
|
||||
* dataUpdatedAt={dataUpdatedAt}
|
||||
* isFetching={isFetching}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function CacheStatusIndicator({
|
||||
dataUpdatedAt,
|
||||
isFetching = false,
|
||||
style,
|
||||
compact = false,
|
||||
}: CacheStatusIndicatorProps) {
|
||||
const { isOnline } = useQueryContext();
|
||||
|
||||
// Don't show anything if no data has been fetched yet
|
||||
if (!dataUpdatedAt && !isFetching && isOnline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Offline state
|
||||
if (!isOnline) {
|
||||
return (
|
||||
<View style={[styles.container, styles.offlineContainer, style]}>
|
||||
<Ionicons name="cloud-offline-outline" size={12} color="#FF9500" />
|
||||
<Text style={styles.offlineText}>Offline - showing cached data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Fetching state
|
||||
if (isFetching) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<Ionicons name="sync-outline" size={12} color="#8E8E93" />
|
||||
{!compact && <Text style={styles.text}>Updating...</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal state with last updated time
|
||||
if (dataUpdatedAt) {
|
||||
if (compact) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<Ionicons name="time-outline" size={12} color="#8E8E93" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<Ionicons name="time-outline" size={12} color="#8E8E93" />
|
||||
<Text style={styles.text}>Updated {formatTimeAgo(dataUpdatedAt)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
offlineContainer: {
|
||||
backgroundColor: "rgba(255, 149, 0, 0.1)",
|
||||
borderRadius: 4,
|
||||
},
|
||||
text: {
|
||||
fontSize: 11,
|
||||
color: "#8E8E93",
|
||||
},
|
||||
offlineText: {
|
||||
fontSize: 11,
|
||||
color: "#FF9500",
|
||||
fontWeight: "500",
|
||||
},
|
||||
});
|
||||
|
||||
export default CacheStatusIndicator;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity } from "react-native";
|
||||
|
||||
type IoniconName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
interface EmptyScreenProps {
|
||||
icon: IoniconName;
|
||||
headline: string;
|
||||
description: string;
|
||||
buttonText?: string;
|
||||
onButtonPress?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyScreen({
|
||||
icon,
|
||||
headline,
|
||||
description,
|
||||
buttonText,
|
||||
onButtonPress,
|
||||
className,
|
||||
}: EmptyScreenProps) {
|
||||
return (
|
||||
<View
|
||||
className={`flex w-full select-none flex-col items-center justify-center rounded-lg border border-gray-300 bg-white p-7 ${className || ""}`}
|
||||
>
|
||||
<View className="h-[72px] w-[72px] items-center justify-center rounded-full border border-gray-300 bg-gray-100">
|
||||
<Ionicons name={icon} size={40} color="#374151" />
|
||||
</View>
|
||||
|
||||
<View className="mt-6 max-w-[420px] flex-col items-center">
|
||||
<Text className="text-center text-2xl font-semibold text-gray-900">{headline}</Text>
|
||||
|
||||
<Text className="mb-8 mt-3 text-center text-sm font-normal leading-6 text-gray-500">
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{buttonText && onButtonPress && (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-center gap-1 rounded-lg bg-gray-900 px-4 py-2.5"
|
||||
onPress={onButtonPress}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-medium text-white">{buttonText}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -7,16 +7,16 @@ import {
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
ActionSheetIOS,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CalComAPIService, UserProfile } from "../services/calcom";
|
||||
import { CalComLogo } from "./CalComLogo";
|
||||
import { FullScreenModal } from "./FullScreenModal";
|
||||
import { openInAppBrowser } from "../utils/browser";
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
@@ -40,14 +40,41 @@ export function Header() {
|
||||
}
|
||||
};
|
||||
|
||||
// Build public page URL
|
||||
const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null;
|
||||
|
||||
const handleViewPublicPage = () => {
|
||||
if (publicPageUrl) {
|
||||
openInAppBrowser(publicPageUrl, "Public page");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyPublicPageLink = async () => {
|
||||
if (!publicPageUrl) return;
|
||||
try {
|
||||
await Clipboard.setStringAsync(publicPageUrl);
|
||||
Alert.alert("Link Copied!", "Your public page link has been copied to clipboard.");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy public page link:", error);
|
||||
Alert.alert("Error", "Failed to copy link. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfile = () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const options = ["Cancel", "My Profile", "My Settings", "Out of Office", "Help", "Sign Out"];
|
||||
const options = [
|
||||
"Cancel",
|
||||
"My Profile",
|
||||
"My Settings",
|
||||
"Out of Office",
|
||||
"View public page",
|
||||
"Copy public page link",
|
||||
"Help",
|
||||
];
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
destructiveButtonIndex: 6, // Sign Out
|
||||
cancelButtonIndex: 0,
|
||||
title: userProfile?.name || "Profile Menu",
|
||||
},
|
||||
@@ -62,11 +89,14 @@ export function Header() {
|
||||
case 3: // Out of Office
|
||||
handleMenuOption("outOfOffice");
|
||||
break;
|
||||
case 4: // Support
|
||||
handleMenuOption("help");
|
||||
case 4: // View public page
|
||||
handleViewPublicPage();
|
||||
break;
|
||||
case 5: // Sign Out
|
||||
handleMenuOption("signOut");
|
||||
case 5: // Copy public page link
|
||||
handleCopyPublicPageLink();
|
||||
break;
|
||||
case 6: // Help
|
||||
handleMenuOption("help");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -76,55 +106,28 @@ export function Header() {
|
||||
}
|
||||
};
|
||||
|
||||
const openExternalLink = async (url: string, fallbackMessage: string) => {
|
||||
try {
|
||||
const supported = await Linking.canOpenURL(url);
|
||||
if (supported) {
|
||||
await Linking.openURL(url);
|
||||
} else {
|
||||
Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to open ${url}:`, error);
|
||||
Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuOption = (option: string) => {
|
||||
if (Platform.OS !== "ios") {
|
||||
setShowProfileModal(false);
|
||||
}
|
||||
switch (option) {
|
||||
case "profile":
|
||||
openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page");
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page");
|
||||
break;
|
||||
case "settings":
|
||||
openExternalLink("https://app.cal.com/settings/my-account", "Settings page");
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account", "Settings page");
|
||||
break;
|
||||
case "outOfOffice":
|
||||
openExternalLink(
|
||||
openInAppBrowser(
|
||||
"https://app.cal.com/settings/my-account/out-of-office",
|
||||
"Out of Office page"
|
||||
);
|
||||
break;
|
||||
case "roadmap":
|
||||
openExternalLink("https://cal.com/roadmap", "Roadmap");
|
||||
openInAppBrowser("https://cal.com/roadmap", "Roadmap");
|
||||
break;
|
||||
case "help":
|
||||
openExternalLink("https://cal.com/help", "Help page");
|
||||
break;
|
||||
case "signOut":
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
// TODO: Implement sign out
|
||||
console.log("Sign Out pressed");
|
||||
},
|
||||
},
|
||||
]);
|
||||
openInAppBrowser("https://cal.com/help", "Help page");
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -250,6 +253,37 @@ export function Header() {
|
||||
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* View public page */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => {
|
||||
setShowProfileModal(false);
|
||||
handleViewPublicPage();
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="globe-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">View public page</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Copy public page link */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => {
|
||||
setShowProfileModal(false);
|
||||
handleCopyPublicPageLink();
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="copy-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Copy public page link</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => handleMenuOption("roadmap")}
|
||||
@@ -271,16 +305,6 @@ export function Header() {
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => handleMenuOption("signOut")}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={20} color="#EF4444" />
|
||||
<Text className="ml-3 text-base text-red-500">Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Cancel button */}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* LoadingSpinner Component
|
||||
*
|
||||
* A stylish loading spinner with iOS glass effect support when available.
|
||||
* Falls back to a nice styled container on other platforms.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { View, ActivityIndicator, Platform, StyleSheet } from "react-native";
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
/** Size of the spinner - defaults to large */
|
||||
size?: "small" | "large";
|
||||
/** Color of the spinner - defaults to system color */
|
||||
color?: string;
|
||||
/** Whether to show the container background */
|
||||
showBackground?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
size = "large",
|
||||
color,
|
||||
showBackground = true,
|
||||
}: LoadingSpinnerProps) {
|
||||
const supportsGlass = isLiquidGlassAvailable();
|
||||
|
||||
// Use glass effect on supported iOS devices
|
||||
if (supportsGlass && Platform.OS === "ios") {
|
||||
return (
|
||||
<GlassView style={styles.glassContainer} glassEffectStyle="regular">
|
||||
<ActivityIndicator size={size} color={color || "#000000"} />
|
||||
</GlassView>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled container for other platforms
|
||||
if (showBackground) {
|
||||
return (
|
||||
<View style={styles.styledContainer}>
|
||||
<ActivityIndicator size={size} color={color || "#000000"} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple spinner without background
|
||||
return <ActivityIndicator size={size} color={color || "#000000"} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
glassContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
borderRadius: 20,
|
||||
},
|
||||
styledContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* LocationsList Component
|
||||
* Reusable component for displaying and managing multiple event type locations
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Modal,
|
||||
ScrollView,
|
||||
Platform,
|
||||
ActionSheetIOS,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { SvgImage } from "./SvgImage";
|
||||
import { LocationItem, LocationOptionGroup } from "../types/locations";
|
||||
import {
|
||||
locationRequiresInput,
|
||||
getLocationInputPlaceholder,
|
||||
getLocationInputLabel,
|
||||
createLocationItemFromOption,
|
||||
} from "../utils/locationHelpers";
|
||||
|
||||
interface LocationsListProps {
|
||||
/** Array of current locations */
|
||||
locations: LocationItem[];
|
||||
/** Callback when a location is added */
|
||||
onAdd: (location: LocationItem) => void;
|
||||
/** Callback when a location is removed */
|
||||
onRemove: (locationId: string) => void;
|
||||
/** Callback when a location is updated (for input fields) */
|
||||
onUpdate: (locationId: string, updates: Partial<LocationItem>) => void;
|
||||
/** Available location options grouped by category */
|
||||
locationOptions: LocationOptionGroup[];
|
||||
/** Whether the component is disabled */
|
||||
disabled?: boolean;
|
||||
/** Whether locations are loading */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string): boolean {
|
||||
return locations.some((loc) => {
|
||||
if (loc.type === "integration" && optionValue.startsWith("integrations:")) {
|
||||
return loc.integration === optionValue.replace("integrations:", "");
|
||||
}
|
||||
if (["address", "link", "phone"].includes(loc.type)) {
|
||||
return false;
|
||||
}
|
||||
return loc.type === optionValue;
|
||||
});
|
||||
}
|
||||
|
||||
export const LocationsList: React.FC<LocationsListProps> = ({
|
||||
locations,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
locationOptions,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
const handleAddLocation = () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const allOptions: Array<{ label: string; value: string }> = [];
|
||||
locationOptions.forEach((group) => {
|
||||
group.options.forEach((option) => {
|
||||
if (!isLocationAlreadyAdded(locations, option.value)) {
|
||||
allOptions.push({ label: option.label, value: option.value });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const options = [...allOptions.map((o) => o.label), "Cancel"];
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex: options.length - 1,
|
||||
title: "Add Location",
|
||||
},
|
||||
(buttonIndex) => {
|
||||
if (buttonIndex !== options.length - 1 && buttonIndex < allOptions.length) {
|
||||
const selected = allOptions[buttonIndex];
|
||||
const newLocation = createLocationItemFromOption(selected.value, selected.label);
|
||||
onAdd(newLocation);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setShowAddModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOption = (optionValue: string, optionLabel: string) => {
|
||||
const newLocation = createLocationItemFromOption(optionValue, optionLabel);
|
||||
onAdd(newLocation);
|
||||
setShowAddModal(false);
|
||||
};
|
||||
|
||||
const renderLocationIcon = (location: LocationItem) => {
|
||||
if (location.iconUrl) {
|
||||
return <SvgImage uri={location.iconUrl} width={20} height={20} style={{ marginRight: 12 }} />;
|
||||
}
|
||||
return (
|
||||
<View style={{ marginRight: 12 }}>
|
||||
<Ionicons name="location-outline" size={20} color="#6B7280" />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLocationInput = (location: LocationItem) => {
|
||||
if (!locationRequiresInput(location.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const placeholder = getLocationInputPlaceholder(location.type);
|
||||
const label = getLocationInputLabel(location.type);
|
||||
|
||||
let value = "";
|
||||
let fieldKey: "address" | "link" | "phone" = "address";
|
||||
|
||||
switch (location.type) {
|
||||
case "address":
|
||||
value = location.address || "";
|
||||
fieldKey = "address";
|
||||
break;
|
||||
case "link":
|
||||
value = location.link || "";
|
||||
fieldKey = "link";
|
||||
break;
|
||||
case "phone":
|
||||
value = location.phone || "";
|
||||
fieldKey = "phone";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<Text className="mb-1 text-xs text-gray-500">{label}</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChangeText={(text) => onUpdate(location.id, { [fieldKey]: text })}
|
||||
editable={!disabled}
|
||||
keyboardType={location.type === "phone" ? "phone-pad" : "default"}
|
||||
autoCapitalize={location.type === "link" ? "none" : "sentences"}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* Locations List */}
|
||||
{locations.length > 0 ? (
|
||||
<View className="mb-3 space-y-2">
|
||||
{locations.map((location, index) => (
|
||||
<View key={location.id} className="rounded-lg border border-gray-200 bg-white p-3">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1 flex-row items-center">
|
||||
{renderLocationIcon(location)}
|
||||
<Text className="flex-1 text-base text-gray-900" numberOfLines={1}>
|
||||
{location.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
{!disabled && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemove(location.id)}
|
||||
className="ml-2 p-1"
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{renderLocationInput(location)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="mb-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||
<Text className="text-center text-sm text-gray-500">No locations added yet</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Add Location Button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleAddLocation}
|
||||
disabled={disabled || loading}
|
||||
className={`flex-row items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 ${
|
||||
disabled ? "opacity-50" : "active:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<Text className="text-sm text-gray-500">Loading options...</Text>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="add-circle-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-2 text-sm font-medium text-gray-600">Add Location</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Add Location Modal (for non-iOS) */}
|
||||
<Modal
|
||||
visible={showAddModal}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowAddModal(false)}
|
||||
>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="max-h-[70%] rounded-t-3xl bg-white">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<Text className="text-lg font-semibold text-gray-900">Add Location</Text>
|
||||
<TouchableOpacity onPress={() => setShowAddModal(false)} className="p-1">
|
||||
<Ionicons name="close" size={24} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Options List */}
|
||||
<ScrollView className="px-4 py-2">
|
||||
{locationOptions.map((group, groupIndex) => (
|
||||
<View key={group.category} className={groupIndex > 0 ? "mt-4" : ""}>
|
||||
<Text className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
{group.category}
|
||||
</Text>
|
||||
{group.options.map((option) => {
|
||||
const alreadyAdded = isLocationAlreadyAdded(locations, option.value);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleSelectOption(option.value, option.label)}
|
||||
disabled={alreadyAdded}
|
||||
className={`flex-row items-center rounded-lg px-2 py-3 ${
|
||||
alreadyAdded ? "opacity-40" : "active:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{option.iconUrl ? (
|
||||
<SvgImage
|
||||
uri={option.iconUrl}
|
||||
width={24}
|
||||
height={24}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<View style={{ marginRight: 12 }}>
|
||||
<Ionicons name="location-outline" size={24} color="#6B7280" />
|
||||
</View>
|
||||
)}
|
||||
<Text className="flex-1 text-base text-gray-900">{option.label}</Text>
|
||||
{alreadyAdded && <Ionicons name="checkmark" size={20} color="#10B981" />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
{/* Bottom padding for safe area */}
|
||||
<View className="h-8" />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +1,75 @@
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, Alert, ActivityIndicator } from "react-native";
|
||||
import { View, Text, TouchableOpacity } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { CalComLogo } from "./CalComLogo";
|
||||
import { showErrorAlert } from "../utils/alerts";
|
||||
import { openInAppBrowser } from "../utils/browser";
|
||||
|
||||
export function LoginScreen() {
|
||||
const { loginWithOAuth, loading } = useAuth();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
try {
|
||||
await loginWithOAuth();
|
||||
} catch (error) {
|
||||
console.error("OAuth login error:", error);
|
||||
Alert.alert(
|
||||
showErrorAlert(
|
||||
"Login Failed",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to login with OAuth. Please check your configuration and try again.",
|
||||
[{ text: "OK" }]
|
||||
: "Failed to login with OAuth. Please check your configuration and try again."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUp = async () => {
|
||||
await openInAppBrowser("https://app.cal.com/signup", "Sign up page");
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1 justify-center bg-white px-6">
|
||||
<View className="mb-8">
|
||||
<Text className="mb-2 text-center text-3xl font-bold text-gray-900">
|
||||
Welcome to Cal.com Companion
|
||||
</Text>
|
||||
<Text className="text-center text-gray-600">
|
||||
Sign in to manage your bookings and event types
|
||||
</Text>
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Logo centered in the middle */}
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<CalComLogo width={180} height={40} color="#111827" />
|
||||
</View>
|
||||
|
||||
{/* Bottom section with button */}
|
||||
<View className="px-6" style={{ paddingBottom: insets.bottom + 28 }}>
|
||||
{/* Primary CTA button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleOAuthLogin}
|
||||
disabled={loading}
|
||||
className="flex-row items-center justify-center rounded-2xl py-[18px]"
|
||||
style={{
|
||||
backgroundColor: loading ? "#9CA3AF" : "#111827",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: loading ? 0 : 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: loading ? 0 : 6,
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<Text className="text-[17px] font-semibold text-white">Continue with Cal.com</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Sign up link */}
|
||||
<TouchableOpacity
|
||||
onPress={handleSignUp}
|
||||
className="mt-3 items-center justify-center py-1"
|
||||
style={{ cursor: "pointer" } as any}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View>
|
||||
<Text className="text-[15px] text-gray-500">
|
||||
Don't have an account? <Text className="font-semibold text-gray-900">Sign up</Text>
|
||||
</Text>
|
||||
<View className="h-px bg-gray-400" style={{ marginTop: 2 }} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleOAuthLogin}
|
||||
disabled={loading}
|
||||
className="mb-4 flex-row items-center justify-center rounded-lg bg-gray-900 px-6 py-4"
|
||||
style={{ opacity: loading ? 0.7 : 1 }}
|
||||
>
|
||||
{loading ? <ActivityIndicator size="small" color="white" className="mr-2" /> : null}
|
||||
<Text className="text-lg font-semibold text-white">
|
||||
{loading ? "Signing in..." : "Sign in with Cal.com"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* NetworkStatusBanner Component
|
||||
*
|
||||
* Shows a minimal, classy popup when the device is offline.
|
||||
* - Shows popup when going offline
|
||||
* - Auto-dismisses when internet comes back
|
||||
* - Shows again on next disconnect
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { View, Text, TouchableOpacity, Modal, Animated } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
|
||||
|
||||
export function NetworkStatusBanner() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Simple refs to track state
|
||||
const previousOfflineRef = useRef<boolean | null>(null);
|
||||
const userDismissedRef = useRef(false);
|
||||
|
||||
const checkIfOffline = (state: NetInfoState): boolean => {
|
||||
if (state.isConnected === false) return true;
|
||||
if (state.isInternetReachable === false) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleNetworkChange = (state: NetInfoState) => {
|
||||
const currentlyOffline = checkIfOffline(state);
|
||||
const wasOffline = previousOfflineRef.current;
|
||||
|
||||
// Transition: Online → Offline (only show if user hasn't dismissed)
|
||||
if (currentlyOffline && wasOffline === false && !userDismissedRef.current) {
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
// Transition: Offline → Online
|
||||
if (!currentlyOffline && wasOffline === true) {
|
||||
setShowModal(false); // Auto-dismiss
|
||||
userDismissedRef.current = false; // Reset for next offline event
|
||||
}
|
||||
|
||||
previousOfflineRef.current = currentlyOffline;
|
||||
};
|
||||
|
||||
// Get initial state
|
||||
NetInfo.fetch().then((state) => {
|
||||
const offline = checkIfOffline(state);
|
||||
previousOfflineRef.current = offline;
|
||||
if (offline) {
|
||||
setShowModal(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for changes
|
||||
const unsubscribe = NetInfo.addEventListener(handleNetworkChange);
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
fadeAnim.setValue(0);
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [showModal, fadeAnim]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
userDismissedRef.current = true;
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setShowModal(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!showModal) return null;
|
||||
|
||||
return (
|
||||
<Modal transparent visible={showModal} animationType="none" onRequestClose={handleDismiss}>
|
||||
<Animated.View
|
||||
style={{ opacity: fadeAnim }}
|
||||
className="flex-1 items-center justify-center bg-black/40 px-8"
|
||||
>
|
||||
<View className="w-full max-w-xs items-center rounded-2xl bg-white px-6 py-8">
|
||||
<Ionicons name="cloud-offline-outline" size={36} color="#292929" />
|
||||
<Text className="mt-4 text-lg font-semibold text-gray-900">You're offline</Text>
|
||||
<Text className="mt-2 text-center text-sm leading-5 text-gray-500">
|
||||
The internet left the chat. Don't panic! You can still browse around.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleDismiss}
|
||||
className="mt-6 rounded-full bg-gray-900 px-8 py-3 active:bg-gray-700"
|
||||
>
|
||||
<Text className="text-sm font-medium text-white">Cool, got it</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkStatusBanner;
|
||||
@@ -0,0 +1,514 @@
|
||||
import React from "react";
|
||||
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { openInAppBrowser } from "../../../utils/browser";
|
||||
|
||||
interface ConfigureOnWebCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
eventTypeId: string;
|
||||
browserTitle: string;
|
||||
}
|
||||
|
||||
function ConfigureOnWebCard({
|
||||
title,
|
||||
description,
|
||||
eventTypeId,
|
||||
browserTitle,
|
||||
}: ConfigureOnWebCardProps) {
|
||||
return (
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-medium text-[#333]">{title}</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">{description}</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 md:px-4"
|
||||
onPress={() => {
|
||||
if (eventTypeId && eventTypeId !== "new") {
|
||||
openInAppBrowser(
|
||||
`https://app.cal.com/event-types/${eventTypeId}?tabName=advanced`,
|
||||
browserTitle
|
||||
);
|
||||
} else {
|
||||
Alert.alert("Info", "Save the event type first to configure this setting.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="settings-outline" size={20} color="#666" />
|
||||
<Text className="ml-2 text-base text-[#666]">Configure on Web</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdvancedTabProps {
|
||||
requiresConfirmation: boolean;
|
||||
setRequiresConfirmation: (value: boolean) => void;
|
||||
autoTranslate: boolean;
|
||||
setAutoTranslate: (value: boolean) => void;
|
||||
requiresBookerEmailVerification: boolean;
|
||||
setRequiresBookerEmailVerification: (value: boolean) => void;
|
||||
hideCalendarNotes: boolean;
|
||||
setHideCalendarNotes: (value: boolean) => void;
|
||||
hideCalendarEventDetails: boolean;
|
||||
setHideCalendarEventDetails: (value: boolean) => void;
|
||||
hideOrganizerEmail: boolean;
|
||||
setHideOrganizerEmail: (value: boolean) => void;
|
||||
lockTimezone: boolean;
|
||||
setLockTimezone: (value: boolean) => void;
|
||||
lockedTimezone: string;
|
||||
setLockedTimezone: (value: string) => void;
|
||||
allowReschedulingPastEvents: boolean;
|
||||
setAllowReschedulingPastEvents: (value: boolean) => void;
|
||||
allowBookingThroughRescheduleLink: boolean;
|
||||
setAllowBookingThroughRescheduleLink: (value: boolean) => void;
|
||||
successRedirectUrl: string;
|
||||
setSuccessRedirectUrl: (value: string) => void;
|
||||
forwardParamsSuccessRedirect: boolean;
|
||||
setForwardParamsSuccessRedirect: (value: boolean) => void;
|
||||
customReplyToEmail: string;
|
||||
setCustomReplyToEmail: (value: string) => void;
|
||||
eventTypeColorLight: string;
|
||||
setEventTypeColorLight: (value: string) => void;
|
||||
eventTypeColorDark: string;
|
||||
setEventTypeColorDark: (value: string) => void;
|
||||
seatsEnabled: boolean;
|
||||
setSeatsEnabled: (value: boolean) => void;
|
||||
seatsPerTimeSlot: string;
|
||||
setSeatsPerTimeSlot: (value: string) => void;
|
||||
showAttendeeInfo: boolean;
|
||||
setShowAttendeeInfo: (value: boolean) => void;
|
||||
showAvailabilityCount: boolean;
|
||||
setShowAvailabilityCount: (value: boolean) => void;
|
||||
eventTypeId: string;
|
||||
}
|
||||
|
||||
export function AdvancedTab(props: AdvancedTabProps) {
|
||||
return (
|
||||
<View className="gap-3">
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Requires confirmation</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
The booking needs to be manually confirmed before it is pushed to your calendar and a
|
||||
confirmation is sent.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.requiresConfirmation}
|
||||
onValueChange={props.setRequiresConfirmation}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
title="Disable Cancelling"
|
||||
description="Guests and Organizer can no longer cancel the event with calendar invite or email."
|
||||
eventTypeId={props.eventTypeId}
|
||||
browserTitle="Disable Cancelling"
|
||||
/>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
title="Disable Rescheduling"
|
||||
description="Guests and Organizer can no longer reschedule the event with calendar invite or email."
|
||||
eventTypeId={props.eventTypeId}
|
||||
browserTitle="Disable Rescheduling"
|
||||
/>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
title="Send Cal Video Transcription Emails"
|
||||
description="Send emails with the transcription of the Cal Video after the meeting ends. (Requires a paid plan)"
|
||||
eventTypeId={props.eventTypeId}
|
||||
browserTitle="Cal Video Transcription"
|
||||
/>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Auto translate title and description
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Automatically translate titles and descriptions to the visitor's browser language
|
||||
using AI.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.autoTranslate}
|
||||
onValueChange={props.setAutoTranslate}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
title="Interface Language"
|
||||
description="Set your preferred language for the booking interface."
|
||||
eventTypeId={props.eventTypeId}
|
||||
browserTitle="Interface Language"
|
||||
/>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Requires booker email verification
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
To ensure booker's email verification before scheduling events.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.requiresBookerEmailVerification}
|
||||
onValueChange={props.setRequiresBookerEmailVerification}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Hide notes in calendar</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
For privacy reasons, additional inputs and notes will be hidden in the calendar entry.
|
||||
They will still be sent to your email.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.hideCalendarNotes}
|
||||
onValueChange={props.setHideCalendarNotes}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Hide calendar event details on shared calendars
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
When a calendar is shared, events are visible to readers but their details are hidden
|
||||
from those without write access.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.hideCalendarEventDetails}
|
||||
onValueChange={props.setHideCalendarEventDetails}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-medium text-[#333]">Redirect on booking</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
Redirect to a custom URL after a successful booking.
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mb-3 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.successRedirectUrl}
|
||||
onChangeText={props.setSuccessRedirectUrl}
|
||||
placeholder="https://example.com/thank-you"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="url"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="flex-1 text-sm text-[#333]">
|
||||
Forward parameters such as ?email=...&name=...
|
||||
</Text>
|
||||
<Switch
|
||||
value={props.forwardParamsSuccessRedirect}
|
||||
onValueChange={props.setForwardParamsSuccessRedirect}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
{props.successRedirectUrl ? (
|
||||
<Text className="mt-2 text-xs text-[#FF9500]">
|
||||
Adding a redirect will disable the success page. Make sure to mention "Booking
|
||||
Confirmed" on your custom success page.
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-medium text-[#333]">Private Links</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
Generate private URLs without exposing the username, with configurable expiry and usage
|
||||
limits.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 md:px-4"
|
||||
onPress={() => {
|
||||
if (props.eventTypeId && props.eventTypeId !== "new") {
|
||||
openInAppBrowser(
|
||||
`https://app.cal.com/event-types/${props.eventTypeId}?tabName=advanced`,
|
||||
"Private Links"
|
||||
);
|
||||
} else {
|
||||
Alert.alert("Info", "Save the event type first to manage private links.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="link-outline" size={20} color="#666" />
|
||||
<Text className="ml-2 text-base text-[#666]">Manage Private Links</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white">
|
||||
<View className="flex-row items-start justify-between p-5">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Offer seats</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Offer seats for booking. This automatically disables guest & opt-in bookings.{" "}
|
||||
<Text
|
||||
className="text-sm text-[#007AFF]"
|
||||
onPress={() =>
|
||||
openInAppBrowser("https://cal.com/help/event-types/offer-seats", "Learn more")
|
||||
}
|
||||
>
|
||||
Learn more
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.seatsEnabled}
|
||||
onValueChange={props.setSeatsEnabled}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Seats Configuration - shown when enabled */}
|
||||
{props.seatsEnabled && (
|
||||
<View className="gap-4 border-t border-[#E5E5EA] p-5">
|
||||
{/* Number of seats per booking */}
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">
|
||||
Number of seats per booking
|
||||
</Text>
|
||||
<View className="flex-row items-center">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
value={props.seatsPerTimeSlot}
|
||||
onChangeText={props.setSeatsPerTimeSlot}
|
||||
placeholder="2"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="ml-3 text-base text-[#666]">seats</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Share attendee information between guests - Checkbox style */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
onPress={() => props.setShowAttendeeInfo(!props.showAttendeeInfo)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
className={`mr-3 h-5 w-5 items-center justify-center rounded border ${
|
||||
props.showAttendeeInfo
|
||||
? "border-[#111827] bg-[#111827]"
|
||||
: "border-[#D1D5DB] bg-white"
|
||||
}`}
|
||||
>
|
||||
{props.showAttendeeInfo && <Ionicons name="checkmark" size={14} color="#fff" />}
|
||||
</View>
|
||||
<Text className="text-sm text-[#333]">Share attendee information between guests</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Show the number of available seats - Checkbox style */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
onPress={() => props.setShowAvailabilityCount(!props.showAvailabilityCount)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
className={`mr-3 h-5 w-5 items-center justify-center rounded border ${
|
||||
props.showAvailabilityCount
|
||||
? "border-[#111827] bg-[#111827]"
|
||||
: "border-[#D1D5DB] bg-white"
|
||||
}`}
|
||||
>
|
||||
{props.showAvailabilityCount && (
|
||||
<Ionicons name="checkmark" size={14} color="#fff" />
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-sm text-[#333]">Show the number of available seats</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Hide organizer's email</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
Hide organizer's email address from the booking screen, email notifications, and
|
||||
calendar events.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.hideOrganizerEmail}
|
||||
onValueChange={props.setHideOrganizerEmail}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white">
|
||||
<View className="flex-row items-start justify-between p-5">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Lock timezone on booking page
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
To lock the timezone on booking page, useful for in-person events.{" "}
|
||||
<Text
|
||||
className="text-sm text-[#007AFF]"
|
||||
onPress={() =>
|
||||
openInAppBrowser("https://cal.com/help/event-types/lock-timezone", "Learn more")
|
||||
}
|
||||
>
|
||||
Learn more
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.lockTimezone}
|
||||
onValueChange={props.setLockTimezone}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Timezone selector - shown when enabled */}
|
||||
{props.lockTimezone && (
|
||||
<View className="border-t border-[#E5E5EA] p-5">
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">Timezone</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
"Select Timezone",
|
||||
"To change the timezone, please use the Cal.com website for the full timezone selector.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Open Website",
|
||||
onPress: () =>
|
||||
openInAppBrowser("https://app.cal.com/event-types", "Cal.com Event Types"),
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className="text-base text-[#333]">
|
||||
{props.lockedTimezone || "Europe/London"}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
title="Allow rescheduling past events"
|
||||
description="Enabling this option allows for past events to be rescheduled."
|
||||
eventTypeId={props.eventTypeId}
|
||||
browserTitle="Allow Rescheduling Past Events"
|
||||
/>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
title="Allow booking through reschedule link"
|
||||
description="When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking."
|
||||
eventTypeId={props.eventTypeId}
|
||||
browserTitle="Allow Booking Through Reschedule Link"
|
||||
/>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
title="Custom 'Reply-To' email"
|
||||
description="Use a different email address as the replyTo for confirmation emails instead of the organizer's email."
|
||||
eventTypeId={props.eventTypeId}
|
||||
browserTitle="Custom Reply-To Email"
|
||||
/>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<Text className="mb-1.5 text-base font-medium text-[#333]">Event type color</Text>
|
||||
<Text className="mb-3 text-sm text-[#666]">
|
||||
This is only used for event type & booking differentiation within the app. It is not
|
||||
displayed to bookers.
|
||||
</Text>
|
||||
<View className="gap-3">
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">
|
||||
Event Type Color (Light Theme)
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View
|
||||
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
|
||||
style={{
|
||||
backgroundColor: props.eventTypeColorLight.startsWith("#")
|
||||
? props.eventTypeColorLight
|
||||
: `#${props.eventTypeColorLight}`,
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.eventTypeColorLight}
|
||||
onChangeText={props.setEventTypeColorLight}
|
||||
placeholder="292929"
|
||||
placeholderTextColor="#8E8E93"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">
|
||||
Event Type Color (Dark Theme)
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View
|
||||
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
|
||||
style={{
|
||||
backgroundColor: props.eventTypeColorDark.startsWith("#")
|
||||
? props.eventTypeColorDark
|
||||
: `#${props.eventTypeColorDark}`,
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.eventTypeColorDark}
|
||||
onChangeText={props.setEventTypeColorDark}
|
||||
placeholder="fafafa"
|
||||
placeholderTextColor="#8E8E93"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
title="Optimized slots"
|
||||
description="Arrange time slots to optimize availability."
|
||||
eventTypeId={props.eventTypeId}
|
||||
browserTitle="Optimized Slots"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { View, Text, TextInput, TouchableOpacity, Switch } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { SvgImage } from "../../../components/SvgImage";
|
||||
import { defaultLocations, DefaultLocationType } from "../../../utils/defaultLocations";
|
||||
|
||||
import { LocationsList } from "../../../components/LocationsList";
|
||||
import { LocationItem, LocationOptionGroup } from "../../../types/locations";
|
||||
import { slugify } from "../../../utils/slugify";
|
||||
|
||||
interface BasicsTabProps {
|
||||
@@ -25,17 +26,13 @@ interface BasicsTabProps {
|
||||
defaultDuration: string;
|
||||
setShowDefaultDurationDropdown: (show: boolean) => void;
|
||||
|
||||
// Location
|
||||
selectedLocation: string;
|
||||
setShowLocationDropdown: (show: boolean) => void;
|
||||
// Multiple locations support
|
||||
locations: LocationItem[];
|
||||
onAddLocation: (location: LocationItem) => void;
|
||||
onRemoveLocation: (locationId: string) => void;
|
||||
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
|
||||
locationOptions: LocationOptionGroup[];
|
||||
conferencingLoading: boolean;
|
||||
getSelectedLocationIconUrl: () => string | null;
|
||||
locationAddress: string;
|
||||
setLocationAddress: (value: string) => void;
|
||||
locationLink: string;
|
||||
setLocationLink: (value: string) => void;
|
||||
locationPhone: string;
|
||||
setLocationPhone: (value: string) => void;
|
||||
}
|
||||
|
||||
export function BasicsTab(props: BasicsTabProps) {
|
||||
@@ -155,102 +152,15 @@ export function BasicsTab(props: BasicsTabProps) {
|
||||
|
||||
{/* Location Card */}
|
||||
<View className="rounded-2xl bg-white p-5">
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Location</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowLocationDropdown(true)}
|
||||
disabled={props.conferencingLoading}
|
||||
>
|
||||
<View className="flex-1 flex-row items-center">
|
||||
{!props.conferencingLoading &&
|
||||
props.selectedLocation &&
|
||||
props.getSelectedLocationIconUrl() && (
|
||||
<SvgImage
|
||||
uri={props.getSelectedLocationIconUrl()!}
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-base text-black">
|
||||
{props.conferencingLoading
|
||||
? "Loading locations..."
|
||||
: props.selectedLocation || "Select location"}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Location Input Fields - shown conditionally based on selected location type */}
|
||||
{(() => {
|
||||
const currentLocation = defaultLocations.find(
|
||||
(loc) => loc.label === props.selectedLocation
|
||||
);
|
||||
if (!currentLocation || !currentLocation.organizerInputType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentLocation.organizerInputType === "text") {
|
||||
// Text input for address or link
|
||||
const isAddress = currentLocation.type === "inPerson";
|
||||
const isLink = currentLocation.type === "link";
|
||||
|
||||
return (
|
||||
<View className="mt-3">
|
||||
<Text className="mb-1.5 text-sm font-medium text-[#333]">
|
||||
{currentLocation.organizerInputLabel ||
|
||||
(isAddress ? "Address" : "Meeting Link")}
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 text-base text-[#333] md:px-4"
|
||||
placeholder={currentLocation.organizerInputPlaceholder || ""}
|
||||
value={isAddress ? props.locationAddress : props.locationLink}
|
||||
onChangeText={(text) => {
|
||||
if (isAddress) {
|
||||
props.setLocationAddress(text);
|
||||
} else {
|
||||
props.setLocationLink(text);
|
||||
}
|
||||
}}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType={isLink ? "url" : "default"}
|
||||
/>
|
||||
{currentLocation.messageForOrganizer && (
|
||||
<Text className="mt-2 text-xs text-[#666]">
|
||||
{currentLocation.messageForOrganizer}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
} else if (currentLocation.organizerInputType === "phone") {
|
||||
// Phone input
|
||||
return (
|
||||
<View className="mt-3">
|
||||
<Text className="mb-1.5 text-sm font-medium text-[#333]">
|
||||
{currentLocation.organizerInputLabel || "Phone Number"}
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 text-base text-[#333] md:px-4"
|
||||
placeholder={currentLocation.organizerInputPlaceholder || "Enter phone number"}
|
||||
value={props.locationPhone}
|
||||
onChangeText={props.setLocationPhone}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
{currentLocation.messageForOrganizer && (
|
||||
<Text className="mt-2 text-xs text-[#666]">
|
||||
{currentLocation.messageForOrganizer}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</View>
|
||||
<Text className="mb-3 text-base font-semibold text-[#333]">Locations</Text>
|
||||
<LocationsList
|
||||
locations={props.locations}
|
||||
onAdd={props.onAddLocation}
|
||||
onRemove={props.onRemoveLocation}
|
||||
onUpdate={props.onUpdateLocation}
|
||||
locationOptions={props.locationOptions}
|
||||
loading={props.conferencingLoading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -1,13 +1,6 @@
|
||||
import React from "react";
|
||||
import { View, Text, TextInput, TouchableOpacity, Switch, Animated } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
bufferTimeOptions,
|
||||
timeUnitOptions,
|
||||
frequencyUnitOptions,
|
||||
durationUnitOptions,
|
||||
slotIntervalOptions,
|
||||
} from "../constants";
|
||||
|
||||
interface FrequencyLimit {
|
||||
id: number;
|
||||
@@ -48,6 +41,10 @@ interface LimitsTabProps {
|
||||
removeFrequencyLimit: (id: number) => void;
|
||||
addFrequencyLimit: () => void;
|
||||
|
||||
// Only show first slot
|
||||
onlyShowFirstAvailableSlot: boolean;
|
||||
setOnlyShowFirstAvailableSlot: (value: boolean) => void;
|
||||
|
||||
// Total duration
|
||||
limitTotalDuration: boolean;
|
||||
toggleTotalDuration: (value: boolean) => void;
|
||||
@@ -58,15 +55,13 @@ interface LimitsTabProps {
|
||||
removeDurationLimit: (id: number) => void;
|
||||
addDurationLimit: () => void;
|
||||
|
||||
// Only show first slot
|
||||
onlyShowFirstAvailableSlot: boolean;
|
||||
setOnlyShowFirstAvailableSlot: (value: boolean) => void;
|
||||
|
||||
// Max active bookings
|
||||
maxActiveBookingsPerBooker: boolean;
|
||||
setMaxActiveBookingsPerBooker: (value: boolean) => void;
|
||||
maxActiveBookingsValue: string;
|
||||
setMaxActiveBookingsValue: (value: string) => void;
|
||||
offerReschedule: boolean;
|
||||
setOfferReschedule: (value: boolean) => void;
|
||||
|
||||
// Future bookings
|
||||
limitFutureBookings: boolean;
|
||||
@@ -159,7 +154,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Booking Frequency Limit Card */}
|
||||
{/* 1. Booking Frequency Limit Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
@@ -190,7 +185,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
>
|
||||
{props.limitBookingFrequency && (
|
||||
<>
|
||||
{props.frequencyLimits.map((limit, index) => (
|
||||
{props.frequencyLimits.map((limit) => (
|
||||
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
@@ -218,7 +213,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
|
||||
onPress={() => props.removeFrequencyLimit(limit.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
<Ionicons name="trash-outline" size={20} color="#800000" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -235,7 +230,28 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Total Booking Duration Limit Card */}
|
||||
{/* 2. Only Show First Available Slot Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Only show the first slot of each day as available
|
||||
</Text>
|
||||
<Text className="text-sm leading-5 text-[#666]">
|
||||
This will limit your availability for this event type to one slot per day, scheduled
|
||||
at the earliest available time.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.onlyShowFirstAvailableSlot}
|
||||
onValueChange={props.setOnlyShowFirstAvailableSlot}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 3. Total Booking Duration Limit Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
@@ -268,7 +284,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
>
|
||||
{props.limitTotalDuration && (
|
||||
<>
|
||||
{props.durationLimits.map((limit, index) => (
|
||||
{props.durationLimits.map((limit) => (
|
||||
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<TextInput
|
||||
@@ -299,7 +315,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
|
||||
onPress={() => props.removeDurationLimit(limit.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
<Ionicons name="trash-outline" size={20} color="#800000" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -316,30 +332,9 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Only Show First Available Slot Card */}
|
||||
{/* 4. Max Active Bookings Per Booker Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Only show the first slot of each day as available
|
||||
</Text>
|
||||
<Text className="text-sm leading-5 text-[#666]">
|
||||
This will limit your availability for this event type to one slot per day, scheduled
|
||||
at the earliest available time.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.onlyShowFirstAvailableSlot}
|
||||
onValueChange={props.setOnlyShowFirstAvailableSlot}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Max Active Bookings Per Booker Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="mb-3 flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Limit number of upcoming bookings per booker
|
||||
@@ -356,28 +351,46 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
/>
|
||||
</View>
|
||||
{props.maxActiveBookingsPerBooker && (
|
||||
<View className="mt-3">
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.maxActiveBookingsValue}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue) || 0;
|
||||
if (num >= 0) {
|
||||
props.setMaxActiveBookingsValue(numericValue || "1");
|
||||
}
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<View className="mt-4 gap-3">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
value={props.maxActiveBookingsValue}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue) || 0;
|
||||
if (num >= 0) {
|
||||
props.setMaxActiveBookingsValue(numericValue || "1");
|
||||
}
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="text-base text-[#666]">bookings</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
onPress={() => props.setOfferReschedule(!props.offerReschedule)}
|
||||
>
|
||||
<View
|
||||
className={`mr-3 h-5 w-5 items-center justify-center rounded border ${
|
||||
props.offerReschedule ? "border-[#007AFF] bg-[#007AFF]" : "border-[#C7C7CC]"
|
||||
}`}
|
||||
>
|
||||
{props.offerReschedule && <Ionicons name="checkmark" size={14} color="#FFFFFF" />}
|
||||
</View>
|
||||
<Text className="flex-1 text-sm text-[#333]">
|
||||
Offer to reschedule the last booking to the new time slot
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Limit Future Bookings Card */}
|
||||
{/* 5. Limit Future Bookings Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="mb-3 flex-row items-start justify-between">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Limit future bookings</Text>
|
||||
<Text className="text-sm leading-5 text-[#666]">
|
||||
@@ -392,106 +405,87 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
/>
|
||||
</View>
|
||||
{props.limitFutureBookings && (
|
||||
<View className="mt-3 gap-3">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<TouchableOpacity
|
||||
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
|
||||
props.futureBookingType === "rolling"
|
||||
? "border-[#333] bg-[#F0F0F0]"
|
||||
: "border-[#E5E5EA] bg-[#F8F9FA]"
|
||||
<View className="mt-4 gap-3">
|
||||
{/* Rolling option */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-start"
|
||||
onPress={() => props.setFutureBookingType("rolling")}
|
||||
>
|
||||
<View
|
||||
className={`mr-3 mt-0.5 h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
props.futureBookingType === "rolling" ? "border-[#007AFF]" : "border-[#C7C7CC]"
|
||||
}`}
|
||||
onPress={() => props.setFutureBookingType("rolling")}
|
||||
>
|
||||
<Text
|
||||
className={`text-base ${
|
||||
props.futureBookingType === "rolling"
|
||||
? "font-semibold text-[#333]"
|
||||
: "text-[#666]"
|
||||
}`}
|
||||
>
|
||||
Rolling
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
|
||||
props.futureBookingType === "range"
|
||||
? "border-[#333] bg-[#F0F0F0]"
|
||||
: "border-[#E5E5EA] bg-[#F8F9FA]"
|
||||
}`}
|
||||
onPress={() => props.setFutureBookingType("range")}
|
||||
>
|
||||
<Text
|
||||
className={`text-base ${
|
||||
props.futureBookingType === "range"
|
||||
? "font-semibold text-[#333]"
|
||||
: "text-[#666]"
|
||||
}`}
|
||||
>
|
||||
Date Range
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{props.futureBookingType === "rolling" && (
|
||||
<View className="gap-3">
|
||||
<View className="flex-row items-center gap-3">
|
||||
{props.futureBookingType === "rolling" && (
|
||||
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<View className="flex-row flex-wrap items-center gap-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
className="w-16 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-2 text-center text-base text-black"
|
||||
value={props.rollingDays}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue) || 0;
|
||||
if (num >= 0) {
|
||||
props.setRollingDays(numericValue || "30");
|
||||
}
|
||||
props.setRollingDays(numericValue || "30");
|
||||
props.setFutureBookingType("rolling");
|
||||
}}
|
||||
placeholder="30"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
|
||||
props.rollingCalendarDays
|
||||
? "border-[#333] bg-[#F0F0F0]"
|
||||
: "border-[#E5E5EA] bg-[#F8F9FA]"
|
||||
}`}
|
||||
onPress={() => props.setRollingCalendarDays(!props.rollingCalendarDays)}
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2"
|
||||
onPress={() => {
|
||||
props.setRollingCalendarDays(!props.rollingCalendarDays);
|
||||
props.setFutureBookingType("rolling");
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={`text-base ${
|
||||
props.rollingCalendarDays ? "font-semibold text-[#333]" : "text-[#666]"
|
||||
}`}
|
||||
>
|
||||
{props.rollingCalendarDays ? "Calendar days" : "Business days"}
|
||||
<Text className="text-base text-black">
|
||||
{props.rollingCalendarDays ? "calendar days" : "business days"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text className="text-sm text-[#666]">days into the future</Text>
|
||||
</View>
|
||||
)}
|
||||
{props.futureBookingType === "range" && (
|
||||
<View className="gap-3">
|
||||
<View>
|
||||
<Text className="mb-1.5 text-sm text-[#666]">Start date</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.rangeStartDate}
|
||||
onChangeText={props.setRangeStartDate}
|
||||
placeholder="YYYY-MM-DD"
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="mb-1.5 text-sm text-[#666]">End date</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.rangeEndDate}
|
||||
onChangeText={props.setRangeEndDate}
|
||||
placeholder="YYYY-MM-DD"
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
<Text className="text-base text-[#666]">into the future</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Date Range option */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-start"
|
||||
onPress={() => props.setFutureBookingType("range")}
|
||||
>
|
||||
<View
|
||||
className={`mr-3 mt-0.5 h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
props.futureBookingType === "range" ? "border-[#007AFF]" : "border-[#C7C7CC]"
|
||||
}`}
|
||||
>
|
||||
{props.futureBookingType === "range" && (
|
||||
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-base text-[#333]">Within a date range</Text>
|
||||
{props.futureBookingType === "range" && (
|
||||
<View className="gap-2">
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2 text-base text-black"
|
||||
value={props.rangeStartDate}
|
||||
onChangeText={props.setRangeStartDate}
|
||||
placeholder="Start date (YYYY-MM-DD)"
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2 text-base text-black"
|
||||
value={props.rangeEndDate}
|
||||
onChangeText={props.setRangeEndDate}
|
||||
placeholder="End date (YYYY-MM-DD)"
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||