Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
895d039efc | ||
|
|
9f566bc8fc | ||
|
|
518e541177 | ||
|
|
c1be922ee1 | ||
|
|
967bba1e80 | ||
|
|
0fc26f782f | ||
|
|
fadd824d12 | ||
|
|
c1b7b6e83d | ||
|
|
e82a53a442 | ||
|
|
a6578fdcd2 | ||
|
|
4e68bcb021 | ||
|
|
1e673f1df4 | ||
|
|
b91f27dc80 | ||
|
|
becae27b45 | ||
|
|
584f5581fa | ||
|
|
e10c4c1cb5 | ||
|
|
c78552ce60 | ||
|
|
9631d0a64b | ||
|
|
1182460d5c | ||
|
|
aedc0f5a03 | ||
|
|
ff88ec0eae | ||
|
|
110b5ceaf6 | ||
|
|
7c53d2c9ce | ||
|
|
eccebff4c6 | ||
|
|
0a36dd7f61 | ||
|
|
5143083efb | ||
|
|
b874e0d55a | ||
|
|
3ee24586a7 | ||
|
|
b014ded5fd | ||
|
|
46b88a3c81 | ||
|
|
c97f1928bb | ||
|
|
dea6ec5003 | ||
|
|
8cace7f7a0 | ||
|
|
b80c0692dc | ||
|
|
44291be7b7 | ||
|
|
54440cf954 | ||
|
|
43315108a2 | ||
|
|
311b6e071d | ||
|
|
308c7fa12b | ||
|
|
51cf73218b | ||
|
|
dc44825ce5 | ||
|
|
908128ca5d | ||
|
|
b12f14c538 | ||
|
|
f03e6075a2 | ||
|
|
a8aac6dc3a | ||
|
|
1594567fd5 | ||
|
|
dde6e7d140 | ||
|
|
d8f36d0665 | ||
|
|
9362253bfb | ||
|
|
c91da96956 | ||
|
|
dbb33b9632 | ||
|
|
ead0691ea3 | ||
|
|
e4a6b97017 | ||
|
|
98b862c048 | ||
|
|
78c411621e | ||
|
|
a1e1bf8b76 | ||
|
|
d00cf329e8 | ||
|
|
843329ccce | ||
|
|
741144bff2 | ||
|
|
a11f7e6510 | ||
|
|
3a8bf7014c | ||
|
|
6f02fbe13b | ||
|
|
af3f44a7f5 | ||
|
|
0605572969 | ||
|
|
5ddfbcf225 | ||
|
|
2fc1078158 | ||
|
|
ad416ac9df | ||
|
|
2a7439b58d | ||
|
|
8aaa84142b | ||
|
|
059ca2503d | ||
|
|
35d6c41fff | ||
|
|
1c3ced5b70 | ||
|
|
88ce7868e8 | ||
|
|
ba3838ec0f | ||
|
|
f87d4de546 | ||
|
|
fd6c6e6419 | ||
|
|
63d1361a7a | ||
|
|
cbcd769f88 | ||
|
|
a2185d9ee0 | ||
|
|
7a66add217 | ||
|
|
68b93385eb | ||
|
|
e2aa6361d5 | ||
|
|
d15281160f | ||
|
|
8b6c6273ee | ||
|
|
957d19740e | ||
|
|
150e93dc6e | ||
|
|
94c7bfea4d | ||
|
|
dbef7644d4 | ||
|
|
a843a38e67 | ||
|
|
73ffd9c7b5 | ||
|
|
2c8ff89fac | ||
|
|
3bf2a8fa6c | ||
|
|
16a4c75e1e | ||
|
|
f9db1d6111 | ||
|
|
1d9aee7154 | ||
|
|
56e3a314a8 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
This PR introduces two variants for the calendar view atom, a generic calendar view and an event type specific view containing bookings data and busy times from connected calendars.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": major
|
||||
---
|
||||
|
||||
feat: use tailwind v4 (breaking change)
|
||||
@@ -1,187 +0,0 @@
|
||||
language: en-US
|
||||
tone_instructions: ''
|
||||
early_access: false
|
||||
enable_free_tier: true
|
||||
reviews:
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
high_level_summary: false
|
||||
high_level_summary_placeholder: '@coderabbitai summary'
|
||||
high_level_summary_in_walkthrough: false
|
||||
auto_title_placeholder: '@coderabbitai'
|
||||
auto_title_instructions: ''
|
||||
review_status: true
|
||||
commit_status: true
|
||||
fail_commit_status: false
|
||||
collapse_walkthrough: false
|
||||
changed_files_summary: false
|
||||
sequence_diagrams: false
|
||||
estimate_code_review_effort: false
|
||||
assess_linked_issues: true
|
||||
related_issues: false
|
||||
related_prs: true
|
||||
suggested_labels: false
|
||||
auto_apply_labels: false
|
||||
suggested_reviewers: false
|
||||
auto_assign_reviewers: false
|
||||
poem: false
|
||||
labeling_instructions: []
|
||||
path_filters:
|
||||
- '!**/node_modules/**'
|
||||
- '!**/dist/**'
|
||||
- '!**/build/**'
|
||||
- '!**/.next/**'
|
||||
path_instructions:
|
||||
- path: "**/*.{ts,tsx,js,jsx}"
|
||||
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
|
||||
abort_on_close: true
|
||||
disable_cache: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
auto_incremental_review: true
|
||||
ignore_title_keywords: []
|
||||
labels: []
|
||||
drafts: false
|
||||
base_branches: []
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: true
|
||||
unit_tests:
|
||||
enabled: true
|
||||
pre_merge_checks:
|
||||
docstrings:
|
||||
mode: warning
|
||||
threshold: 80
|
||||
title:
|
||||
mode: warning
|
||||
requirements: ''
|
||||
description:
|
||||
mode: warning
|
||||
issue_assessment:
|
||||
mode: warning
|
||||
tools:
|
||||
ast-grep:
|
||||
rule_dirs: []
|
||||
util_dirs: []
|
||||
essential_rules: true
|
||||
packages: []
|
||||
shellcheck:
|
||||
enabled: true
|
||||
ruff:
|
||||
enabled: true
|
||||
markdownlint:
|
||||
enabled: true
|
||||
github-checks:
|
||||
enabled: true
|
||||
timeout_ms: 180000
|
||||
languagetool:
|
||||
enabled: true
|
||||
enabled_rules: []
|
||||
disabled_rules: []
|
||||
enabled_categories: []
|
||||
disabled_categories: []
|
||||
enabled_only: false
|
||||
level: default
|
||||
biome:
|
||||
enabled: true
|
||||
hadolint:
|
||||
enabled: true
|
||||
swiftlint:
|
||||
enabled: true
|
||||
phpstan:
|
||||
enabled: true
|
||||
level: default
|
||||
phpmd:
|
||||
enabled: true
|
||||
phpcs:
|
||||
enabled: true
|
||||
golangci-lint:
|
||||
enabled: true
|
||||
yamllint:
|
||||
enabled: true
|
||||
gitleaks:
|
||||
enabled: true
|
||||
checkov:
|
||||
enabled: true
|
||||
detekt:
|
||||
enabled: true
|
||||
eslint:
|
||||
enabled: true
|
||||
flake8:
|
||||
enabled: true
|
||||
rubocop:
|
||||
enabled: true
|
||||
buf:
|
||||
enabled: true
|
||||
regal:
|
||||
enabled: true
|
||||
actionlint:
|
||||
enabled: true
|
||||
pmd:
|
||||
enabled: true
|
||||
cppcheck:
|
||||
enabled: true
|
||||
semgrep:
|
||||
enabled: true
|
||||
circleci:
|
||||
enabled: true
|
||||
clippy:
|
||||
enabled: true
|
||||
sqlfluff:
|
||||
enabled: true
|
||||
prismaLint:
|
||||
enabled: true
|
||||
pylint:
|
||||
enabled: true
|
||||
oxc:
|
||||
enabled: true
|
||||
shopifyThemeCheck:
|
||||
enabled: true
|
||||
luacheck:
|
||||
enabled: true
|
||||
brakeman:
|
||||
enabled: true
|
||||
dotenvLint:
|
||||
enabled: true
|
||||
htmlhint:
|
||||
enabled: true
|
||||
checkmake:
|
||||
enabled: true
|
||||
osvScanner:
|
||||
enabled: true
|
||||
chat:
|
||||
art: true
|
||||
auto_reply: true
|
||||
integrations:
|
||||
jira:
|
||||
usage: auto
|
||||
linear:
|
||||
usage: auto
|
||||
knowledge_base:
|
||||
opt_out: false
|
||||
web_search:
|
||||
enabled: true
|
||||
code_guidelines:
|
||||
enabled: true
|
||||
filePatterns: []
|
||||
learnings:
|
||||
scope: auto
|
||||
issues:
|
||||
scope: auto
|
||||
jira:
|
||||
usage: auto
|
||||
project_keys: []
|
||||
linear:
|
||||
usage: auto
|
||||
team_keys: []
|
||||
pull_requests:
|
||||
scope: auto
|
||||
mcp:
|
||||
usage: auto
|
||||
disabled_servers: []
|
||||
code_generation:
|
||||
docstrings:
|
||||
language: en-US
|
||||
path_instructions: []
|
||||
unit_tests:
|
||||
path_instructions: []
|
||||
@@ -1,2 +1,4 @@
|
||||
GET /v2/event-types/{eventTypeId} added to the 'data' response property 'oneOf' list for the response status '200'
|
||||
GET /v2/event-types/{eventTypeId} added '#/components/schemas/EventTypeOutput_2024_06_14, #/components/schemas/TeamEventTypeOutput_2024_06_14' to the 'data' response property 'oneOf' list for the response status '200'
|
||||
GET /v2/bookings/{bookingUid}/recordings added the new required 'header' request parameter 'Authorization'
|
||||
GET /v2/bookings/{bookingUid}/transcripts added the new required 'header' request parameter 'Authorization'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Companion Builds
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
EXPO_PUBLIC_CAL_API_KEY: ${{ secrets.CI_EXPO_PUBLIC_CAL_API_KEY || vars.CI_EXPO_PUBLIC_CAL_API_KEY || vars.EXPO_PUBLIC_CAL_API_KEY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: companion/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: npm ci
|
||||
- name: Build Expo web app
|
||||
working-directory: companion
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: 1
|
||||
CI: true
|
||||
run: npx expo export --platform web
|
||||
- name: Build browser extension
|
||||
working-directory: companion
|
||||
run: npm run ext:build
|
||||
@@ -146,6 +146,13 @@ jobs:
|
||||
uses: ./.github/workflows/docs-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-companion:
|
||||
name: Companion builds
|
||||
needs: [changes, check-label, deps]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/companion-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: [changes, check-label, deps]
|
||||
@@ -244,6 +251,7 @@ jobs:
|
||||
build-api-v2,
|
||||
build-atoms,
|
||||
build-docs,
|
||||
build-companion,
|
||||
e2e,
|
||||
e2e-api-v2,
|
||||
e2e-embed,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
|
||||
yarn app-store:build && git add packages/app-store/*.generated.*
|
||||
|
||||
@@ -1,30 +1,204 @@
|
||||
# Cal.com Development Guide for AI Agents
|
||||
|
||||
**📖 Complete documentation is in the [.agents/](.agents/) directory.**
|
||||
You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioritize type safety, security, and small, reviewable diffs.
|
||||
|
||||
## Quick Reference
|
||||
## Do
|
||||
|
||||
### Essential Commands
|
||||
- Use `select` instead of `include` in Prisma queries for performance and security
|
||||
- Use `import type { X }` for TypeScript type imports
|
||||
- Use early returns to reduce nesting: `if (!booking) return null;`
|
||||
- Use TRPCError with proper error codes for API errors
|
||||
- Use conventional commits: `feat:`, `fix:`, `refactor:`
|
||||
- Create PRs in draft mode by default
|
||||
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
|
||||
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
|
||||
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
|
||||
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
|
||||
- Put permission checks in `page.tsx`, never in `layout.tsx`
|
||||
|
||||
- `yarn dev` - Start development server
|
||||
- `yarn build` - Build all packages
|
||||
- `yarn lint:fix` - Lint and fix code
|
||||
- `yarn type-check` - TypeScript checking
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests
|
||||
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test
|
||||
## Don't
|
||||
|
||||
## Tool Preferences
|
||||
- Never use `as any` - use proper type-safe solutions instead
|
||||
- Never expose `credential.key` field in API responses or queries
|
||||
- Never commit secrets or API keys
|
||||
- Never modify `*.generated.ts` files directly - they're created by app-store-cli
|
||||
- Never put business logic in repositories - that belongs in Services
|
||||
- Never use barrel imports from index.ts files
|
||||
- Never skip running type checks before pushing
|
||||
- Never create large PRs (>500 lines or >10 files) - split them instead
|
||||
|
||||
### Search Tools Priority
|
||||
## Commands
|
||||
|
||||
Use tools in this order of preference:
|
||||
### File-scoped (preferred for speed)
|
||||
|
||||
1. **ast-grep** - For AST-based code searches (if available)
|
||||
2. **rg (ripgrep)** - For fast text searches
|
||||
3. **grep** - As fallback for text searches
|
||||
```bash
|
||||
# Type check - always run on changed files
|
||||
yarn type-check:ci --force
|
||||
|
||||
## 📚 Detailed Documentation
|
||||
# Lint single file
|
||||
yarn eslint --fix path/to/file.tsx
|
||||
|
||||
- **[.agents/README.md](.agents/README.md)** - Complete development guide
|
||||
- **[.agents/commands.md](.agents/commands.md)** - All build, test & dev commands
|
||||
- **[.agents/knowledge-base.md](.agents/knowledge-base.md)** - Knowledge base & best practices
|
||||
# Format single file
|
||||
yarn prettier --write path/to/file.tsx
|
||||
|
||||
# Unit test specific file
|
||||
yarn vitest run path/to/file.test.ts
|
||||
|
||||
# Integration test specific file
|
||||
yarn test path/to/file.integration-test.ts -- --integrationTestsOnly
|
||||
|
||||
# E2E test specific file
|
||||
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts
|
||||
```
|
||||
|
||||
### Project-wide (use sparingly)
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn dev # Start dev server
|
||||
yarn dx # Dev with database setup
|
||||
|
||||
# Build & check
|
||||
yarn build # Build all packages
|
||||
yarn lint:fix # Lint and fix all
|
||||
yarn type-check # Type check all
|
||||
|
||||
# Tests (use TZ=UTC for consistency)
|
||||
TZ=UTC yarn test # All unit tests
|
||||
yarn e2e # All E2E tests
|
||||
|
||||
# Database
|
||||
yarn prisma generate # Regenerate types after schema changes
|
||||
yarn workspace @calcom/prisma db-migrate # Run migrations
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
### Always do
|
||||
- Run type check on changed files before committing
|
||||
- Run relevant tests before pushing
|
||||
- Use `select` in Prisma queries
|
||||
- Follow conventional commits for PR titles
|
||||
|
||||
### Ask first
|
||||
- Adding new dependencies
|
||||
- Schema changes to `packages/prisma/schema.prisma`
|
||||
- Changes affecting multiple packages
|
||||
- Deleting files
|
||||
- Running full build or E2E suites
|
||||
|
||||
### Never do
|
||||
- Commit secrets, API keys, or `.env` files
|
||||
- Expose `credential.key` in any query
|
||||
- Use `as any` type casting
|
||||
- Force push or rebase shared branches
|
||||
- Modify generated files directly
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ # Main Next.js application
|
||||
packages/prisma/ # Database schema (schema.prisma) and migrations
|
||||
packages/trpc/ # tRPC API layer (routers in server/routers/)
|
||||
packages/ui/ # Shared UI components
|
||||
packages/features/ # Feature-specific code
|
||||
packages/app-store/ # Third-party integrations
|
||||
packages/lib/ # Shared utilities
|
||||
```
|
||||
|
||||
### Key files
|
||||
- Routes: `apps/web/app/` (App Router)
|
||||
- Database schema: `packages/prisma/schema.prisma`
|
||||
- tRPC routers: `packages/trpc/server/routers/`
|
||||
- Translations: `apps/web/public/static/locales/en/common.json`
|
||||
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 13+ (App Router in some areas)
|
||||
- **Language**: TypeScript (strict)
|
||||
- **Database**: PostgreSQL with Prisma ORM
|
||||
- **API**: tRPC for type-safe APIs
|
||||
- **Auth**: NextAuth.js
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Testing**: Vitest (unit), Playwright (E2E)
|
||||
- **i18n**: next-i18next
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Good error handling
|
||||
|
||||
```typescript
|
||||
// Good - Descriptive error with context
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unable to create booking: User ${userId} has no available time slots for ${date}`,
|
||||
});
|
||||
|
||||
// Bad - Generic error
|
||||
throw new Error("Booking failed");
|
||||
```
|
||||
|
||||
### Good Prisma query
|
||||
|
||||
```typescript
|
||||
// Good - Use select for performance and security
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bad - Include fetches all fields including sensitive ones
|
||||
const booking = await prisma.booking.findFirst({
|
||||
include: { user: true }
|
||||
});
|
||||
```
|
||||
|
||||
### Good imports
|
||||
|
||||
```typescript
|
||||
// Good - Type imports and direct paths
|
||||
import type { User } from "@prisma/client";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
// Bad - Regular import for types, barrel imports
|
||||
import { User } from "@prisma/client";
|
||||
import { Button } from "@calcom/ui";
|
||||
```
|
||||
|
||||
## PR Checklist
|
||||
|
||||
- [ ] Title follows conventional commits: `feat(scope): description`
|
||||
- [ ] Type check passes: `yarn type-check:ci --force`
|
||||
- [ ] Lint passes: `yarn lint:fix`
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] Diff is small and focused (<500 lines, <10 files)
|
||||
- [ ] No secrets or API keys committed
|
||||
- [ ] UI strings added to translation files
|
||||
- [ ] Created as draft PR
|
||||
|
||||
## When Stuck
|
||||
|
||||
- Ask a clarifying question before making large speculative changes
|
||||
- Propose a short plan for complex tasks
|
||||
- Open a draft PR with notes if unsure about approach
|
||||
- Fix type errors before test failures - they're often the root cause
|
||||
- Run `yarn prisma generate` if you see missing enum/type errors
|
||||
|
||||
## Extended Documentation
|
||||
|
||||
For detailed information, see the `agents/` directory:
|
||||
|
||||
- **[agents/README.md](agents/README.md)** - Architecture overview and patterns
|
||||
- **[agents/commands.md](agents/commands.md)** - Complete command reference
|
||||
- **[agents/knowledge-base.md](agents/knowledge-base.md)** - Domain knowledge and best practices
|
||||
- **[agents/coding-standards.md](agents/coding-standards.md)** - Coding standards with examples
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ ARG CALCOM_TELEMETRY_DISABLED
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET=secret
|
||||
ARG CALENDSO_ENCRYPTION_KEY=secret
|
||||
ARG MAX_OLD_SPACE_SIZE=4096
|
||||
ARG MAX_OLD_SPACE_SIZE=6144
|
||||
ARG NEXT_PUBLIC_API_V2_URL
|
||||
ARG CSP_POLICY
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(_request: NextRequest) {
|
||||
// Matcher guarantees the last segment is one of the blocked segments, so we can
|
||||
// immediately return a 403 without further path checks.
|
||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// The blocked segment is always the last part of the path, so we only need this matcher.
|
||||
matcher: [
|
||||
"/:path*/_get",
|
||||
"/:path*/_post",
|
||||
"/:path*/_patch",
|
||||
"/:path*/_delete",
|
||||
"/:path*/_auth-middleware",
|
||||
],
|
||||
};
|
||||
@@ -35,7 +35,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^15.4.5",
|
||||
"next": "^15.4.8",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
@@ -96,6 +98,8 @@ import { Module } from "@nestjs/common";
|
||||
BookingReferencesService_2024_08_13,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CalVideoService,
|
||||
CalVideoOutputService,
|
||||
BookingPbacGuard,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
GetOptionalUser,
|
||||
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||
@@ -66,6 +68,7 @@ import {
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
GetBookingRecordingsOutput,
|
||||
GetBookingTranscriptsOutput,
|
||||
GetBookingVideoSessionsOutput,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInputPipe,
|
||||
@@ -209,12 +212,16 @@ export class BookingsController_2024_08_13 {
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/recordings")
|
||||
// @Pbac(["booking.readRecordings"])
|
||||
@Permissions([BOOKING_READ])
|
||||
@UseGuards(BookingUidGuard)
|
||||
// @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get all the recordings for the booking",
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\`
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\`. Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>
|
||||
`,
|
||||
})
|
||||
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
|
||||
@@ -223,11 +230,17 @@ export class BookingsController_2024_08_13 {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: recordings,
|
||||
message:
|
||||
"This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/transcripts")
|
||||
// @Pbac(["booking.readRecordings"])
|
||||
@Permissions([BOOKING_READ])
|
||||
@UseGuards(BookingUidGuard)
|
||||
// @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get Cal Video real time transcript download links for the booking",
|
||||
description: `Fetches all the transcript download links for the booking \`:bookingUid\`
|
||||
@@ -245,6 +258,8 @@ export class BookingsController_2024_08_13 {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: transcripts ?? [],
|
||||
message:
|
||||
"This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -546,4 +561,25 @@ export class BookingsController_2024_08_13 {
|
||||
data: bookingReferences,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/conferencing-sessions")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Pbac(["booking.readRecordings"])
|
||||
@Permissions([BOOKING_READ])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get Video Meeting Sessions. Only supported for Cal Video",
|
||||
description: `Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
|
||||
|
||||
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>`,
|
||||
})
|
||||
async getVideoSessions(@Param("bookingUid") bookingUid: string): Promise<GetBookingVideoSessionsOutput> {
|
||||
const sessions = await this.calVideoService.getVideoSessions(bookingUid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: sessions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Booking access authorization", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
|
||||
const ownerEmail = `booking-access-auth-owner-${randomString()}@api.com`;
|
||||
const unauthorizedEmail = `booking-access-auth-unauthorized-${randomString()}@api.com`;
|
||||
let ownerUser: User;
|
||||
let unauthorizedUser: User;
|
||||
let ownerApiKey: string;
|
||||
let unauthorizedApiKey: string;
|
||||
|
||||
let eventTypeId: number;
|
||||
let testBooking: Booking;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `booking-access-auth-organization-${randomString()}`,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
ownerUser = await userRepositoryFixture.create({
|
||||
email: ownerEmail,
|
||||
locale: "en",
|
||||
name: `booking-access-auth-owner-${randomString()}`,
|
||||
});
|
||||
|
||||
unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedEmail,
|
||||
locale: "en",
|
||||
name: `booking-access-auth-unauthorized-${randomString()}`,
|
||||
});
|
||||
|
||||
const { keyString: ownerKeyString } = await apiKeysRepositoryFixture.createApiKey(ownerUser.id, null);
|
||||
ownerApiKey = `cal_test_${ownerKeyString}`;
|
||||
|
||||
const { keyString: unauthorizedKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
unauthorizedUser.id,
|
||||
null
|
||||
);
|
||||
unauthorizedApiKey = `cal_test_${unauthorizedKeyString}`;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `booking-access-auth-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(ownerUser.id, userSchedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `booking-access-auth-event-type-${randomString()}`,
|
||||
slug: `booking-access-auth-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
ownerUser.id
|
||||
);
|
||||
eventTypeId = eventType.id;
|
||||
|
||||
testBooking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-access-auth-booking-${randomString()}`,
|
||||
title: "Test Booking for Access Auth",
|
||||
description: "",
|
||||
startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: ownerUser.id,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Test Attendee",
|
||||
email: "attendee@example.com",
|
||||
},
|
||||
references: {
|
||||
create: [
|
||||
{
|
||||
type: "daily_video",
|
||||
uid: `daily-room-${randomString()}`,
|
||||
meetingId: `daily-room-${randomString()}`,
|
||||
meetingPassword: "test-password",
|
||||
meetingUrl: `https://daily.co/test-room-${randomString()}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("GET /v2/bookings/:bookingUid/conferencing-sessions - Authorization", () => {
|
||||
it("should allow booking organizer to access conferencing sessions", async () => {
|
||||
const calVideoService = app.get(CalVideoService);
|
||||
jest.spyOn(calVideoService, "getVideoSessions").mockResolvedValue([]);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
|
||||
.set("Authorization", `Bearer ${ownerApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
|
||||
it("should return 403 when unauthorized user tries to access conferencing sessions", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
|
||||
.set("Authorization", `Bearer ${unauthorizedApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
// describe("GET /v2/bookings/:bookingUid/recordings - Authorization", () => {
|
||||
// it("should allow booking organizer to access recordings", async () => {
|
||||
// const calVideoService = app.get(CalVideoService);
|
||||
// jest.spyOn(calVideoService, "getRecordings").mockResolvedValue([]);
|
||||
|
||||
// const response = await request(app.getHttpServer())
|
||||
// .get(`/v2/bookings/${testBooking.uid}/recordings`)
|
||||
// .set("Authorization", `Bearer ${ownerApiKey}`)
|
||||
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
// .expect(200);
|
||||
|
||||
// expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
// });
|
||||
|
||||
// it("should return 403 when unauthorized user tries to access recordings", async () => {
|
||||
// await request(app.getHttpServer())
|
||||
// .get(`/v2/bookings/${testBooking.uid}/recordings`)
|
||||
// .set("Authorization", `Bearer ${unauthorizedApiKey}`)
|
||||
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
// .expect(403);
|
||||
// });
|
||||
// });
|
||||
|
||||
// describe("GET /v2/bookings/:bookingUid/transcripts - Authorization", () => {
|
||||
// it("should allow booking organizer to access transcripts", async () => {
|
||||
// const calVideoService = app.get(CalVideoService);
|
||||
// jest.spyOn(calVideoService, "getTranscripts").mockResolvedValue([]);
|
||||
|
||||
// const response = await request(app.getHttpServer())
|
||||
// .get(`/v2/bookings/${testBooking.uid}/transcripts`)
|
||||
// .set("Authorization", `Bearer ${ownerApiKey}`)
|
||||
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
// .expect(200);
|
||||
|
||||
// expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
// });
|
||||
|
||||
// it("should return 403 when unauthorized user tries to access transcripts", async () => {
|
||||
// await request(app.getHttpServer())
|
||||
// .get(`/v2/bookings/${testBooking.uid}/transcripts`)
|
||||
// .set("Authorization", `Bearer ${unauthorizedApiKey}`)
|
||||
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
// .expect(403);
|
||||
// });
|
||||
// });
|
||||
|
||||
afterAll(async () => {
|
||||
await bookingsRepositoryFixture.deleteById(testBooking.id);
|
||||
await eventTypesRepositoryFixture.delete(eventTypeId);
|
||||
await userRepositoryFixture.deleteByEmail(ownerEmail);
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedEmail);
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { CreateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
@@ -3078,6 +3079,102 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
);
|
||||
}
|
||||
|
||||
describe("Meeting sessions", () => {
|
||||
it("should get cal video sessions for a booking", async () => {
|
||||
const mockSessions = [
|
||||
{
|
||||
id: "session-123",
|
||||
room: "daily-room-123",
|
||||
start_time: 1678901234,
|
||||
duration: 3600,
|
||||
ongoing: false,
|
||||
max_participants: 10,
|
||||
participants: [
|
||||
{
|
||||
user_id: "user-1",
|
||||
participant_id: "participant-1",
|
||||
user_name: "John Doe",
|
||||
join_time: 1678901234,
|
||||
duration: 3600,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `test-video-session-${randomString()}`,
|
||||
title: "Test Video Session Booking",
|
||||
description: "",
|
||||
startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
references: {
|
||||
create: [
|
||||
{
|
||||
type: "daily_video",
|
||||
uid: `daily-room-123-${randomString()}`,
|
||||
meetingId: "daily-room-123",
|
||||
meetingPassword: "test-password",
|
||||
meetingUrl: "https://daily.co/daily-room-123",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const calVideoService = app.get(CalVideoService);
|
||||
jest
|
||||
.spyOn(calVideoService, "getVideoSessions")
|
||||
.mockResolvedValue([
|
||||
{
|
||||
id: mockSessions[0].id,
|
||||
room: mockSessions[0].room,
|
||||
startTime: mockSessions[0].start_time,
|
||||
duration: mockSessions[0].duration,
|
||||
ongoing: mockSessions[0].ongoing,
|
||||
maxParticipants: mockSessions[0].max_participants,
|
||||
participants: mockSessions[0].participants.map((p) => ({
|
||||
userId: p.user_id,
|
||||
userName: p.user_name,
|
||||
joinTime: p.join_time,
|
||||
duration: p.duration,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${booking.uid}/conferencing-sessions`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response.body.data[0].id).toEqual(mockSessions[0].id);
|
||||
expect(response.body.data[0].room).toEqual(mockSessions[0].room);
|
||||
expect(response.body.data[0].startTime).toEqual(mockSessions[0].start_time);
|
||||
expect(response.body.data[0].duration).toEqual(mockSessions[0].duration);
|
||||
expect(response.body.data[0].ongoing).toEqual(mockSessions[0].ongoing);
|
||||
expect(response.body.data[0].maxParticipants).toEqual(mockSessions[0].max_participants);
|
||||
expect(response.body.data[0].participants.length).toBe(1);
|
||||
expect(response.body.data[0].participants[0].userId).toEqual(mockSessions[0].participants[0].user_id);
|
||||
expect(response.body.data[0].participants[0].userName).toEqual(
|
||||
mockSessions[0].participants[0].user_name
|
||||
);
|
||||
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { BookingAccessService } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class BookingPbacGuard implements CanActivate {
|
||||
private bookingAccessService: BookingAccessService;
|
||||
|
||||
constructor(private readonly prismaReadService: PrismaReadService) {
|
||||
this.bookingAccessService = new BookingAccessService(this.prismaReadService.prisma);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user?: ApiAuthGuardUser; pbacAuthorizedRequest?: boolean }>();
|
||||
const user = request.user;
|
||||
const bookingUid = request.params.bookingUid;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (!bookingUid) {
|
||||
throw new BadRequestException("BookingPbacGuard - bookingUid is required");
|
||||
}
|
||||
|
||||
const hasAccess = await this.bookingAccessService.doesUserIdHaveAccessToBooking({
|
||||
userId: user.id,
|
||||
bookingUid,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
throw new ForbiddenException(
|
||||
`BookingPbacGuard - user with id=${user.id} does not have access to booking with uid=${bookingUid}`
|
||||
);
|
||||
}
|
||||
|
||||
request.pbacAuthorizedRequest = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { CalMeetingSession } from "@calcom/platform-libraries/conferencing";
|
||||
|
||||
@Injectable()
|
||||
export class CalVideoOutputService {
|
||||
getOutputVideoSessions(sessions: CalMeetingSession[]) {
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
room: session.room,
|
||||
startTime: session.start_time,
|
||||
duration: session.duration,
|
||||
ongoing: session.ongoing,
|
||||
maxParticipants: session.max_participants,
|
||||
participants: session.participants.map((participant) => ({
|
||||
userId: participant.user_id,
|
||||
userName: participant.user_name,
|
||||
joinTime: participant.join_time,
|
||||
duration: participant.duration,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { CAL_VIDEO_TYPE } from "@calcom/platform-constants";
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getAllTranscriptsAccessLinkFromRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
getCalVideoMeetingSessionsByRoomName,
|
||||
} from "@calcom/platform-libraries/conferencing";
|
||||
|
||||
@Injectable()
|
||||
@@ -13,18 +15,23 @@ export class CalVideoService {
|
||||
private readonly logger = new Logger("CalVideoService");
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly outputService: OutputBookingsService_2024_08_13
|
||||
private readonly calVideoOutputService: CalVideoOutputService
|
||||
) {}
|
||||
|
||||
private getVideoSessionsRoomName(references?: Array<{ type: string; meetingId?: string | null }>) {
|
||||
return (
|
||||
references?.filter((reference) => reference.type === CAL_VIDEO_TYPE)?.pop()?.meetingId ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async getRecordings(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName =
|
||||
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
|
||||
undefined;
|
||||
const roomName = this.getVideoSessionsRoomName(booking.references);
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
@@ -68,10 +75,7 @@ export class CalVideoService {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName =
|
||||
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
|
||||
undefined;
|
||||
|
||||
const roomName = this.getVideoSessionsRoomName(booking.references);
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
@@ -80,4 +84,19 @@ export class CalVideoService {
|
||||
|
||||
return transcripts;
|
||||
}
|
||||
|
||||
async getVideoSessions(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName = this.getVideoSessionsRoomName(booking.references);
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
|
||||
const sessions = await getCalVideoMeetingSessionsByRoomName(roomName);
|
||||
return this.calVideoOutputService.getOutputVideoSessions(sessions.data);
|
||||
}
|
||||
}
|
||||
|
||||
+162
-2
@@ -34,6 +34,7 @@ import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import {
|
||||
BaseConfirmationPolicy_2024_06_14,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
supportedIntegrations,
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
@@ -640,9 +641,9 @@ describe("Event types Endpoints", () => {
|
||||
hiddenEventType = responseBody.data;
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username`, async () => {
|
||||
it(`/GET/event-types by username with sortCreatedAt=desc`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=desc`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
@@ -653,6 +654,12 @@ describe("Event types Endpoints", () => {
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Verify ordering: event types are returned newest to oldest when sortCreatedAt=desc
|
||||
// hiddenEventType was created after eventType, so it should have a higher ID and appear first
|
||||
expect(responseBody.data[0].id).toBeGreaterThan(responseBody.data[1].id);
|
||||
expect(responseBody.data[0].id).toEqual(hiddenEventType.id);
|
||||
expect(responseBody.data[1].id).toEqual(eventType.id);
|
||||
|
||||
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
|
||||
const fetchedHiddenEventType = responseBody.data?.find((et) => et.id === hiddenEventType.id);
|
||||
|
||||
@@ -711,6 +718,46 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType?.hidden).toEqual(false);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username with sortCreatedAt=asc`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=asc`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Verify ordering: event types are returned oldest to newest when sortCreatedAt=asc
|
||||
// eventType was created before hiddenEventType, so it should have a lower ID and appear first
|
||||
expect(responseBody.data[0].id).toBeLessThan(responseBody.data[1].id);
|
||||
expect(responseBody.data[0].id).toEqual(eventType.id);
|
||||
expect(responseBody.data[1].id).toEqual(hiddenEventType.id);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username without sortCreatedAt parameter`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Without sortCreatedAt, no specific order is guaranteed
|
||||
// Just verify both event types are present
|
||||
const ids = responseBody.data.map((et) => et.id);
|
||||
expect(ids).toContain(eventType.id);
|
||||
expect(ids).toContain(hiddenEventType.id);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username should not return hidden event type if no auth provided`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
@@ -2855,6 +2902,119 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept newly supported integration locations in input validation", async () => {
|
||||
// Test that the API accepts various newly supported integrations by creating event types
|
||||
// with these location types directly in the database and then retrieving them
|
||||
// Note: Database uses underscores in integration types, API returns hyphens
|
||||
const integrationTests = [
|
||||
{ internal: "integrations:jitsi", api: "jitsi" },
|
||||
{ internal: "integrations:whereby_video", api: "whereby-video" },
|
||||
{ internal: "integrations:huddle01", api: "huddle" },
|
||||
{ internal: "integrations:tandem", api: "tandem" },
|
||||
{ internal: "integrations:element-call_video", api: "element-call-video" },
|
||||
];
|
||||
|
||||
for (const { internal, api } of integrationTests) {
|
||||
const eventTypeInput = {
|
||||
title: `event type ${api}`,
|
||||
description: "event type description",
|
||||
length: 30,
|
||||
hidden: false,
|
||||
slug: `event-type-${api}`,
|
||||
locations: [
|
||||
{
|
||||
type: internal,
|
||||
link: `https://example.com/${api}/meeting`,
|
||||
},
|
||||
],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
bookingFields: [],
|
||||
};
|
||||
|
||||
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${legacyEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
// Verify the integration location is returned correctly
|
||||
expect(fetchedEventType.locations).toHaveLength(1);
|
||||
expect(fetchedEventType.locations[0]).toMatchObject({
|
||||
type: "integration",
|
||||
integration: api,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await eventTypesRepositoryFixture.delete(legacyEventType.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject with 400 if creating event type with unsupported integration location", async () => {
|
||||
const createPayload = {
|
||||
title: "Event with unsupported integration",
|
||||
slug: "event-with-unsupported-integration",
|
||||
lengthInMinutes: 30,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "unsupported-integration",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(createPayload);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Validation failed for integration location: integration must be one of the following values: ${supportedIntegrations.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if patching event type with integration that user has not connected", async () => {
|
||||
const createPayload = {
|
||||
title: "Event for patch test",
|
||||
slug: "event-patch-test-unconnected-integration",
|
||||
lengthInMinutes: 30,
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(createPayload)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
|
||||
const patchPayload = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "jitsi",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const patchResponse = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(patchPayload);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body.error.message).toBe("jitsi not connected.");
|
||||
|
||||
// Cleanup
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
|
||||
describe("EventType Hidden Property", () => {
|
||||
let createdEventTypeId: number;
|
||||
|
||||
|
||||
+2
@@ -155,6 +155,8 @@ export class EventTypesController_2024_06_14 {
|
||||
summary: "Get all event types",
|
||||
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
|
||||
|
||||
Use the optional \`sortCreatedAt\` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -67,21 +68,23 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypesPublic(userId: number) {
|
||||
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
hidden: false,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
+11
-9
@@ -22,7 +22,7 @@ import {
|
||||
getEventTypesPublic,
|
||||
EventTypesPublic,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import type { GetEventTypesQuery_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { GetEventTypesQuery_2024_06_14, SortOrderType } from "@calcom/platform-types";
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -160,15 +160,16 @@ export class EventTypesService_2024_06_14 {
|
||||
orgSlug?: string;
|
||||
orgId?: number;
|
||||
authUser?: AuthOptionalUser;
|
||||
sortCreatedAt?: SortOrderType;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
if (params.authUser?.id !== user.id) {
|
||||
return await this.getUserEventTypesPublic(user.id);
|
||||
return await this.getUserEventTypesPublic(user.id, params.sortCreatedAt);
|
||||
}
|
||||
return await this.getUserEventTypes(user.id);
|
||||
return await this.getUserEventTypes(user.id, params.sortCreatedAt);
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
@@ -211,16 +212,16 @@ export class EventTypesService_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
|
||||
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId, sortCreatedAt);
|
||||
|
||||
return eventTypes.map((eventType) => {
|
||||
return { ownerId: userId, ...eventType };
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypesPublic(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId);
|
||||
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId, sortCreatedAt);
|
||||
|
||||
return eventTypes.map((eventType) => {
|
||||
return { ownerId: userId, ...eventType };
|
||||
@@ -237,7 +238,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14, authUser?: AuthOptionalUser) {
|
||||
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
|
||||
const { username, eventSlug, usernames, orgSlug, orgId, sortCreatedAt } = queryParams;
|
||||
if (username && eventSlug) {
|
||||
const eventType = await this.getEventTypeByUsernameAndSlug({
|
||||
username,
|
||||
@@ -255,6 +256,7 @@ export class EventTypesService_2024_06_14 {
|
||||
orgSlug,
|
||||
orgId,
|
||||
authUser,
|
||||
sortCreatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,7 +266,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
if (authUser?.id) {
|
||||
return await this.getUserEventTypes(authUser.id);
|
||||
return await this.getUserEventTypes(authUser.id, sortCreatedAt);
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
+32
-5
@@ -39,6 +39,7 @@ import {
|
||||
InputBookingField_2024_06_14,
|
||||
OutputUnknownLocation_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
supportedIntegrations,
|
||||
} from "@calcom/platform-types";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -252,7 +253,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
requiresConfirmationWillBlockSlot:
|
||||
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
|
||||
eventTypeColor: this.transformInputEventTypeColor(color),
|
||||
...this.transformInputSeatOptions(seats),
|
||||
...(seats ? this.transformInputSeatOptions(seats) : { seatsPerTimeSlot: undefined }),
|
||||
eventName: customName,
|
||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||
...maxActiveBookingsPerBooker,
|
||||
@@ -542,14 +543,40 @@ export class InputEventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) {
|
||||
const conferencingApps = ["google-meet", "office365-video", "zoom"];
|
||||
const conferencingApps = supportedIntegrations as readonly string[];
|
||||
if (!conferencingApps.includes(appSlug)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
|
||||
}
|
||||
|
||||
if (appSlug === "office365-video") {
|
||||
appSlug = "msteams";
|
||||
}
|
||||
// Map API integration names to actual app slugs
|
||||
const slugMap: Record<string, string> = {
|
||||
"office365-video": "msteams",
|
||||
"facetime-video": "facetime",
|
||||
"whereby-video": "whereby",
|
||||
"whatsapp-video": "whatsapp",
|
||||
"webex-video": "webex",
|
||||
"telegram-video": "telegram",
|
||||
"sylaps-video": "sylapsvideo",
|
||||
"skype-video": "skype",
|
||||
"sirius-video": "sirius_video",
|
||||
"signal-video": "signal",
|
||||
"shimmer-video": "shimmervideo",
|
||||
"salesroom-video": "salesroom",
|
||||
"roam-video": "roam",
|
||||
"riverside-video": "riverside",
|
||||
"ping-video": "ping",
|
||||
"mirotalk-video": "mirotalk",
|
||||
"jelly-video": "jelly",
|
||||
"jelly-conferencing": "jelly",
|
||||
"huddle": "huddle01",
|
||||
"element-call-video": "element-call",
|
||||
"eightxeight-video": "eightxeight",
|
||||
"discord-video": "discord",
|
||||
"demodesk-video": "demodesk",
|
||||
"campfire-video": "campfire",
|
||||
};
|
||||
|
||||
appSlug = slugMap[appSlug] || appSlug;
|
||||
|
||||
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep
|
||||
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
|
||||
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
@@ -16,6 +17,7 @@ import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booki
|
||||
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
|
||||
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
||||
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
|
||||
@@ -35,6 +37,7 @@ import { Module, Scope } from "@nestjs/common";
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
BookingAuditProducerService,
|
||||
BookingEventHandlerService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
CheckBookingLimitsService,
|
||||
@@ -45,6 +48,7 @@ import { Module, Scope } from "@nestjs/common";
|
||||
BookingEmailAndSmsSyncTaskerService,
|
||||
BookingEmailAndSmsTriggerTaskerService,
|
||||
BookingEmailAndSmsTasker,
|
||||
TaskerService,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
|
||||
constructor(taskerService: TaskerService) {
|
||||
super({
|
||||
tasker: taskerService.getTasker(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@ import { BookingEventHandlerService as BaseBookingEventHandlerService } from "@c
|
||||
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
|
||||
import { BookingAuditProducerService } from "./booking-audit-producer.service";
|
||||
import { HashedLinkService } from "./hashed-link.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEventHandlerService extends BaseBookingEventHandlerService {
|
||||
constructor(hashedLinkService: HashedLinkService, bridgeLogger: Logger) {
|
||||
constructor(
|
||||
hashedLinkService: HashedLinkService,
|
||||
bridgeLogger: Logger,
|
||||
bookingAuditProducerService: BookingAuditProducerService
|
||||
) {
|
||||
super({
|
||||
log: bridgeLogger,
|
||||
hashedLinkService,
|
||||
bookingAuditProducerService,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getTasker, type Tasker } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TaskerService {
|
||||
private readonly tasker: Tasker;
|
||||
|
||||
constructor() {
|
||||
this.tasker = getTasker();
|
||||
}
|
||||
|
||||
getTasker(): Tasker {
|
||||
return this.tasker;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/reposito
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
|
||||
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
@@ -44,6 +45,7 @@ import { Module } from "@nestjs/common";
|
||||
ManagedOrganizationsBillingService,
|
||||
OAuthClientRepository,
|
||||
BookingsRepository_2024_08_13,
|
||||
IsUserInBillingOrg,
|
||||
],
|
||||
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
|
||||
controllers: [BillingController],
|
||||
|
||||
@@ -17,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import type { Team, PlatformBilling } from "@calcom/prisma/client";
|
||||
|
||||
@@ -32,8 +32,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||
let organization: Team;
|
||||
let organization2: Team;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withNextAuth(
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
@@ -49,6 +51,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
name: `billing-organization-${randomString()}`,
|
||||
isPlatform: true,
|
||||
});
|
||||
organization2 = await organizationsRepositoryFixture.create({
|
||||
name: `billing-organization-2-${randomString()}`,
|
||||
isPlatform: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -133,7 +139,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (GET) should get billing plan for org", () => {
|
||||
it("/billing/:orgId/check (GET) should check billing plan for org", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/billing/${organization.id}/check`)
|
||||
.expect(200)
|
||||
@@ -143,6 +149,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/:organizationId/check (GET) should not be able to check other org plan", () => {
|
||||
return request(app.getHttpServer()).get(`/v2/billing/${organization2.id}/check`).expect(403);
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
||||
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import {
|
||||
@@ -49,8 +51,9 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Get("/:teamId/check")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
|
||||
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
|
||||
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
|
||||
async checkTeamBilling(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
|
||||
@@ -66,8 +69,9 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Post("/:teamId/subscribe")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
|
||||
async subscribeTeamToStripe(
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
@@ -84,8 +88,9 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Post("/:teamId/upgrade")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
|
||||
async upgradeTeamBillingInStripe(
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
@@ -100,13 +105,12 @@ export class BillingController {
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:organizationId/unsubscribe")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@Delete("/:teamId/unsubscribe")
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
async cancelTeamSubscriptionInStripe(
|
||||
@Param("organizationId") organizationId: number
|
||||
): Promise<ApiResponse> {
|
||||
await this.billingService.cancelTeamSubscription(organizationId);
|
||||
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
|
||||
async cancelTeamSubscriptionInStripe(@Param("teamId") teamId: number): Promise<ApiResponse> {
|
||||
await this.billingService.cancelTeamSubscription(teamId);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import type { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class IsUserInBillingOrg implements CanActivate {
|
||||
constructor(private organizationsRepository: OrganizationsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const orgId: string = request.params.teamId;
|
||||
const userId = (request.user as ApiAuthGuardUser).id;
|
||||
|
||||
if (!userId) {
|
||||
throw new ForbiddenException("IsUserInBillingOrg - No user id found.");
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("IsUserInBillingOrg - No org id found in request params.");
|
||||
}
|
||||
|
||||
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException(
|
||||
`IsUserInBillingOrg - user with id=${userId} is not part of the organization with id=${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+17
-8
@@ -47,8 +47,8 @@ import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
GetOrganizationEventTypesQuery_2024_06_14,
|
||||
GetTeamEventTypesQuery_2024_06_14,
|
||||
SkipTakePagination,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -159,12 +159,16 @@ export class OrganizationsEventTypesController {
|
||||
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
@ApiOperation({ summary: "Get team event types" })
|
||||
@ApiOperation({
|
||||
summary: "Get team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { eventSlug, hostsLimit } = queryParams;
|
||||
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
|
||||
|
||||
if (eventSlug) {
|
||||
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(
|
||||
@@ -179,7 +183,7 @@ export class OrganizationsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -191,16 +195,21 @@ export class OrganizationsEventTypesController {
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/event-types")
|
||||
@ApiOperation({ summary: "Get all team event types" })
|
||||
@ApiOperation({
|
||||
summary: "Get all team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamsEventTypes(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
@Query() queryParams: GetOrganizationEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const { skip, take, sortCreatedAt } = queryParams;
|
||||
const eventTypes = await this.organizationsEventTypesService.getOrganizationsTeamsEventTypes(
|
||||
orgId,
|
||||
skip,
|
||||
take
|
||||
take,
|
||||
sortCreatedAt
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
+9
-1
@@ -1,16 +1,24 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsEventTypesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
async getOrganizationTeamsEventTypes(orgId: number, skip: number, take: number) {
|
||||
async getOrganizationTeamsEventTypes(
|
||||
orgId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
sortCreatedAt?: SortOrderType
|
||||
) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
team: {
|
||||
parentId: orgId,
|
||||
},
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
skip,
|
||||
take,
|
||||
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
|
||||
|
||||
+64
@@ -1230,6 +1230,70 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve seatsPerTimeSlot when doing partial update", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation with seats",
|
||||
slug: `organizations-event-types-seats-${randomString()}`,
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
},
|
||||
],
|
||||
seats: {
|
||||
seatsPerTimeSlot: 5,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: TeamEventTypeOutput_2024_06_14 = createResponse.body.data;
|
||||
const createdSeats = createdEventType.seats;
|
||||
expect(createdSeats).toBeDefined();
|
||||
expect(createdSeats && "seatsPerTimeSlot" in createdSeats).toBe(true);
|
||||
if (createdSeats && "seatsPerTimeSlot" in createdSeats) {
|
||||
expect(createdSeats.seatsPerTimeSlot).toEqual(5);
|
||||
expect(createdSeats.showAttendeeInfo).toEqual(true);
|
||||
expect(createdSeats.showAvailabilityCount).toEqual(true);
|
||||
}
|
||||
|
||||
// Now do a partial update that only changes a different field (not seats)
|
||||
const updateBody: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
bookingRequiresAuthentication: false,
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${createdEventType.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const updatedEventType: TeamEventTypeOutput_2024_06_14 = updateResponse.body.data;
|
||||
|
||||
// Verify that seatsPerTimeSlot is preserved and not reset to null
|
||||
const updatedSeats = updatedEventType.seats;
|
||||
expect(updatedSeats).toBeDefined();
|
||||
expect(updatedSeats && "seatsPerTimeSlot" in updatedSeats).toBe(true);
|
||||
if (updatedSeats && "seatsPerTimeSlot" in updatedSeats) {
|
||||
expect(updatedSeats.seatsPerTimeSlot).toEqual(5);
|
||||
expect(updatedSeats.showAttendeeInfo).toEqual(true);
|
||||
expect(updatedSeats.showAvailabilityCount).toEqual(true);
|
||||
}
|
||||
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
|
||||
});
|
||||
|
||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
||||
expect(expected.userId).toEqual(received?.userId);
|
||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
||||
|
||||
+12
-4
@@ -1,4 +1,6 @@
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
|
||||
import {
|
||||
TransformedCreateTeamEventTypeInput,
|
||||
@@ -83,16 +85,22 @@ export class OrganizationsEventTypesService {
|
||||
return this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventTypeSlug, hostsLimit);
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesService.getTeamEventTypes(teamId);
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
}
|
||||
|
||||
async getOrganizationsTeamsEventTypes(
|
||||
orgId: number,
|
||||
skip = 0,
|
||||
take = 250
|
||||
take = 250,
|
||||
sortCreatedAt?: SortOrderType
|
||||
): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(orgId, skip, take);
|
||||
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(
|
||||
orgId,
|
||||
skip,
|
||||
take,
|
||||
sortCreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
async updateOrganizationTeamEventType(
|
||||
|
||||
@@ -188,7 +188,8 @@ export class SlotsController_2024_09_04 {
|
||||
@DocsResponse({
|
||||
status: 200,
|
||||
description: `A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.
|
||||
For seated slots each object will have attendeesCount and bookingUid properties.`,
|
||||
For seated slots each object will have attendeesCount and bookingUid properties.
|
||||
If no slots are available, the data field will be an empty object {}.`,
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
|
||||
+7
-3
@@ -136,12 +136,16 @@ export class TeamsEventTypesController {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get a team event type" })
|
||||
@ApiOperation({
|
||||
summary: "Get team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { eventSlug, hostsLimit } = queryParams;
|
||||
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
|
||||
|
||||
if (eventSlug) {
|
||||
const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(
|
||||
@@ -156,7 +160,7 @@ export class TeamsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId);
|
||||
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries/event-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -101,8 +103,8 @@ export class TeamsEventTypesService {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId);
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
|
||||
@@ -2,6 +2,8 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsEventTypesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -79,11 +81,12 @@ export class TeamsEventTypesRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number) {
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: {
|
||||
users: true,
|
||||
schedule: true,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Added as a separate route for now to ease the testing of the audit logs feature
|
||||
// It partially matches the figma design - https://www.figma.com/design/wleA2SR6rn60EK7ORxAfMy/Cal.com-New-Features?node-id=5641-6732&p=f
|
||||
// TOOD: Move it to the booking page side bar later
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import BookingLogsView from "~/booking/logs/views/booking-logs-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("booking_history"),
|
||||
(t) => t("booking_history_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
`/booking/${(await params).uid}/logs`
|
||||
);
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
const resolvedParams = await params;
|
||||
const bookingUid = resolvedParams.uid;
|
||||
|
||||
if (!bookingUid || typeof bookingUid !== "string") {
|
||||
redirect("/bookings/upcoming");
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("booking_history")} subtitle={t("booking_history_description")}>
|
||||
<BookingLogsView bookingUid={bookingUid} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -6,11 +6,10 @@ import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing"
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { TeamsCTA } from "./CTA";
|
||||
|
||||
const getCachedTeams = unstable_cache(
|
||||
@@ -52,7 +51,7 @@ export const ServerTeamsListing = async ({
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
if (e instanceof ErrorWithCode) errorMsgFromInvite = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const ServerPage = async (props: { searchParams: Promise<{ fromTeamOnboarding?: string }> }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@@ -28,8 +28,10 @@ const ServerPage = async () => {
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
const userName = session.user.name || "";
|
||||
const searchParams = await props.searchParams;
|
||||
const fromTeamOnboarding = searchParams?.fromTeamOnboarding === "true";
|
||||
|
||||
return <PersonalSettingsView userEmail={userEmail} userName={userName} />;
|
||||
return <PersonalSettingsView userEmail={userEmail} userName={userName} fromTeamOnboarding={fromTeamOnboarding} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -34,7 +34,7 @@ const ServerPage = async () => {
|
||||
return <TeamInviteEmailView userEmail={userEmail} />;
|
||||
}
|
||||
|
||||
return <TeamInviteView userEmail={userEmail} />;
|
||||
return <TeamInviteEmailView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
+16
-8
@@ -91,6 +91,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
href: "/settings/organizations/general",
|
||||
trackingMetadata: { section: "organization", page: "general" },
|
||||
},
|
||||
{
|
||||
name: "guest_notifications",
|
||||
href: "/settings/organizations/guest-notifications",
|
||||
},
|
||||
...(orgBranding
|
||||
? [
|
||||
{
|
||||
@@ -371,22 +375,26 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
const [teamMenuState, setTeamMenuState] =
|
||||
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const pathname = usePathname();
|
||||
const searchParamsId = searchParams?.get("id");
|
||||
const pathTeamId = pathname?.match(/\/settings\/teams\/(\d+)/)?.[1];
|
||||
const activeTeamId = pathTeamId || searchParamsId;
|
||||
|
||||
useEffect(() => {
|
||||
if (teams) {
|
||||
const teamStates = teams?.map((team) => ({
|
||||
teamId: team.id,
|
||||
teamMenuOpen: String(team.id) === searchParamsId,
|
||||
teamMenuOpen: String(team.id) === activeTeamId,
|
||||
}));
|
||||
setTeamMenuState(teamStates);
|
||||
setTimeout(() => {
|
||||
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
|
||||
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
|
||||
)[1];
|
||||
tabMembers?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
if (activeTeamId) {
|
||||
setTimeout(() => {
|
||||
const teamTrigger = document.querySelector(`[aria-controls="team-content-${activeTeamId}"]`);
|
||||
teamTrigger?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [searchParamsId, teams]);
|
||||
}, [activeTeamId, teams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import GuestNotificationsView from "@calcom/features/ee/organizations/pages/settings/guest-notifications";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("guest_notifications"),
|
||||
(t) => t("guest_notifications_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/guest-notifications"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await validateUserHasOrg();
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
const { canRead, canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!canRead) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("guest_notifications")} description={t("guest_notifications_description")}>
|
||||
<GuestNotificationsView permissions={{ canRead, canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+8
-60
@@ -5,13 +5,11 @@ import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { getTeamMemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -33,7 +31,7 @@ const getCachedTeamRoles = unstable_cache(
|
||||
try {
|
||||
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
|
||||
return await roleManager.getTeamRoles(teamId);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// PBAC not enabled or error occurred, return empty array
|
||||
return [];
|
||||
}
|
||||
@@ -49,7 +47,7 @@ const getCachedTeamAttributes = unstable_cache(
|
||||
|
||||
try {
|
||||
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -79,65 +77,15 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
const organizationId = team.parentId || teamId;
|
||||
|
||||
// Load PBAC roles and attributes if available
|
||||
const [roles, attributes] = await Promise.all([
|
||||
const [roles, attributes, memberPermissions] = await Promise.all([
|
||||
getCachedTeamRoles(teamId, organizationId),
|
||||
getCachedTeamAttributes(organizationId),
|
||||
getTeamMemberPermissions({
|
||||
userId: session.user.id,
|
||||
team,
|
||||
}),
|
||||
]);
|
||||
|
||||
const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
|
||||
|
||||
// If the team is not private we allow members to list other members
|
||||
if (!team.isPrivate) {
|
||||
fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
|
||||
}
|
||||
|
||||
// Get specific PBAC permissions for team member actions
|
||||
const permissions = await getSpecificPermissions({
|
||||
userId: session.user.id,
|
||||
teamId: teamId,
|
||||
resource: Resource.Team,
|
||||
userRole: team.membership.role,
|
||||
actions: [
|
||||
CustomAction.Invite,
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.ListMembersPrivate,
|
||||
CustomAction.Impersonate,
|
||||
],
|
||||
fallbackRoles: {
|
||||
[CustomAction.Invite]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: fallbackRolesCanListMembers,
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ListMembersPrivate]: {
|
||||
roles: fallbackRolesCanListMembers,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: team.isPrivate
|
||||
? permissions[CustomAction.ListMembersPrivate]
|
||||
: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
canImpersonate: permissions[CustomAction.Impersonate],
|
||||
};
|
||||
|
||||
const facetedTeamValues = {
|
||||
roles,
|
||||
teams: [team],
|
||||
|
||||
@@ -43,6 +43,7 @@ async function getHandler(req: NextRequest) {
|
||||
|
||||
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
|
||||
if (isOnboardingV3Enabled && organizationOnboardingId) {
|
||||
params.append("fromTeamOnboarding", "true");
|
||||
const redirectUrl = new URL(
|
||||
`/onboarding/personal/settings?${params.toString()}`,
|
||||
WEBAPP_URL
|
||||
|
||||
@@ -64,7 +64,8 @@ async function postHandler(request: NextRequest) {
|
||||
} catch (error) {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +85,8 @@ async function handleBookingAction(
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +142,10 @@ async function handleBookingAction(
|
||||
} catch (e) {
|
||||
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)}`);
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`,
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.booking.update({
|
||||
@@ -148,7 +153,7 @@ async function handleBookingAction(
|
||||
data: { oneTimePassword: null },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`);
|
||||
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`, { status: 303 });
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router
|
||||
*
|
||||
* Currently only serves the /router endpoint (routing forms redirect page).
|
||||
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
|
||||
*
|
||||
* For App Router, use PageWrapperAppDir.tsx instead.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { DefaultSeo } from "next-seo";
|
||||
|
||||
@@ -36,12 +36,13 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
userName,
|
||||
}) => {
|
||||
const parsedMetaData = EventTypeMetaDataSchema.safeParse(metadata);
|
||||
const durations =
|
||||
parsedMetaData.success &&
|
||||
parsedMetaData.data?.multipleDuration &&
|
||||
Boolean(parsedMetaData.data?.multipleDuration.length)
|
||||
? [length, ...parsedMetaData.data?.multipleDuration?.filter((duration) => duration !== length)].sort()
|
||||
: [length];
|
||||
const multipleDuration = parsedMetaData.success && parsedMetaData.data?.multipleDuration
|
||||
? parsedMetaData.data.multipleDuration
|
||||
: [];
|
||||
|
||||
const durations = multipleDuration.length > 0
|
||||
? [length, ...multipleDuration.filter((duration) => duration !== length)].sort()
|
||||
: [length];
|
||||
return (
|
||||
<div
|
||||
data-testid={`select-event-type-${id}`}
|
||||
@@ -50,7 +51,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
<input
|
||||
id={`${id}`}
|
||||
checked={selected}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-cal checked:border-transparent checked:bg-gray-800 border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
|
||||
@@ -64,7 +65,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
{Boolean(description) && (
|
||||
<div
|
||||
className="text-subtle line-clamp-4 wrap-break-word text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToSafeHTML(description),
|
||||
}}
|
||||
|
||||
@@ -452,7 +452,8 @@ describe("TestFormDialog", () => {
|
||||
fireEvent.click(screen.getByText("submit"));
|
||||
|
||||
// Verify the URL shows the substituted value, not the variable
|
||||
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("/team/sales-team/meeting");
|
||||
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("/team/Sales%20Team/meeting");
|
||||
expect(screen.getByTestId("test-routing-result")).not.toHaveTextContent("/team/sales-team/meeting");
|
||||
expect(screen.getByTestId("test-routing-result")).not.toHaveTextContent("{name}");
|
||||
});
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
data-today={String(booking.isToday)}
|
||||
className="hover:bg-cal-muted group w-full">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-48">
|
||||
<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">
|
||||
{eventTypeColor && (
|
||||
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
|
||||
@@ -445,6 +445,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRescheduled && (
|
||||
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
|
||||
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
|
||||
{t("rescheduled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip
|
||||
@@ -497,7 +504,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 stack-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:stack-y-0 sm:pl-0">
|
||||
<div className="stack-y-2 sm:stack-y-0 flex w-full flex-col flex-wrap items-end justify-end space-x-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
<BookingActionsDropdown booking={booking} context="list" />
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
|
||||
@@ -598,7 +598,7 @@ export function BookingActionsDropdown({
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
href={action.disabled ? undefined : action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -621,7 +621,7 @@ export function BookingActionsDropdown({
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
href={action.disabled ? undefined : action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -642,7 +642,7 @@ export function BookingActionsDropdown({
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
href={action.disabled ? undefined : action.href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router (_app.tsx)
|
||||
*
|
||||
* Currently only serves the /router endpoint (routing forms redirect page).
|
||||
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
|
||||
*
|
||||
* For App Router, use app-providers-app-dir.tsx instead.
|
||||
*/
|
||||
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -12,7 +21,6 @@ import type { ParsedUrlQuery } from "querystring";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
@@ -53,12 +61,6 @@ export type AppProps = Omit<
|
||||
err?: Error;
|
||||
};
|
||||
|
||||
const PostHogPageView = dynamic(
|
||||
() => import("@calcom/features/ee/event-tracking/lib/posthog/web/PostHogPageView"),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
type AppPropsWithChildren = AppProps & {
|
||||
children: ReactNode;
|
||||
@@ -134,8 +136,8 @@ const enum ThemeSupport {
|
||||
|
||||
type CalcomThemeProps = PropsWithChildren<
|
||||
Pick<AppProps, "router"> &
|
||||
Pick<AppProps["pageProps"], "themeBasis"> &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
|
||||
Pick<AppProps["pageProps"], "themeBasis"> &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
|
||||
>;
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
|
||||
@@ -209,8 +211,8 @@ function getThemeProviderProps({
|
||||
? ThemeSupport.Booking
|
||||
: // if isThemeSupported is explicitly false, we don't use theme there
|
||||
props.isThemeSupported === false
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
const themeBasis = props.themeBasis;
|
||||
@@ -234,13 +236,13 @@ function getThemeProviderProps({
|
||||
|
||||
const storageKey = isEmbedMode
|
||||
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
|
||||
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
|
||||
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
|
||||
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
: themeSupport === ThemeSupport.App
|
||||
? "app-theme"
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
? "app-theme"
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
storageKey,
|
||||
@@ -307,12 +309,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicHelpscoutProvider>
|
||||
<DynamicPostHogProvider>
|
||||
<PostHogPageView />
|
||||
{RemainingProviders}
|
||||
</DynamicPostHogProvider>
|
||||
</DynamicHelpscoutProvider>
|
||||
<DynamicHelpscoutProvider>{RemainingProviders}</DynamicHelpscoutProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
|
||||
// TODO: This approach of checking booking page isn't correct.
|
||||
// app.cal.com/rick is a booking page but useIsBookingPage won't return true. This is because all unregistered router in Next.js could technically be a booking page throw catch all routes.
|
||||
// The only way to confirm it is by actually checking if we actually rendered a booking route.
|
||||
export default function useIsBookingPage(): boolean {
|
||||
const pathname = usePathname();
|
||||
const isBookingPage = [
|
||||
@@ -13,6 +15,7 @@ export default function useIsBookingPage(): boolean {
|
||||
"/d", // Private Link of booking page
|
||||
"/apps/routing-forms/routing-link", // Routing Form page
|
||||
"/forms/", // Rewrites to /apps/routing-forms/routing-link
|
||||
"/router", // Headless router page - Loads as a page when redirect type is customPageMessage
|
||||
].some((route) => pathname?.startsWith(route));
|
||||
const isBookingsListPage = ["/upcoming", "/unconfirmed", "/recurring", "/cancelled", "/past"].some(
|
||||
(route) => pathname?.endsWith(route)
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* TODO: Move it to features/booking-audit
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
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";
|
||||
|
||||
interface BookingLogsViewProps {
|
||||
bookingUid: string;
|
||||
}
|
||||
|
||||
type AuditLog = {
|
||||
id: string;
|
||||
action: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: Record<string, unknown> | null;
|
||||
actor: {
|
||||
type: string;
|
||||
displayName: 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 }>;
|
||||
}
|
||||
|
||||
interface BookingLogsTimelineProps {
|
||||
logs: AuditLog[];
|
||||
}
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case "CREATED":
|
||||
return "calendar";
|
||||
case "CANCELLED":
|
||||
case "REJECTED":
|
||||
return "ban";
|
||||
case "ACCEPTED":
|
||||
return "check";
|
||||
case "RESCHEDULED":
|
||||
case "RESCHEDULE_REQUESTED":
|
||||
return "pencil";
|
||||
case "REASSIGNMENT":
|
||||
case "ATTENDEE_ADDED":
|
||||
case "ATTENDEE_REMOVED":
|
||||
return "user-check";
|
||||
case "LOCATION_CHANGED":
|
||||
return "map-pin";
|
||||
case "HOST_NO_SHOW_UPDATED":
|
||||
case "ATTENDEE_NO_SHOW_UPDATED":
|
||||
return "ban";
|
||||
default:
|
||||
return "sparkles";
|
||||
}
|
||||
};
|
||||
|
||||
function BookingLogsFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
actorFilter,
|
||||
onActorFilterChange,
|
||||
typeOptions,
|
||||
actorOptions,
|
||||
}: BookingLogsFiltersProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-start">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<FilterSearchField
|
||||
size="sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
containerClassName=""
|
||||
/>
|
||||
</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);
|
||||
}}
|
||||
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
const { t } = useLocale();
|
||||
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
||||
const [showJsonMap, setShowJsonMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpand = (logId: string) => {
|
||||
setExpandedLogIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(logId)) {
|
||||
newSet.delete(logId);
|
||||
} else {
|
||||
newSet.add(logId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleJson = (logId: string) => {
|
||||
setShowJsonMap((prev) => ({
|
||||
...prev,
|
||||
[logId]: !prev[logId],
|
||||
}));
|
||||
};
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted">{t("no_audit_logs_found")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{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;
|
||||
|
||||
return (
|
||||
<div key={log.id} className="flex gap-1">
|
||||
<div className="flex flex-col items-center self-stretch">
|
||||
<div className="pt-2 shrink-0">
|
||||
<div className="bg-subtle rounded-[3.556px] p-1 flex items-center justify-center w-4 h-4">
|
||||
<Icon name={getActionIcon(log.action)} className="h-3 w-3 text-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-px bg-subtle flex-1 min-h-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 rounded-lg py-2">
|
||||
<div className="px-3 mb-2">
|
||||
<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}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
|
||||
<span>{log.actor.displayName}</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>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 { t } = useLocale();
|
||||
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
|
||||
bookingUid,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 font-medium">{t("error_loading_booking_logs")}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">{error.message}</p>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const auditLogs = data?.auditLogs || [];
|
||||
|
||||
// Apply filters
|
||||
const filteredLogs = auditLogs.filter((log) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
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;
|
||||
});
|
||||
|
||||
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 actorOptions = uniqueActorTypes.map((actorType) => ({
|
||||
label: actorType,
|
||||
value: actorType,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BookingLogsFilters
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
actorFilter={actorFilter}
|
||||
onActorFilterChange={setActorFilter}
|
||||
typeOptions={typeOptions}
|
||||
actorOptions={actorOptions}
|
||||
/>
|
||||
|
||||
<BookingLogsTimeline logs={filteredLogs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function EmailInviteForm({
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{showTeamSelect ? (
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="mr-7 grid grid-cols-2">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.email">
|
||||
{t("email")}
|
||||
</Label>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children
|
||||
{/* Logo and container - centered */}
|
||||
<div className="flex w-full flex-1 flex-col items-center justify-center gap-6">
|
||||
<Logo className="mt-4 h-5 w-auto shrink-0" />
|
||||
<div className="border-subtle bg-default grid max-h-[690px] min-h-0 w-full max-w-[532px] flex-1 grid-cols-1 gap-6 overflow-hidden rounded-2xl border px-4 py-2 sm:px-12 sm:py-10 xl:h-[690px] xl:max-w-[1130px] xl:grid-cols-[40%_1fr] xl:pl-10 xl:pr-0">
|
||||
<div className="border-subtle bg-default grid max-h-[690px] min-h-0 w-full max-w-[532px] flex-1 grid-cols-1 gap-12 overflow-hidden rounded-2xl border px-4 py-2 sm:px-12 sm:py-10 xl:h-[690px] xl:max-w-[1130px] xl:grid-cols-[40%_1fr] xl:pl-10 xl:pr-0">
|
||||
{/* Column 1 - Always visible, 40% on xl+ */}
|
||||
<div className="flex h-full min-h-0 flex-col">{column1}</div>
|
||||
{/* Column 2 - Hidden on mobile, visible on xl+, 60% on xl+ */}
|
||||
|
||||
@@ -4,6 +4,8 @@ import classNames from "classnames";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -18,6 +20,21 @@ type OnboardingBrowserViewProps = {
|
||||
teamSlug?: string;
|
||||
};
|
||||
|
||||
const getDisplayUrl = (
|
||||
orgSlug: string | null | undefined,
|
||||
username: string | null | undefined,
|
||||
teamSlug: string | undefined
|
||||
): string => {
|
||||
if (orgSlug) {
|
||||
return teamSlug !== undefined
|
||||
? `${orgSlug}.${subdomainSuffix()}/team/${teamSlug || ""}`
|
||||
: `${orgSlug}.${subdomainSuffix()}/${username || ""}`;
|
||||
}
|
||||
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
return teamSlug !== undefined ? `${webappUrl}/team/${teamSlug || ""}` : `${webappUrl}/${username || ""}`;
|
||||
};
|
||||
|
||||
export const OnboardingBrowserView = ({
|
||||
avatar,
|
||||
name,
|
||||
@@ -27,9 +44,9 @@ export const OnboardingBrowserView = ({
|
||||
}: OnboardingBrowserViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const pathname = usePathname();
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
const displayUrl =
|
||||
teamSlug !== undefined ? `${webappUrl}/team/${teamSlug || ""}` : `${webappUrl}/${username || ""}`;
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
const displayUrl = getDisplayUrl(orgBranding?.slug, username, teamSlug);
|
||||
|
||||
// Animation variants for entry and exit
|
||||
const containerVariants = {
|
||||
|
||||
@@ -133,22 +133,44 @@ export const OnboardingInviteBrowserView = ({
|
||||
duration: 0.5,
|
||||
ease: "backOut",
|
||||
}}>
|
||||
<div className="bg-default border-subtle flex flex-col gap-4 rounded-2xl border p-8">
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar || undefined}
|
||||
alt={displayName}
|
||||
className="border-default h-12 w-12 border-2"
|
||||
/>
|
||||
<div className="flex w-full flex-col items-start gap-1">
|
||||
<h2 className="text-emphasis font-cal w-full text-left text-xl font-semibold leading-tight">
|
||||
{displayName}
|
||||
</h2>
|
||||
<p className="text-subtle text-left text-sm font-normal leading-tight">
|
||||
{displayBio || "We're emailing you all the details"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-default border-subtle flex flex-col rounded-2xl border">
|
||||
<div className="relative p-1">
|
||||
{/* Banner Image */}
|
||||
{organizationBrand.banner && (
|
||||
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={organizationBrand.banner}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organization Avatar - Overlaying the banner */}
|
||||
{organizationBrand.banner && avatar && (
|
||||
<div className="absolute -bottom-6 left-4">
|
||||
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Organization Info */}
|
||||
<div className={`flex flex-col items-start gap-1 px-4 pb-4 pt-8`}>
|
||||
{!organizationBrand.banner && avatar && (
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar}
|
||||
alt={displayName}
|
||||
className="border-default mb-4 h-12 w-12 border-2"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-emphasis font-cal w-full text-left text-xl font-semibold leading-tight">
|
||||
{displayName}
|
||||
</h2>
|
||||
<p className="text-subtle text-left text-sm font-normal leading-tight">
|
||||
{displayBio || "We're emailing you all the details"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,19 +3,30 @@
|
||||
import classNames from "classnames";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
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 { Button } from "@calcom/ui/components/button";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
// Helper function to darken a hex color
|
||||
const darkenColor = (hex: string, amount: number): string => {
|
||||
const num = parseInt(hex.replace("#", ""), 16);
|
||||
const r = Math.max(0, Math.min(255, ((num >> 16) & 0xff) * (1 - amount)));
|
||||
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xff) * (1 - amount)));
|
||||
const b = Math.max(0, Math.min(255, (num & 0xff) * (1 - amount)));
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
|
||||
};
|
||||
|
||||
type OnboardingOrganizationBrowserViewProps = {
|
||||
avatar?: string | null;
|
||||
name?: string;
|
||||
bio?: string;
|
||||
slug?: string;
|
||||
bannerUrl?: string | null;
|
||||
brandColor?: string;
|
||||
};
|
||||
|
||||
export const OnboardingOrganizationBrowserView = ({
|
||||
@@ -24,11 +35,22 @@ export const OnboardingOrganizationBrowserView = ({
|
||||
bio,
|
||||
slug,
|
||||
bannerUrl,
|
||||
brandColor,
|
||||
}: OnboardingOrganizationBrowserViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const pathname = usePathname();
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
const displayUrl = `${webappUrl}/${slug || ""}`;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
|
||||
|
||||
// Update CSS variables when brandColor changes
|
||||
useEffect(() => {
|
||||
if (brandColor && containerRef.current) {
|
||||
containerRef.current.style.setProperty("--cal-brand", brandColor);
|
||||
// Set brand-emphasis as a slightly darker version for hover states
|
||||
const darkerColor = darkenColor(brandColor, 0.1);
|
||||
containerRef.current.style.setProperty("--cal-brand-emphasis", darkerColor);
|
||||
}
|
||||
}, [brandColor]);
|
||||
|
||||
// Animation variants for entry and exit
|
||||
const containerVariants = {
|
||||
@@ -79,7 +101,9 @@ export const OnboardingOrganizationBrowserView = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
|
||||
{/* Browser header */}
|
||||
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
|
||||
{/* Navigation buttons */}
|
||||
@@ -164,7 +188,7 @@ export const OnboardingOrganizationBrowserView = ({
|
||||
</div>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
|
||||
</div>
|
||||
<Button color="secondary" size="sm" EndIcon="arrow-right" tabIndex={-1}>
|
||||
<Button color="primary" size="sm" EndIcon="arrow-right" tabIndex={-1}>
|
||||
{t("book_now")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -108,11 +108,6 @@ export const OnboardingTeamsBrowserView = ({
|
||||
{/* Teams Info */}
|
||||
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
|
||||
<p className="text-subtle text-sm leading-normal">
|
||||
{hasValidTeams
|
||||
? t("onboarding_teams_browser_view_subtitle")
|
||||
: t("onboarding_teams_browser_view_placeholder_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,10 +25,15 @@ type OnboardingViewProps = {
|
||||
export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { selectedPlan, setSelectedPlan } = useOnboardingStore();
|
||||
const { selectedPlan, setSelectedPlan, resetOnboardingPreservingPlan } = useOnboardingStore();
|
||||
const previousPlanRef = useRef<PlanType | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Reset onboarding data when visiting this page, but preserve the selected plan
|
||||
useEffect(() => {
|
||||
resetOnboardingPreservingPlan();
|
||||
}, []);
|
||||
|
||||
// Plan order mapping for determining direction
|
||||
const planOrder: Record<PlanType, number> = {
|
||||
personal: 0,
|
||||
|
||||
@@ -18,11 +18,13 @@ export function useCreateTeam() {
|
||||
const createTeamMutation = trpc.viewer.teams.create.useMutation();
|
||||
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
|
||||
|
||||
const createTeam = async (store: OnboardingState) => {
|
||||
const createTeam = async (store?: OnboardingState) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const { teamDetails, teamBrand } = store;
|
||||
// Get the latest state from the store to ensure we have the most up-to-date values
|
||||
const currentStore = store || useOnboardingStore.getState();
|
||||
const { teamDetails, teamBrand } = currentStore;
|
||||
|
||||
// Validate team details - if empty, redirect back to team details step
|
||||
if (!teamDetails.name || !teamDetails.name.trim() || !teamDetails.slug || !teamDetails.slug.trim()) {
|
||||
@@ -103,9 +105,9 @@ export function useCreateTeam() {
|
||||
|
||||
// Redirect to personal settings after successful invite
|
||||
const gettingStartedPath = flags["onboarding-v3"]
|
||||
? "/onboarding/personal/settings"
|
||||
? "/onboarding/personal/settings?fromTeamOnboarding=true"
|
||||
: "/getting-started";
|
||||
router.push(gettingStartedPath);
|
||||
router.replace(gettingStartedPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to invite members:", error);
|
||||
// Extract error message from TRPC error
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
|
||||
@@ -10,7 +9,6 @@ import { showToast } from "@calcom/ui/components/toast";
|
||||
import type { OnboardingState } from "../store/onboarding-store";
|
||||
|
||||
export const useSubmitOnboarding = () => {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const flags = useFlagMap();
|
||||
@@ -94,7 +92,9 @@ export const useSubmitOnboarding = () => {
|
||||
const skipToPersonal = (resetOnboarding: () => void) => {
|
||||
resetOnboarding();
|
||||
const gettingStartedPath = flags["onboarding-v3"] ? "/onboarding/personal/settings" : "/getting-started";
|
||||
router.push(gettingStartedPath);
|
||||
// Use window.location.href for a full page reload to ensure JWT callback runs
|
||||
// without trigger="update", which will call autoMergeIdentities() and fetch org data
|
||||
window.location.href = gettingStartedPath;
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -81,6 +81,11 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip brand customization and go to teams
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
|
||||
{/* Left column - Main content */}
|
||||
@@ -95,9 +100,14 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
onClick={() => router.push("/onboarding/organization/details")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
{/* Form */}
|
||||
@@ -189,6 +199,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
bio={organizationDetails.bio}
|
||||
slug={organizationDetails.link}
|
||||
bannerUrl={bannerPreview}
|
||||
brandColor={brandColor}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
|
||||
@@ -37,12 +37,9 @@ export const InstallableAppCard = ({
|
||||
</span>
|
||||
)}
|
||||
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
|
||||
<p
|
||||
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
|
||||
title={app.name}>
|
||||
<p className="text-default line-clamp-1 break-words text-left text-sm font-medium" title={app.name}>
|
||||
{app.name}
|
||||
</p>
|
||||
<p className="text-subtle hidden text-left text-xs leading-tight xl:line-clamp-2">{app.description}</p>
|
||||
<InstallAppButtonWithoutPlanCheck
|
||||
type={app.type}
|
||||
options={installOptions}
|
||||
|
||||
@@ -26,9 +26,14 @@ import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
type PersonalSettingsViewProps = {
|
||||
userEmail: string;
|
||||
userName?: string;
|
||||
fromTeamOnboarding?: boolean;
|
||||
};
|
||||
|
||||
export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => {
|
||||
export const PersonalSettingsView = ({
|
||||
userEmail,
|
||||
userName,
|
||||
fromTeamOnboarding = false,
|
||||
}: PersonalSettingsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
@@ -117,12 +122,14 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
subtitle={t("personal_details_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/getting-started")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
{!fromTeamOnboarding && (
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/getting-started")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="personal-settings-form"
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface OnboardingState {
|
||||
|
||||
// Reset
|
||||
resetOnboarding: () => void;
|
||||
resetOnboardingPreservingPlan: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
@@ -167,6 +168,11 @@ export const useOnboardingStore = create<OnboardingState>()(
|
||||
})),
|
||||
|
||||
resetOnboarding: () => set(initialState),
|
||||
resetOnboardingPreservingPlan: () =>
|
||||
set((state) => ({
|
||||
...initialState,
|
||||
selectedPlan: state.selectedPlan,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "cal-onboarding-storage", // Storage key
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
@@ -65,7 +65,9 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
setTeamLogo(newLogo);
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
const handleContinue = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (!isSlugValid) {
|
||||
return;
|
||||
}
|
||||
@@ -81,7 +83,8 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
});
|
||||
|
||||
// Create the team (will handle payment redirect if needed)
|
||||
await createTeam(store);
|
||||
// Don't pass store - let createTeam read the latest state from the store
|
||||
await createTeam();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -89,82 +92,87 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("create_your_team")}
|
||||
subtitle={t("team_onboarding_details_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/getting-started")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
{/* Team Profile Picture */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={teamLogo || undefined}
|
||||
alt={teamName || "Team"}
|
||||
className="border-2 border-white"
|
||||
<form onSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("create_your_team")}
|
||||
subtitle={t("team_onboarding_details_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/getting-started")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
{/* Team Profile Picture */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={teamLogo || undefined}
|
||||
alt={teamName || "Team"}
|
||||
className="border-2 border-white"
|
||||
/>
|
||||
</div>
|
||||
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="team-logo-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
imageSrc={teamLogo}
|
||||
/>
|
||||
</div>
|
||||
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="team-logo-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
imageSrc={teamLogo}
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_logo_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Team Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_name")}</Label>
|
||||
<TextField
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Team Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_name")}</Label>
|
||||
<TextField
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
{/* Team Slug */}
|
||||
<ValidatedTeamSlug
|
||||
value={teamSlug}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Slug */}
|
||||
<ValidatedTeamSlug
|
||||
value={teamSlug}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
|
||||
{/* Team Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_bio")}</Label>
|
||||
<TextArea
|
||||
value={teamBio}
|
||||
onChange={(e) => setTeamBio(e.target.value)}
|
||||
placeholder={t("team_bio_placeholder")}
|
||||
className="border-default max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
{/* Team Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_bio")}</Label>
|
||||
<TextArea
|
||||
value={teamBio}
|
||||
onChange={(e) => setTeamBio(e.target.value)}
|
||||
placeholder={t("team_bio_placeholder")}
|
||||
className="border-default max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</OnboardingCard>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
|
||||
@@ -54,7 +54,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
const formSchema = z.object({
|
||||
invites: z.array(
|
||||
z.object({
|
||||
email: z.string().email(t("invalid_email_address")),
|
||||
email: z.union([z.literal(""), z.string().email(t("invalid_email_address"))]),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
@@ -113,16 +113,16 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
}
|
||||
} else {
|
||||
// No invites, skip to personal settings
|
||||
const gettingStartedPath = "/onboarding/personal/settings";
|
||||
router.push(gettingStartedPath);
|
||||
const gettingStartedPath = "/onboarding/personal/settings?fromTeamOnboarding=true";
|
||||
router.replace(gettingStartedPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
setTeamInvites([]);
|
||||
// Skip inviting members and go to personal settings
|
||||
const gettingStartedPath = "/onboarding/personal/settings";
|
||||
router.push(gettingStartedPath);
|
||||
const gettingStartedPath = "/onboarding/personal/settings?fromTeamOnboarding=true";
|
||||
router.replace(gettingStartedPath);
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
@@ -137,33 +137,33 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("invite")}
|
||||
subtitle={t("team_invite_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidInvites || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={form.handleSubmit(handleContinue)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<Form form={form} handleSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("invite")}
|
||||
subtitle={t("team_invite_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidInvites || isSubmitting}
|
||||
loading={isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-1">
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-1">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<EmailInviteForm
|
||||
fields={fields}
|
||||
@@ -184,9 +184,9 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
showInfoBadge
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
|
||||
@@ -7,12 +7,12 @@ import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import posthog from "posthog-js";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { Toaster } from "sonner";
|
||||
import { z } from "zod";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
@@ -41,7 +41,7 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { PasswordField, CheckboxField, TextField, Form } from "@calcom/ui/components/form";
|
||||
import { PasswordField, CheckboxField, TextField, Form, SelectField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
@@ -337,7 +337,6 @@ export default function Signup({
|
||||
}}
|
||||
/>
|
||||
<noscript
|
||||
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `<iframe src="https://www.googletagmanager.com/ns.html?id=${process.env.NEXT_PUBLIC_GTM_ID}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`,
|
||||
}}
|
||||
@@ -393,6 +392,56 @@ export default function Signup({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{IS_CALCOM && (
|
||||
<div className="mt-4">
|
||||
<SelectField
|
||||
label={t("data_region")}
|
||||
value={{
|
||||
label: t(
|
||||
// Use WEBAPP_URL for SSR-safe region detection
|
||||
WEBAPP_URL.includes("cal.eu") ||
|
||||
(typeof window !== "undefined" &&
|
||||
window.location.hostname === "localhost" &&
|
||||
new URL(window.location.href).searchParams.get("region") === "eu")
|
||||
? "european_union"
|
||||
: "united_states"
|
||||
),
|
||||
value:
|
||||
// Use WEBAPP_URL for SSR-safe region detection
|
||||
WEBAPP_URL.includes("cal.eu") ||
|
||||
(typeof window !== "undefined" &&
|
||||
window.location.hostname === "localhost" &&
|
||||
new URL(window.location.href).searchParams.get("region") === "eu")
|
||||
? "eu"
|
||||
: "us",
|
||||
}}
|
||||
options={[
|
||||
{ label: t("united_states"), value: "us" },
|
||||
{ label: t("european_union"), value: "eu" },
|
||||
]}
|
||||
onChange={(option) => {
|
||||
if (option && "value" in option) {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
|
||||
// Handle localhost - add region as URL parameter
|
||||
if (currentUrl.hostname === "localhost") {
|
||||
currentUrl.searchParams.set("region", option.value);
|
||||
window.location.href = currentUrl.toString();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle production domains - modify hostname only to preserve query params
|
||||
if (option.value === "eu") {
|
||||
currentUrl.hostname = currentUrl.hostname.replace("cal.com", "cal.eu");
|
||||
} else {
|
||||
currentUrl.hostname = currentUrl.hostname.replace("cal.eu", "cal.com");
|
||||
}
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
@@ -426,14 +475,14 @@ export default function Signup({
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? truncateDomain(
|
||||
`${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
|
||||
URL_PROTOCOL_REGEX,
|
||||
""
|
||||
)}/`
|
||||
)
|
||||
`${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
|
||||
URL_PROTOCOL_REGEX,
|
||||
""
|
||||
)}/`
|
||||
)
|
||||
: truncateDomain(
|
||||
`${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
|
||||
)
|
||||
`${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@@ -565,7 +614,10 @@ export default function Signup({
|
||||
<>
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
<img
|
||||
className={classNames("text-subtle mr-2 h-4 w-4", premiumUsername && "opacity-50")}
|
||||
className={classNames(
|
||||
"text-subtle mr-2 h-4 w-4",
|
||||
premiumUsername && "opacity-50"
|
||||
)}
|
||||
src="/google-icon-colored.svg"
|
||||
alt="Continue with Google Icon"
|
||||
/>
|
||||
@@ -748,7 +800,7 @@ export default function Signup({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/3 dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
|
||||
<div className="border-default bg-black/3 hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
|
||||
<img className="block dark:hidden" src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
<img
|
||||
|
||||
@@ -115,7 +115,7 @@ export function UserPage(props: PageProps) {
|
||||
eventType: type,
|
||||
});
|
||||
}}
|
||||
className="bg-default border-subtle dark:bg-cal-muted dark:hover:bg-emphasis hover:bg-cal-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0"
|
||||
className="bg-default border-subtle dark:bg-cal-muted dark:hover:bg-subtle hover:bg-cal-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0"
|
||||
data-testid="event-type-link">
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
|
||||
@@ -445,7 +445,8 @@ export function LogInOverlay(props: LogInOverlayProps) {
|
||||
<DialogContent
|
||||
title={t("join_video_call")}
|
||||
description={t("choose_how_you_d_like_to_appear_on_the_call")}
|
||||
className="p-6 sm:max-w-lg">
|
||||
className="p-6 sm:max-w-lg"
|
||||
onInteractOutside={(e) => e.preventDefault()}>
|
||||
<div className="mt-2 pb-4">
|
||||
<div className="stack-y-4">
|
||||
<div>
|
||||
@@ -516,7 +517,7 @@ export function LogInOverlay(props: LogInOverlayProps) {
|
||||
{/* Divider */}
|
||||
<hr className="my-6 h-0.5 border-t-0 bg-neutral-100 dark:bg-white/10" />
|
||||
|
||||
<div className="mt-5 stack-y-4">
|
||||
<div className="stack-y-4 mt-5">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-black dark:text-white">
|
||||
{t("sign_in_to_cal_com")}
|
||||
@@ -568,7 +569,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||||
"no-scrollbar fixed left-0 top-0 z-30 flex h-full w-64 transform justify-between overflow-x-hidden overflow-y-scroll transition-all duration-300 ease-in-out",
|
||||
open ? "translate-x-0" : "-translate-x-[232px]"
|
||||
)}>
|
||||
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden! border-r border-gray-300/20 bg-default p-4 text-white shadow-sm backdrop-blur-lg">
|
||||
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 overflow-x-hidden! bg-default w-full overflow-scroll border-r border-gray-300/20 p-4 text-white shadow-sm backdrop-blur-lg">
|
||||
<h3>{t("what")}:</h3>
|
||||
<p>{booking.title}</p>
|
||||
<h3>{t("invitee_timezone")}:</h3>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.9.7",
|
||||
"version": "5.9.11",
|
||||
"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.4",
|
||||
"next": "15.5.7",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-i18next": "^15.4.2",
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("Booking Confirmation and Rejection via API", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test("should successfully confirm a booking via GET /api/verify-booking-token", async ({ page, users }) => {
|
||||
const organizer = await users.create({
|
||||
name: "Test Organizer",
|
||||
overrideDefaultEventTypes: true,
|
||||
eventTypes: [
|
||||
{
|
||||
title: "30 Min Meeting",
|
||||
slug: "30-min",
|
||||
length: 30,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eventType = organizer.eventTypes[0];
|
||||
if (!eventType) {
|
||||
throw new Error("Event type not found");
|
||||
}
|
||||
|
||||
const oneTimePassword = uuidv4();
|
||||
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-booking-${Date.now()}`,
|
||||
title: "Test Booking",
|
||||
startTime,
|
||||
endTime,
|
||||
status: BookingStatus.PENDING,
|
||||
oneTimePassword,
|
||||
user: {
|
||||
connect: {
|
||||
id: organizer.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventType.id,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "attendee@example.com",
|
||||
name: "Test Attendee",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const url = `/api/verify-booking-token?action=accept&token=${oneTimePassword}&bookingUid=${booking.uid}&userId=${organizer.id}`;
|
||||
|
||||
const response = await page.request.get(url, {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(303);
|
||||
const location = response.headers()["location"];
|
||||
expect(location).toContain(`/booking/${booking.uid}`);
|
||||
|
||||
const updatedBooking = await prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
});
|
||||
|
||||
expect(updatedBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
expect(updatedBooking?.oneTimePassword).toBeNull();
|
||||
});
|
||||
|
||||
test("should successfully reject a booking via POST /api/verify-booking-token", async ({ page, users }) => {
|
||||
const organizer = await users.create({
|
||||
name: "Test Organizer",
|
||||
overrideDefaultEventTypes: true,
|
||||
eventTypes: [
|
||||
{
|
||||
title: "30 Min Meeting",
|
||||
slug: "30-min",
|
||||
length: 30,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eventType = organizer.eventTypes[0];
|
||||
if (!eventType) {
|
||||
throw new Error("Event type not found");
|
||||
}
|
||||
|
||||
const oneTimePassword = uuidv4();
|
||||
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-booking-${Date.now()}`,
|
||||
title: "Test Booking",
|
||||
startTime,
|
||||
endTime,
|
||||
status: BookingStatus.PENDING,
|
||||
oneTimePassword,
|
||||
user: {
|
||||
connect: {
|
||||
id: organizer.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventType.id,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "attendee@example.com",
|
||||
name: "Test Attendee",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const url = `/api/verify-booking-token?action=reject&token=${oneTimePassword}&bookingUid=${booking.uid}&userId=${organizer.id}`;
|
||||
|
||||
const response = await page.request.post(url, {
|
||||
data: { reason: "Not available at this time" },
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(303);
|
||||
const location = response.headers()["location"];
|
||||
expect(location).toContain(`/booking/${booking.uid}`);
|
||||
|
||||
const updatedBooking = await prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
});
|
||||
|
||||
expect(updatedBooking?.status).toBe(BookingStatus.REJECTED);
|
||||
expect(updatedBooking?.rejectionReason).toBe("Not available at this time");
|
||||
expect(updatedBooking?.oneTimePassword).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { addFilter } from "./filter-helpers";
|
||||
@@ -23,18 +23,8 @@ test.describe("Out of office", () => {
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
|
||||
await page.getByTestId("reason_select").click();
|
||||
|
||||
@@ -80,18 +70,8 @@ test.describe("Out of office", () => {
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
|
||||
await page.getByTestId("reason_select").click();
|
||||
|
||||
@@ -167,12 +147,7 @@ test.describe("Out of office", () => {
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
await goToOOOPage(page);
|
||||
|
||||
// expect table-redirect-toUserId to be visible
|
||||
await expect(page.locator(`data-testid=table-redirect-${userTo.username}`)).toBeVisible();
|
||||
@@ -231,20 +206,8 @@ test.describe("Out of office", () => {
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
|
||||
await page.locator('[data-testid="date-range"]').click();
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
|
||||
await selectToAndFromDates(page, "13", "22", true);
|
||||
|
||||
@@ -280,20 +243,8 @@ test.describe("Out of office", () => {
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
|
||||
await page.locator('[data-testid="date-range"]').click();
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
|
||||
await selectToAndFromDates(page, "13", "22");
|
||||
|
||||
@@ -302,11 +253,7 @@ test.describe("Out of office", () => {
|
||||
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
|
||||
|
||||
// add another entry
|
||||
await entriesListRespPromise;
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
|
||||
await page.locator('[data-testid="date-range"]').click();
|
||||
await openOOODialog(page);
|
||||
|
||||
await selectToAndFromDates(page, "11", "24");
|
||||
|
||||
@@ -321,20 +268,8 @@ test.describe("Out of office", () => {
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
|
||||
await page.locator('[data-testid="date-range"]').click();
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
|
||||
await selectToAndFromDates(page, "13", "22");
|
||||
|
||||
@@ -343,11 +278,7 @@ test.describe("Out of office", () => {
|
||||
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
|
||||
|
||||
// add another entry
|
||||
await entriesListRespPromise;
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
|
||||
await page.locator('[data-testid="date-range"]').click();
|
||||
await openOOODialog(page);
|
||||
|
||||
await selectToAndFromDates(page, "13", "22");
|
||||
|
||||
@@ -359,32 +290,16 @@ test.describe("Out of office", () => {
|
||||
const user = await users.create({ name: "userOne" });
|
||||
await user.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const addOOOButton = page.getByTestId("add_entry_ooo");
|
||||
const dateButton = page.locator('[data-testid="date-range"]');
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
|
||||
//Creates 2 OOO entries:
|
||||
//First OOO is created on Next month 1st - 3rd
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "1", "3");
|
||||
await expect(page.locator(`data-testid=table-redirect-n-a`).nth(0)).toBeVisible();
|
||||
|
||||
//Second OOO is created on Next month 4th - 6th
|
||||
await entriesListRespPromise;
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await dateButton.click();
|
||||
await openOOODialog(page);
|
||||
await selectDateAndCreateOOO(page, "4", "6");
|
||||
await expect(page.locator(`data-testid=table-redirect-n-a`).nth(1)).toBeVisible();
|
||||
});
|
||||
@@ -403,23 +318,10 @@ test.describe("Out of office", () => {
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const addOOOButton = page.getByTestId("add_entry_ooo");
|
||||
const dateButton = page.locator('[data-testid="date-range"]');
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
|
||||
//As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "1", "3", member1User?.id);
|
||||
await expect(
|
||||
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
|
||||
@@ -427,12 +329,8 @@ test.describe("Out of office", () => {
|
||||
|
||||
//As member1, OOO is created on Next month 4th - 5th, forwarding to 'owner'
|
||||
await member1User?.apiLogin();
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await dateButton.click();
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
await selectDateAndCreateOOO(page, "4", "5", owner.id);
|
||||
await expect(page.locator(`data-testid=table-redirect-${owner.username ?? "n-a"}`).nth(0)).toBeVisible();
|
||||
});
|
||||
@@ -452,22 +350,10 @@ test.describe("Out of office", () => {
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
await goToOOOPage(page);
|
||||
|
||||
const addOOOButton = page.getByTestId("add_entry_ooo");
|
||||
const dateButton = page.locator('[data-testid="date-range"]');
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
await test.step("As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'", async () => {
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await dateButton.click();
|
||||
await openOOODialog(page);
|
||||
await selectDateAndCreateOOO(page, "1", "3", member1User?.id);
|
||||
await expect(
|
||||
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
|
||||
@@ -476,12 +362,8 @@ test.describe("Out of office", () => {
|
||||
|
||||
await test.step("As member1, expect error while OOO is created on Next month 4th - 5th, forwarding to 'owner'", async () => {
|
||||
await member1User?.apiLogin();
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await dateButton.click();
|
||||
await goToOOOPage(page);
|
||||
await openOOODialog(page);
|
||||
await selectDateAndCreateOOO(page, "2", "5", owner.id, 400);
|
||||
await expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
|
||||
});
|
||||
@@ -505,30 +387,12 @@ test.describe("Out of office", () => {
|
||||
const member3User = users.get().find((user) => user.name === "member-3");
|
||||
await teamAdmin.apiLogin();
|
||||
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office?type=team");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
|
||||
const addOOOButton = page.getByTestId("add_entry_ooo");
|
||||
const dateButton = page.locator('[data-testid="date-range"]');
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
const legacyListMembersRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("legacyListMembers") && response.status() === 200
|
||||
);
|
||||
await addOOOButton.click();
|
||||
await reasonListRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
await goToOOOPage(page, "team");
|
||||
await openOOODialog(page);
|
||||
|
||||
await test.step("Admin can create OOO for team member and add redirect", async () => {
|
||||
//OOO is created for 'member-1' on Next month 1st - 3rd, forwarding to 'member-2'
|
||||
await page.getByTestId(`ooofor_username_select_${member1User?.id}`).click();
|
||||
await dateButton.click();
|
||||
|
||||
await selectDateAndCreateOOO(page, "1", "3", member2User?.id, 200, true);
|
||||
await expect(
|
||||
@@ -538,13 +402,9 @@ test.describe("Out of office", () => {
|
||||
|
||||
await test.step("Reverse redirect not allowed for team member", async () => {
|
||||
//Try to create OOO for 'member-2' on Next month 1st - 3rd, forwarding to 'member-1'
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await reasonListRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
await openOOODialog(page);
|
||||
|
||||
await page.getByTestId(`ooofor_username_select_${member2User?.id}`).click();
|
||||
await dateButton.click();
|
||||
await selectDateAndCreateOOO(page, "1", "3", member1User?.id, 400, true);
|
||||
expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
|
||||
await page.locator(`text=${t("cancel")}`).click();
|
||||
@@ -553,9 +413,6 @@ test.describe("Out of office", () => {
|
||||
await test.step("Edit OOO and change redirect member", async () => {
|
||||
//Change redirect member to 'member-3' for OOO created in step 1
|
||||
await page.getByTestId(`ooo-edit-${member2User?.username}`).click();
|
||||
await reasonListRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
await legacyListMembersRespPromise;
|
||||
|
||||
await page.getByTestId(`team_username_select_${member3User?.id}`).click();
|
||||
await saveAndWaitForResponse(page);
|
||||
@@ -620,12 +477,7 @@ test.describe("Out of office", () => {
|
||||
|
||||
await test.step("member3 logins and navigates to team OOO", async () => {
|
||||
await member3User?.apiLogin();
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office?type=team");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
await goToOOOPage(page, "team");
|
||||
|
||||
//Non-Admin canNot create OOO for team members
|
||||
await expect(page.locator('[data-testid="add_entry_ooo"]')).toBeDisabled();
|
||||
@@ -653,12 +505,7 @@ test.describe("Out of office", () => {
|
||||
test("Default date range filter set to `Last 7 Days`", async ({ page, users }) => {
|
||||
const user = await users.create({ name: `userOne=${Date.now()}` });
|
||||
await user.apiLogin();
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await responsePromise;
|
||||
await goToOOOPage(page);
|
||||
await addFilter(page, "dateRange");
|
||||
await expect(
|
||||
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
|
||||
@@ -668,12 +515,7 @@ test.describe("Out of office", () => {
|
||||
test("Can choose date range presets", async ({ page, users }) => {
|
||||
const user = await users.create({ name: `userOne=${Date.now()}` });
|
||||
await user.apiLogin();
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await responsePromise;
|
||||
await goToOOOPage(page);
|
||||
await addFilter(page, "dateRange");
|
||||
|
||||
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
|
||||
@@ -697,8 +539,6 @@ test.describe("Out of office", () => {
|
||||
teammates: teamMatesObj,
|
||||
}
|
||||
);
|
||||
const member1User = users.get().find((user) => user.name === member1Name);
|
||||
const member2User = users.get().find((user) => user.name === member2Name);
|
||||
|
||||
//create OOO for member3, start:currentDate+2Days, end:currentDate+4days (future ooo)
|
||||
await prisma.outOfOfficeEntry.create({
|
||||
@@ -717,12 +557,7 @@ test.describe("Out of office", () => {
|
||||
});
|
||||
|
||||
await member3User?.apiLogin();
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await entriesListRespPromise;
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await goToOOOPage(page);
|
||||
|
||||
//By Default future OOO will be displayed
|
||||
//1 OOO record should be visible for member3, end=currentDate+4days
|
||||
@@ -747,7 +582,6 @@ test.describe("Out of office", () => {
|
||||
teammates: teamMatesObj,
|
||||
}
|
||||
);
|
||||
const member1User = users.get().find((user) => user.name === member1Name);
|
||||
const member2User = users.get().find((user) => user.name === member2Name);
|
||||
|
||||
//create OOO for member3, start:currentDate-2Days, end:currentDate-4days (for Last 7 Days)
|
||||
@@ -768,12 +602,7 @@ test.describe("Out of office", () => {
|
||||
});
|
||||
|
||||
await member3User?.apiLogin();
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await entriesListRespPromise;
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await goToOOOPage(page);
|
||||
|
||||
//Default filter 'Last 7 Days' when DateRange Filter is selected
|
||||
await test.step("Default filter - 'Last 7 Days'", async () => {
|
||||
@@ -809,7 +638,6 @@ test.describe("Out of office", () => {
|
||||
}
|
||||
);
|
||||
const member1User = users.get().find((user) => user.name === member1Name);
|
||||
const member2User = users.get().find((user) => user.name === member2Name);
|
||||
|
||||
//create OOO for member3, start:currentDate-12Days, end:currentDate-10days (for Last 30 Days)
|
||||
await prisma.outOfOfficeEntry.create({
|
||||
@@ -829,12 +657,7 @@ test.describe("Out of office", () => {
|
||||
});
|
||||
|
||||
await member3User?.apiLogin();
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await entriesListRespPromise;
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await goToOOOPage(page);
|
||||
|
||||
//Select 'Last 30 Days'
|
||||
await test.step("select 'Last 30 Days'", async () => {
|
||||
@@ -872,6 +695,9 @@ async function saveAndWaitForResponse(page: Page, expectedStatusCode = 200) {
|
||||
}
|
||||
|
||||
async function selectToAndFromDates(page: Page, fromDate: string, toDate: string, isRangeInPast = false) {
|
||||
await page.getByTestId("date-range").click();
|
||||
await page.locator(".rdp").waitFor({ state: "visible" });
|
||||
|
||||
const month = isRangeInPast ? "previous" : "next";
|
||||
|
||||
await page.locator(`button[name="${month}-month"]`).click();
|
||||
@@ -891,14 +717,25 @@ async function selectDateAndCreateOOO(
|
||||
editMode = false
|
||||
) {
|
||||
const t = await localize("en");
|
||||
await page.locator(`button[name="${month}-month"]`).click();
|
||||
|
||||
await page.getByTestId("date-range").click();
|
||||
await page.locator(".rdp").waitFor({ state: "visible" });
|
||||
|
||||
// Look for month navigation button
|
||||
const monthButtonSelector = `button[name="${month}-month"]`;
|
||||
|
||||
await page.locator(monthButtonSelector).click();
|
||||
|
||||
await page.locator(`button[name="day"]:text-is("${fromDate}")`).nth(0).click();
|
||||
|
||||
await page.locator(`button[name="day"]:text-is("${toDate}")`).nth(0).click();
|
||||
editMode
|
||||
? await page.locator(`text=${t("edit_an_out_of_office")}`).click()
|
||||
: forTeamMember
|
||||
? await page.locator(`text=${t("create_ooo_dialog_team_title")}`).click()
|
||||
: await page.locator(`text=${t("create_an_out_of_office")}`).click();
|
||||
if (editMode) {
|
||||
await page.locator(`text=${t("edit_an_out_of_office")}`).click();
|
||||
} else if (forTeamMember) {
|
||||
await page.locator(`text=${t("create_ooo_dialog_team_title")}`).click();
|
||||
} else {
|
||||
await page.locator(`text=${t("create_an_out_of_office")}`).click();
|
||||
}
|
||||
await page.getByTestId("reason_select").click();
|
||||
await page.getByTestId("select-option-4").click();
|
||||
await page.getByTestId("notes_input").click();
|
||||
@@ -909,3 +746,23 @@ async function selectDateAndCreateOOO(
|
||||
}
|
||||
await saveAndWaitForResponse(page, expectedStatusCode);
|
||||
}
|
||||
|
||||
async function goToOOOPage(page: Page, type: "individual" | "team" = "individual") {
|
||||
const entriesListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office" + (type === "team" ? "?type=team" : ""));
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await entriesListRespPromise;
|
||||
}
|
||||
|
||||
async function openOOODialog(page: Page) {
|
||||
const reasonListRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
|
||||
);
|
||||
const hasTeamPlanRespPromise = page.waitForResponse(
|
||||
(response) => response.url().includes("hasTeamPlan?batch=1") && response.status() === 200
|
||||
);
|
||||
await page.getByTestId("add_entry_ooo").click();
|
||||
await Promise.all([reasonListRespPromise, hasTeamPlanRespPromise]);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"verify_email_change_success_toast": "Updated your email to {{email}}",
|
||||
"verify_email_change_failure_toast": "Failed to update email.",
|
||||
"change_of_email": "Verify your new {{appName}} email address",
|
||||
"trace_reference_id": "Trace reference ID",
|
||||
"change_of_email_toast": "We have sent a verification link to {{email}}. We will update your email address once you click this link.",
|
||||
"copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.",
|
||||
"verify_email_email_body": "Please verify your email address by clicking the button below.",
|
||||
@@ -308,6 +309,8 @@
|
||||
"guest": "Guest",
|
||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
||||
"confirmation": "Confirmation",
|
||||
"cancellation": "Cancellation",
|
||||
"request": "Request",
|
||||
"what_booker_should_provide": "What your booker should provide to receive confirmations",
|
||||
"404_the_user": "The username",
|
||||
"username": "Username",
|
||||
@@ -401,8 +404,14 @@
|
||||
"welcome_back": "Welcome back",
|
||||
"welcome_to_calcom": "Welcome to {{appName}}",
|
||||
"personal_welcome_description": "You're all set! Start scheduling meetings and managing your calendar.",
|
||||
"unlimited_calendars": "Unlimited calendars",
|
||||
"unlimited_event_types": "Unlimited event types",
|
||||
"workflows_feature": "Workflows",
|
||||
"integrate_with_favorite_apps": "Integrate with your favorite apps",
|
||||
"accept_payments_via_stripe": "Accept payments via Stripe",
|
||||
"html_react_embed": "HTML & React Embed",
|
||||
"cal_ai_phone_agent": "Cal.ai phone agent",
|
||||
"cal_video": "Cal Video",
|
||||
"and_more": "And more…",
|
||||
"welcome_instructions": "Tell us what to call you and let us know what timezone you're in. You'll be able to edit this later.",
|
||||
"connect_caldav": "Connect to CalDav (Beta)",
|
||||
@@ -1365,6 +1374,9 @@
|
||||
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
|
||||
"team_impersonation_description": "Allows your team Owners/Admins to temporarily sign in as you.",
|
||||
"cal_signup_description": "Free for individuals. Team plans for collaborative features.",
|
||||
"data_region": "Data region",
|
||||
"united_states": "United States",
|
||||
"european_union": "European Union",
|
||||
"make_org_private": "Make organization private",
|
||||
"make_org_private_description": "Your organization members won't be able to see other organization members when this is turned on.",
|
||||
"make_team_private": "Make team private",
|
||||
@@ -2913,8 +2925,8 @@
|
||||
"email_team_invite|content|added_to_subteam": "{{invitedBy}} has added you to the team <strong>{{teamName}}</strong> in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email_team_invite|content|invited_to_subteam": "{{invitedBy}} has invited you to join the team <strong>{{teamName}}</strong> in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email_team_invite|content|invited_to_regular_team": "{{invitedBy}} has invited you to join their team {{teamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email|existing_user_added_link_will_change": "On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.<br /><br />Please note: All of your personal event types will be moved into the <strong>{{teamName}}</strong> organization, which may also include potential personal link. <br /><br />For personal events we recommend creating a new account with a personal email address.",
|
||||
"email|existing_user_added_link_changed": "Your link has been changed from {{a0}} to {{a1}} but don't worry, all previous links still work and redirect appropriately. <br /><br />Please note: All of your personal event types have been moved into the <strong>{{teamName}}</strong> organisation, which may also include potential personal link.<br /><br />Please log in and make sure you have no private events on your new organisational account.<br /><br />For personal events we recommend creating a new account with a personal email address.<br /><br />Enjoy your new clean link: {{a2}}",
|
||||
"email|existing_user_added_link_will_change": "On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.<br /><br />Please note: All of your personal event types will be moved into the <strong>{{teamName}}</strong> organization, which may also include potential personal links. <br /><br />For personal events we recommend creating a new account with a personal email address.",
|
||||
"email|existing_user_added_link_changed": "Your link has been changed from {{a0}} to {{a1}} but don't worry, all previous links still work and redirect appropriately. <br /><br />Please note: All of your personal event types have been moved into the <strong>{{teamName}}</strong> organization, which may also include potential personal links.<br /><br />Please log in and make sure you have no private events on your new organizational account.<br /><br />For personal events we recommend creating a new account with a personal email address.<br /><br />Enjoy your new clean link: {{a2}}",
|
||||
"email_organization_created|subject": "Your organization has been created",
|
||||
"your_current_plan": "Your current plan",
|
||||
"organization_price_per_user_month": "$37 per user per month",
|
||||
@@ -3507,7 +3519,6 @@
|
||||
"user_workflows_unwhitelisted": "User workflows unwhitelisted",
|
||||
"date_and_time": "Date & Time",
|
||||
"email_reminder_upcoming_event_notice": "This is a reminder about your upcoming event.",
|
||||
"email_reminder_triggered_by_workflow": "This reminder was triggered by a Workflow in Cal.",
|
||||
"event_upper_case": "Event",
|
||||
"scheduling_by": "Scheduling by",
|
||||
"experience_review_prompt": "How was your recent experience?",
|
||||
@@ -3525,7 +3536,6 @@
|
||||
"add_verified_emails": "Add verified emails",
|
||||
"custom_reply_to_email_title": "Custom 'Reply-To' email",
|
||||
"custom_reply_to_email_description": "Use a different email address as the replyTo for confirmation emails instead of the organizer's email",
|
||||
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.",
|
||||
"skip_trial": "Skip trial period",
|
||||
"team_trials_skipped_successfully": "Team trials skipped successfully",
|
||||
"sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.",
|
||||
@@ -3926,6 +3936,37 @@
|
||||
"no_description_provided": "No description provided",
|
||||
"organization_blocklist": "Organization Blocklist",
|
||||
"manage_blocked_emails_and_domains": "Manage blocked emails and domains for your organization",
|
||||
"guest_notifications": "Guest Notifications",
|
||||
"guest_notifications_description": "Manage guest booking emails across all event types in your organization",
|
||||
"guest_booking_email_notifications": "Guest Booking Email Notifications",
|
||||
"guest_booking_email_notifications_description": "Manage which booking emails are sent to guests across all event types in your organization",
|
||||
"disable_guest_emails_warning": "These settings control important booking emails to keep your guests updated. They apply to all event types in your organization. If you disable emails, we recommend setting up org-wide workflows instead.",
|
||||
"disable_all_booking_emails_to_guests": "Disable all booking emails to guests",
|
||||
"disable_all": "Disable All",
|
||||
"enable_all": "Enable All",
|
||||
"disable_all_booking_emails_to_guests_description": "When enabled, guests will not receive any booking-related emails. This does not affect Workflow emails.",
|
||||
"disable_all_guest_booking_emails_confirm_title": "Are you sure you want to disable all booking emails?",
|
||||
"disable_all_guest_booking_emails_confirm_description": "This will prevent guests from receiving important booking updates across all event types in your organization. We recommend setting up custom org-wide workflow.",
|
||||
"enable_all_guest_booking_emails_confirm_title": "Are you sure you want to enable all booking emails?",
|
||||
"enable_all_guest_booking_emails_confirm_description": "This will allow guests to receive all booking-related email notifications across all event types in your organization.",
|
||||
"disable_individual_guest_email_confirm_title": "Are you sure you want to disable {{emailType}} emails?",
|
||||
"disable_individual_guest_email_confirm_description": "This will prevent guests from receiving {{emailType}} emails across all event types in your organization. We recommend setting up a custom org-wide Workflow for this.",
|
||||
"email_type": "Email Type",
|
||||
"guest_confirmation_email_description": "Email sent when booking is created",
|
||||
"guest_cancellation_email_description": "Email sent when booking is cancelled",
|
||||
"guest_rescheduled_email_description": "Email sent when booking is rescheduled",
|
||||
"guest_request_email_description": "Email sent when booking is requested",
|
||||
"attendee_request_email_description": "Email sent when booking is requested or declined",
|
||||
"attendee_reassigned_email_description": "Email sent when host is reassigned for the booking",
|
||||
"attendee_awaiting_payment_email_description": "Email sent when payment is pending",
|
||||
"attendee_reschedule_request_email_description": "Email sent when organizer requests attendee to reschedule",
|
||||
"attendee_location_change_email_description": "Email sent when meeting location is updated",
|
||||
"attendee_new_event_email_description": "Email sent to existing attendees when new guests are added",
|
||||
"host_reassignment": "Host Reassignment",
|
||||
"awaiting_payment": "Awaiting Payment",
|
||||
"reschedule_request": "Reschedule Request",
|
||||
"location_change": "Location Change",
|
||||
"guest_added": "Guest Added",
|
||||
"invalid_domain_format": "Invalid domain format. Example: example.com",
|
||||
"invalid_email_address": "Invalid email address. Example: user@example.com",
|
||||
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
|
||||
@@ -4052,5 +4093,17 @@
|
||||
"booking_response": "Booking Response",
|
||||
"list_view": "List view",
|
||||
"calendar_view": "Calendar view",
|
||||
"booking_history": "Booking History",
|
||||
"booking_history_description": "View the history of actions performed on this booking",
|
||||
"audit_action": {
|
||||
"created": "Created"
|
||||
},
|
||||
"error_loading_booking_logs": "Error loading booking logs",
|
||||
"no_audit_logs_found": "No audit logs found",
|
||||
"hide_details": "Hide details",
|
||||
"show_details": "Show details",
|
||||
"actor": "Actor",
|
||||
"timestamp": "Timestamp",
|
||||
"json": "JSON",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,18 @@ import type { EventType, User, WorkflowsOnEventTypes } from "@calcom/prisma/clie
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
// Helper to setup transaction mock that executes the callback with the prisma mock
|
||||
const setupTransactionMock = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||
if (typeof callback === "function") {
|
||||
return await callback(prismaMock);
|
||||
}
|
||||
return Promise.all(callback);
|
||||
});
|
||||
};
|
||||
|
||||
// create input does not allow ID
|
||||
const mockFindFirstEventType = (
|
||||
data?: Partial<EventType> & { workflows?: WorkflowsOnEventTypes[] } & { users?: User[] }
|
||||
@@ -132,6 +144,28 @@ describe("handleChildrenEventTypes", () => {
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Setup transaction mock to execute the callback
|
||||
setupTransactionMock();
|
||||
|
||||
// Mock createManyAndReturn to return the created event type (full EventType shape required for type safety)
|
||||
const createdEventType = {
|
||||
...evType,
|
||||
id: 123,
|
||||
userId: 4,
|
||||
schedulingType,
|
||||
teamId,
|
||||
timeZone,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [], team: { name: "" } },
|
||||
@@ -143,25 +177,28 @@ describe("handleChildrenEventTypes", () => {
|
||||
updatedValues: {},
|
||||
});
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...expectedEvType,
|
||||
parentId: 1,
|
||||
users: { connect: [{ id: 4 }] },
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
eventTypeColor: undefined,
|
||||
customReplyToEmail: null,
|
||||
userId: 4,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
...expectedEvType,
|
||||
parentId: 1,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
eventTypeColor: undefined,
|
||||
customReplyToEmail: null,
|
||||
userId: 4,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(result.newUserIds).toEqual([4]);
|
||||
expect(result.oldUserIds).toEqual([]);
|
||||
@@ -304,6 +341,29 @@ describe("handleChildrenEventTypes", () => {
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Setup transaction mock to execute the callback
|
||||
setupTransactionMock();
|
||||
|
||||
// Mock createManyAndReturn to return the created event type (full EventType shape required for type safety)
|
||||
const createdEventType = {
|
||||
...evType,
|
||||
id: 123,
|
||||
userId: 4,
|
||||
schedulingType,
|
||||
teamId,
|
||||
timeZone,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
@@ -316,27 +376,29 @@ describe("handleChildrenEventTypes", () => {
|
||||
updatedValues: {},
|
||||
});
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...expectedEvType,
|
||||
parentId: 1,
|
||||
users: { connect: [{ id: 4 }] },
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
eventTypeColor: undefined,
|
||||
customReplyToEmail: null,
|
||||
instantMeetingScheduleId: undefined,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
userId: 4,
|
||||
workflows: undefined,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
...expectedEvType,
|
||||
parentId: 1,
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
eventTypeColor: undefined,
|
||||
customReplyToEmail: null,
|
||||
instantMeetingScheduleId: undefined,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
userId: 4,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(result.newUserIds).toEqual([4]);
|
||||
expect(result.oldUserIds).toEqual([]);
|
||||
@@ -439,6 +501,31 @@ describe("handleChildrenEventTypes", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Setup transaction mock to execute the callback
|
||||
setupTransactionMock();
|
||||
|
||||
// Mock createManyAndReturn to return the newly created event type for workflow linking
|
||||
// This simulates the event type created for user 5 (full EventType shape required for type safety)
|
||||
const createdEventType = {
|
||||
...evType,
|
||||
id: 3,
|
||||
userId: 5,
|
||||
schedulingType: _schedulingType,
|
||||
teamId: _teamId,
|
||||
locations: _locations,
|
||||
timeZone: _timeZone,
|
||||
parentId: _parentId,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
// Mock the event type that will be returned for existing users
|
||||
const mockUpdatedEventType = {
|
||||
id: 2,
|
||||
@@ -466,6 +553,10 @@ describe("handleChildrenEventTypes", () => {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Mock workflowsOnEventTypes.findMany to return empty array (no existing workflow links for old event types)
|
||||
// This is needed because the new implementation uses findMany + createMany instead of upsert
|
||||
prismaMock.workflowsOnEventTypes.findMany.mockResolvedValue([]);
|
||||
|
||||
await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
|
||||
@@ -481,33 +572,42 @@ describe("handleChildrenEventTypes", () => {
|
||||
});
|
||||
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...expectedEvType,
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
eventTypeColor: undefined,
|
||||
customReplyToEmail: null,
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
parentId: 1,
|
||||
userId: 5,
|
||||
users: {
|
||||
connect: [{ id: 5 }],
|
||||
if ("workflows" in expectedEvType) delete expectedEvType.workflows;
|
||||
// Verify createManyAndReturn was called for new users (user 5)
|
||||
// Note: createManyAndReturn doesn't support nested relations like workflows, so they're handled separately
|
||||
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
...expectedEvType,
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
eventTypeColor: undefined,
|
||||
customReplyToEmail: null,
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
instantMeetingScheduleId: undefined,
|
||||
parentId: 1,
|
||||
userId: 5,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
workflows: {
|
||||
create: [{ workflowId: 11 }],
|
||||
},
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
|
||||
// Verify workflowsOnEventTypes.createMany was called for new users' workflows
|
||||
expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({
|
||||
data: [{ eventTypeId: 3, workflowId: 11 }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
const { profileId, rrSegmentQueryValue, createdAt: _, updatedAt: __, ...rest } = evType;
|
||||
if ("workflows" in rest) delete rest.workflows;
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
@@ -531,18 +631,23 @@ describe("handleChildrenEventTypes", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(prismaMock.workflowsOnEventTypes.upsert).toHaveBeenCalledWith({
|
||||
create: {
|
||||
eventTypeId: 2,
|
||||
workflowId: 11,
|
||||
},
|
||||
update: {},
|
||||
// Verify workflowsOnEventTypes.findMany was called to check existing relationships
|
||||
expect(prismaMock.workflowsOnEventTypes.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workflowId_eventTypeId: {
|
||||
eventTypeId: 2,
|
||||
workflowId: 11,
|
||||
},
|
||||
workflowId: { in: [11] },
|
||||
eventTypeId: { in: [2] },
|
||||
},
|
||||
select: {
|
||||
workflowId: true,
|
||||
eventTypeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify workflowsOnEventTypes.createMany was called for existing users' workflows
|
||||
// Since findMany returned empty array, all workflow-eventType pairs should be created
|
||||
expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({
|
||||
data: [{ eventTypeId: 2, workflowId: 11 }],
|
||||
skipDuplicates: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Cal.com OAuth Configuration
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here
|
||||
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
.yarn/
|
||||
.pnp.*
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
*.log
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# WXT Chrome Extension build outputs
|
||||
.output/
|
||||
.wxt/
|
||||
dev/
|
||||
|
||||
# Extension builds (generated at build time)
|
||||
*.zip
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Misc
|
||||
*.code-workspace
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd companion
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: "es5",
|
||||
singleQuote: false,
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
arrowParens: "always",
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createReadStream, existsSync } from "fs";
|
||||
import { promises as fs } from "fs";
|
||||
import { IncomingMessage, ServerResponse } from "http";
|
||||
import { extname, join, normalize } from "path";
|
||||
|
||||
const distDir = join(process.cwd(), "dist");
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
const getSafePath = (urlPath: string): string => {
|
||||
const decodedPath = decodeURIComponent(urlPath.split("?")[0]);
|
||||
const normalized = normalize(decodedPath).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
};
|
||||
|
||||
const resolveFilePath = async (safePath: string): Promise<string> => {
|
||||
let candidate = join(distDir, safePath);
|
||||
try {
|
||||
const stats = await fs.stat(candidate);
|
||||
if (stats.isDirectory()) {
|
||||
candidate = join(candidate, "index.html");
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
const fallback = join(distDir, "index.html");
|
||||
if (existsSync(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
throw new Error("Dist folder missing. Run `npm run build` before deploying.");
|
||||
}
|
||||
};
|
||||
|
||||
const sendFile = (res: ServerResponse, filePath: string) => {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const contentType = mimeTypes[ext] || "application/octet-stream";
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable"
|
||||
);
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("error", (error) => {
|
||||
console.error("Failed to read file:", error);
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
});
|
||||
stream.pipe(res);
|
||||
};
|
||||
|
||||
export default async function handler(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method && !["GET", "HEAD"].includes(req.method)) {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "GET, HEAD");
|
||||
res.end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlPath = req.url ?? "/";
|
||||
const safePath = getSafePath(urlPath);
|
||||
const filePath = await resolveFilePath(safePath);
|
||||
sendFile(res, filePath);
|
||||
} catch (error) {
|
||||
console.error("Server render error:", error);
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "expo-wxt-app",
|
||||
"slug": "calcom-companion",
|
||||
"scheme": "expo-wxt-app",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"deploymentTarget": "18.0",
|
||||
"bundleIdentifier": "com.calcom.companion",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.calcom.companion"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png",
|
||||
"bundler": "metro"
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "f5e97f6f-1e95-44ac-bfa4-f737ef90f198"
|
||||
}
|
||||
},
|
||||
"owner": "calcoms-organization"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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 { Platform } from "react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
// Check if liquid glass effect is available
|
||||
const supportsLiquidGlass = isLiquidGlassAvailable();
|
||||
|
||||
// Use NativeTabs for iOS 15+ (liquid glass support), fallback to standard Tabs
|
||||
if (supportsLiquidGlass) {
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="event-types">
|
||||
<Icon sf="link" />
|
||||
<Label>Event Types</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="bookings">
|
||||
<Icon sf="calendar" />
|
||||
<Label>Bookings</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="availability">
|
||||
<Icon sf="clock" />
|
||||
<Label>Availability</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="more">
|
||||
<Icon sf="ellipsis" />
|
||||
<Label>More</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to standard Expo Router Tabs for older iOS and Android
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: "#000000",
|
||||
tabBarInactiveTintColor: "#8E8E93",
|
||||
tabBarStyle: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: "#C6C6C8",
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
height: Platform.OS === "android" ? 74 : 66,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="event-types"
|
||||
options={{
|
||||
title: "Event Types",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons name={focused ? "link" : "link-outline"} size={24} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs.Screen
|
||||
name="bookings"
|
||||
options={{
|
||||
title: "Bookings",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons name={focused ? "calendar" : "calendar-outline"} size={24} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs.Screen
|
||||
name="availability"
|
||||
options={{
|
||||
title: "Availability",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons name={focused ? "time" : "time-outline"} size={24} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs.Screen
|
||||
name="more"
|
||||
options={{
|
||||
title: "More",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
name={focused ? "ellipsis-horizontal" : "ellipsis-horizontal-outline"}
|
||||
size={24}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter, useFocusEffect } from "expo-router";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
ActionSheetIOS,
|
||||
Alert,
|
||||
Platform,
|
||||
Modal,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
} from "react-native";
|
||||
|
||||
import { CalComAPIService, Schedule } from "../../services/calcom";
|
||||
import { Header } from "../../components/Header";
|
||||
import { FullScreenModal } from "../../components/FullScreenModal";
|
||||
|
||||
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);
|
||||
|
||||
// Fetch all schedules
|
||||
const allSchedules = await CalComAPIService.getSchedules();
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
setSchedules(sortedSchedules);
|
||||
setFilteredSchedules(sortedSchedules);
|
||||
} catch (err) {
|
||||
setError("Failed to load availability. Please check your API key and try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, []);
|
||||
|
||||
// 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();
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (Platform.OS !== "ios") {
|
||||
// Fallback for non-iOS platforms (Android Alert supports max 3 buttons)
|
||||
const options = [];
|
||||
if (!schedule.isDefault) {
|
||||
options.push({ text: "⭐ Set as default", onPress: () => handleSetAsDefault(schedule) });
|
||||
}
|
||||
options.push(
|
||||
{ text: "📋 Duplicate", onPress: () => handleDuplicate(schedule) },
|
||||
{ text: "🗑️ Delete", style: "destructive", onPress: () => handleDelete(schedule) }
|
||||
);
|
||||
// Android Alert automatically adds cancel, so we don't need to include it explicitly
|
||||
Alert.alert(schedule.name, "", options);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Cancel"];
|
||||
if (!schedule.isDefault) {
|
||||
options.push("⭐ Set as default");
|
||||
}
|
||||
options.push("📋 Duplicate", "🗑️ Delete");
|
||||
|
||||
const destructiveButtonIndex = options.length - 1; // Delete button
|
||||
const cancelButtonIndex = 0;
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
destructiveButtonIndex,
|
||||
cancelButtonIndex,
|
||||
title: schedule.name,
|
||||
},
|
||||
(buttonIndex) => {
|
||||
if (buttonIndex === cancelButtonIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!schedule.isDefault) {
|
||||
// Options: ["Cancel", "⭐ Set as default", "📋 Duplicate", "🗑️ Delete"]
|
||||
if (buttonIndex === 1) {
|
||||
handleSetAsDefault(schedule);
|
||||
} else if (buttonIndex === 2) {
|
||||
handleDuplicate(schedule);
|
||||
} else if (buttonIndex === 3) {
|
||||
handleDelete(schedule);
|
||||
}
|
||||
} else {
|
||||
// Options: ["Cancel", "📋 Duplicate", "🗑️ Delete"]
|
||||
if (buttonIndex === 1) {
|
||||
handleDuplicate(schedule);
|
||||
} else if (buttonIndex === 2) {
|
||||
handleDelete(schedule);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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 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 handleDelete = (schedule: Schedule) => {
|
||||
if (Platform.OS === "web") {
|
||||
// Use custom modal for web
|
||||
setShowDeleteModal(true);
|
||||
} else {
|
||||
// Use native Alert for iOS/Android
|
||||
Alert.alert("Delete Schedule", `Are you sure you want to delete "${schedule.name}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
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.");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedulePress = (schedule: Schedule) => {
|
||||
router.push({
|
||||
pathname: "/availability-detail",
|
||||
params: { id: schedule.id.toString() },
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setNewScheduleName("");
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleCreateSchedule = async () => {
|
||||
if (!newScheduleName.trim()) {
|
||||
Alert.alert("Error", "Please enter a schedule name");
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// Create schedule with Monday-Friday 9 AM - 5 PM default
|
||||
const newSchedule = await CalComAPIService.createSchedule({
|
||||
name: newScheduleName.trim(),
|
||||
timeZone: userTimezone,
|
||||
isDefault: false,
|
||||
availability: [
|
||||
{
|
||||
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
setNewScheduleName("");
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSchedule = ({ item: schedule, index }: { item: Schedule; index: number }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="border-b border-[#E5E5EA] bg-white active:bg-[#F8F9FA]"
|
||||
onPress={() => handleSchedulePress(schedule)}
|
||||
onLongPress={() => handleScheduleLongPress(schedule)}
|
||||
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 flex-wrap items-center">
|
||||
<Text className="text-base font-semibold text-[#333]">{schedule.name}</Text>
|
||||
{schedule.isDefault && (
|
||||
<View className="ml-2 rounded bg-[#666] px-2 py-0.5">
|
||||
<Text className="text-xs font-semibold text-white">Default</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{schedule.availability && schedule.availability.length > 0 ? (
|
||||
<View>
|
||||
{schedule.availability.map((slot, slotIndex) => (
|
||||
<View
|
||||
key={`${schedule.id}-${slot.days.join("-")}-${slotIndex}`}
|
||||
className={slotIndex > 0 ? "mt-2" : ""}
|
||||
>
|
||||
<Text className="text-sm text-[#666]">
|
||||
{slot.days.join(", ")} {slot.startTime} - {slot.endTime}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-sm text-[#666]">No availability set</Text>
|
||||
)}
|
||||
|
||||
<View className="mt-2 flex-row items-center">
|
||||
<Ionicons name="globe-outline" size={14} color="#666" />
|
||||
<Text className="ml-1.5 text-sm text-[#666]">{schedule.timeZone}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Three dots button - vertically centered on the right */}
|
||||
<TouchableOpacity
|
||||
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
|
||||
style={{ width: 32, height: 32 }}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedSchedule(schedule);
|
||||
setShowActionsModal(true);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View className="flex-1 bg-[#f8f9fa]">
|
||||
<Header />
|
||||
<View className="flex-1 items-center justify-center p-5">
|
||||
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
|
||||
<Text className="mb-2 mt-4 text-center text-xl font-bold text-[#333]">
|
||||
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}>
|
||||
<Text className="text-base font-semibold text-white">Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Create Schedule Modal */}
|
||||
<FullScreenModal
|
||||
visible={showCreateModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowCreateModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowCreateModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 20 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 25,
|
||||
elevation: 24,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="px-8 pb-4 pt-6">
|
||||
<Text className="text-2xl font-semibold text-[#111827]">Add a new schedule</Text>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="px-8 pb-6">
|
||||
<View className="mb-1">
|
||||
<Text className="mb-2 text-sm font-medium text-[#374151]">Name</Text>
|
||||
<TextInput
|
||||
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
|
||||
placeholder="Working Hours"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={newScheduleName}
|
||||
onChangeText={setNewScheduleName}
|
||||
autoFocus
|
||||
autoCapitalize="words"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleCreateSchedule}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
|
||||
<View className="flex-row justify-end gap-2 space-x-2">
|
||||
<TouchableOpacity
|
||||
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
|
||||
onPress={() => {
|
||||
setShowCreateModal(false);
|
||||
setNewScheduleName("");
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
<Text className="text-base font-medium text-[#374151]">Close</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${creating ? "opacity-60" : ""}`}
|
||||
onPress={handleCreateSchedule}
|
||||
disabled={creating}
|
||||
>
|
||||
<Text className="text-base font-medium text-white">Continue</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
|
||||
{/* Schedule Actions Modal */}
|
||||
<FullScreenModal
|
||||
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">
|
||||
Schedule Actions
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Actions List */}
|
||||
<View className="p-2">
|
||||
{/* Set as Default - only show if not already default */}
|
||||
{selectedSchedule && !selectedSchedule.isDefault && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
if (selectedSchedule) {
|
||||
handleSetAsDefault(selectedSchedule);
|
||||
}
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="star-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Set as Default</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Duplicate */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
if (selectedSchedule) {
|
||||
setTimeout(() => {
|
||||
handleDuplicate(selectedSchedule);
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="copy-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Duplicate</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* Delete */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowActionsModal(false);
|
||||
if (selectedSchedule) {
|
||||
// Small delay to ensure modal closes before alert shows
|
||||
setTimeout(() => {
|
||||
handleDelete(selectedSchedule);
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<FullScreenModal
|
||||
visible={showDeleteModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => !deleting && setShowDeleteModal(false)}
|
||||
>
|
||||
<View className="flex-1 items-center justify-center bg-black/50 p-4">
|
||||
<View className="w-full max-w-sm rounded-2xl bg-white p-6">
|
||||
{/* 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" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="mb-2 text-center text-xl font-semibold text-gray-900">
|
||||
Delete Schedule
|
||||
</Text>
|
||||
|
||||
{/* Description */}
|
||||
<Text className="mb-6 text-center text-base text-gray-600">
|
||||
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className="flex-row gap-3">
|
||||
<TouchableOpacity
|
||||
className="flex-1 rounded-lg bg-gray-100 px-4 py-3"
|
||||
onPress={() => setShowDeleteModal(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className={`flex-1 rounded-lg bg-gray-900 px-4 py-3 ${deleting ? "opacity-50" : ""}`}
|
||||
onPress={confirmDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-white">Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, ScrollView, Linking, 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";
|
||||
|
||||
interface MoreMenuItem {
|
||||
name: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
isExternal?: boolean;
|
||||
href?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export default function More() {
|
||||
const router = useRouter();
|
||||
|
||||
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 menuItems: MoreMenuItem[] = [
|
||||
{
|
||||
name: "Profile",
|
||||
icon: "person-outline",
|
||||
isExternal: true,
|
||||
onPress: () =>
|
||||
openExternalLink("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"),
|
||||
},
|
||||
{
|
||||
name: "Routing",
|
||||
icon: "git-branch-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://app.cal.com/routing", "Routing page"),
|
||||
},
|
||||
{
|
||||
name: "Workflows",
|
||||
icon: "flash-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://app.cal.com/workflows", "Workflows page"),
|
||||
},
|
||||
{
|
||||
name: "Insights",
|
||||
icon: "bar-chart-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://app.cal.com/insights", "Insights page"),
|
||||
},
|
||||
{
|
||||
name: "Support",
|
||||
icon: "help-circle-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openExternalLink("https://go.cal.com/support", "Support"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#f8f9fa]">
|
||||
<Header />
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 90 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
{menuItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.name}
|
||||
onPress={item.href ? () => router.push(item.href!) : item.onPress}
|
||||
className={`flex-row items-center justify-between bg-white px-5 py-5 active:bg-[#F8F9FA] ${
|
||||
index < menuItems.length - 1 ? "border-b border-[#E5E5EA]" : ""
|
||||
}`}
|
||||
>
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<Ionicons name={item.icon} size={20} color="#333" />
|
||||
<Text className="ml-3 text-base font-semibold text-[#333]">{item.name}</Text>
|
||||
</View>
|
||||
{item.isExternal ? (
|
||||
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Authentication Info and Logout */}
|
||||
<View className="py-4">
|
||||
<LogoutButton className="w-full bg-transparent text-gray-900" />
|
||||
</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.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform, View, StatusBar } from "react-native";
|
||||
import { AuthProvider, useAuth } from "../contexts/AuthContext";
|
||||
import LoginScreen from "../components/LoginScreen";
|
||||
import "../global.css";
|
||||
|
||||
function RootLayoutContent() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const content = isAuthenticated ? (
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
) : (
|
||||
<LoginScreen />
|
||||
);
|
||||
|
||||
const containerClass =
|
||||
Platform.OS === "web"
|
||||
? "w-[400px] flex-1 flex-col self-end border-l border-gray-200 bg-white"
|
||||
: "bg-white";
|
||||
|
||||
const containerStyle =
|
||||
Platform.OS === "web"
|
||||
? {
|
||||
width: 400,
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
pointerEvents: "auto",
|
||||
}
|
||||
: { flex: 1 };
|
||||
|
||||
return (
|
||||
<View style={containerStyle} className={containerClass}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<RootLayoutContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { GlassView } from "expo-glass-effect";
|
||||
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Switch,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { CalComAPIService, Schedule } from "../services/calcom";
|
||||
import { ScheduleAvailability } from "../services/types";
|
||||
import { FullScreenModal } from "../components/FullScreenModal";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
// Pad single-digit hours with leading zero for consistent width
|
||||
const hour12Padded = hour12.toString().padStart(2, "0");
|
||||
return `${hour12Padded}:${min} ${period}`;
|
||||
};
|
||||
|
||||
// Format availability for display
|
||||
const formatAvailabilityDisplay = (
|
||||
availability: Record<number, ScheduleAvailability[]>
|
||||
): string[] => {
|
||||
// Group slots by time range
|
||||
const timeRangeMap: Record<string, number[]> = {};
|
||||
|
||||
Object.keys(availability).forEach((dayIndexStr) => {
|
||||
const dayIndex = Number(dayIndexStr);
|
||||
const slots = availability[dayIndex];
|
||||
if (slots && slots.length > 0) {
|
||||
slots.forEach((slot) => {
|
||||
const timeKey = `${slot.startTime}-${slot.endTime}`;
|
||||
if (!timeRangeMap[timeKey]) {
|
||||
timeRangeMap[timeKey] = [];
|
||||
}
|
||||
timeRangeMap[timeKey].push(dayIndex);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Format each time range group
|
||||
const formatted: string[] = [];
|
||||
Object.keys(timeRangeMap).forEach((timeKey) => {
|
||||
const days = timeRangeMap[timeKey].sort((a, b) => a - b);
|
||||
const [startTime, endTime] = timeKey.split("-");
|
||||
const dayNames = days.map((day) => DAY_ABBREVIATIONS[day]).join(", ");
|
||||
const timeRange = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
|
||||
formatted.push(`${dayNames}, ${timeRange}`);
|
||||
});
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
// Generate time options (15-minute intervals)
|
||||
const generateTimeOptions = () => {
|
||||
const options: string[] = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const h = hour.toString().padStart(2, "0");
|
||||
const m = minute.toString().padStart(2, "0");
|
||||
options.push(`${h}:${m}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const TIME_OPTIONS = generateTimeOptions();
|
||||
|
||||
export default function AvailabilityDetail() {
|
||||
const router = useRouter();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [scheduleName, setScheduleName] = useState("");
|
||||
const [timeZone, setTimeZone] = useState("");
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [availability, setAvailability] = useState<Record<number, ScheduleAvailability[]>>({});
|
||||
const [showTimezoneModal, setShowTimezoneModal] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState<{
|
||||
dayIndex: number;
|
||||
slotIndex: number;
|
||||
type: "start" | "end";
|
||||
} | null>(null);
|
||||
const [overrides, setOverrides] = useState<
|
||||
Array<{
|
||||
date: string; // Format: "2024-05-20"
|
||||
startTime: string; // Format: "12:00" or "00:00" for unavailable
|
||||
endTime: string; // Format: "14:00" or "00:00" for unavailable
|
||||
}>
|
||||
>([]);
|
||||
const [showOverrideModal, setShowOverrideModal] = useState(false);
|
||||
const [editingOverride, setEditingOverride] = useState<number | null>(null);
|
||||
const [overrideDate, setOverrideDate] = useState("");
|
||||
const [overrideStartTime, setOverrideStartTime] = useState("09:00");
|
||||
const [overrideEndTime, setOverrideEndTime] = useState("17:00");
|
||||
const [isUnavailable, setIsUnavailable] = useState(false);
|
||||
const [showOverrideTimePicker, setShowOverrideTimePicker] = useState<{
|
||||
type: "start" | "end";
|
||||
} | null>(null);
|
||||
|
||||
// Common timezones
|
||||
const timezones = [
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Kolkata",
|
||||
"Australia/Sydney",
|
||||
"UTC",
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchSchedule();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchSchedule = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const scheduleData = await CalComAPIService.getScheduleById(Number(id));
|
||||
|
||||
if (scheduleData) {
|
||||
setSchedule(scheduleData);
|
||||
setScheduleName(scheduleData.name || "");
|
||||
setTimeZone(scheduleData.timeZone || "UTC");
|
||||
setIsDefault(scheduleData.isDefault || false);
|
||||
|
||||
// Convert availability array to day-indexed object
|
||||
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
|
||||
|
||||
// Map day names to numbers
|
||||
const dayNameToNumber: Record<string, number> = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
|
||||
if (scheduleData.availability && Array.isArray(scheduleData.availability)) {
|
||||
scheduleData.availability.forEach((slot) => {
|
||||
// Handle both string day names and number day formats
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
// If it's a day name string (e.g., "Sunday", "Monday")
|
||||
if (typeof day === "string" && dayNameToNumber[day] !== undefined) {
|
||||
return dayNameToNumber[day];
|
||||
}
|
||||
// If it's a number string (e.g., "0", "1")
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
// If it's already a number
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
days.forEach((day) => {
|
||||
if (!availabilityMap[day]) {
|
||||
availabilityMap[day] = [];
|
||||
}
|
||||
availabilityMap[day].push({
|
||||
days: [day.toString()],
|
||||
startTime: slot.startTime || "09:00:00",
|
||||
endTime: slot.endTime || "17:00:00",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setAvailability(availabilityMap);
|
||||
|
||||
// Load overrides if they exist
|
||||
if (scheduleData.overrides && Array.isArray(scheduleData.overrides)) {
|
||||
const formattedOverrides = scheduleData.overrides.map((override) => ({
|
||||
date: override.date || "",
|
||||
startTime: override.startTime || "00:00",
|
||||
endTime: override.endTime || "00:00",
|
||||
}));
|
||||
setOverrides(formattedOverrides);
|
||||
} else {
|
||||
setOverrides([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching schedule:", error);
|
||||
Alert.alert("Error", "Failed to load schedule. Please try again.");
|
||||
router.back();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDay = (dayIndex: number) => {
|
||||
setAvailability((prev) => {
|
||||
const newAvailability = { ...prev };
|
||||
if (newAvailability[dayIndex] && newAvailability[dayIndex].length > 0) {
|
||||
// Disable day - remove availability
|
||||
delete newAvailability[dayIndex];
|
||||
} else {
|
||||
// Enable day - add default time slot (9 AM - 5 PM)
|
||||
newAvailability[dayIndex] = [
|
||||
{
|
||||
days: [dayIndex.toString()],
|
||||
startTime: "09:00:00",
|
||||
endTime: "17:00:00",
|
||||
},
|
||||
];
|
||||
}
|
||||
return newAvailability;
|
||||
});
|
||||
};
|
||||
|
||||
const addTimeSlot = (dayIndex: number) => {
|
||||
setAvailability((prev) => {
|
||||
const newAvailability = { ...prev };
|
||||
if (!newAvailability[dayIndex]) {
|
||||
newAvailability[dayIndex] = [];
|
||||
}
|
||||
newAvailability[dayIndex].push({
|
||||
days: [dayIndex.toString()],
|
||||
startTime: "09:00:00",
|
||||
endTime: "17:00:00",
|
||||
});
|
||||
return newAvailability;
|
||||
});
|
||||
};
|
||||
|
||||
const removeTimeSlot = (dayIndex: number, slotIndex: number) => {
|
||||
setAvailability((prev) => {
|
||||
const newAvailability = { ...prev };
|
||||
if (newAvailability[dayIndex]) {
|
||||
newAvailability[dayIndex] = newAvailability[dayIndex].filter((_, i) => i !== slotIndex);
|
||||
if (newAvailability[dayIndex].length === 0) {
|
||||
delete newAvailability[dayIndex];
|
||||
}
|
||||
}
|
||||
return newAvailability;
|
||||
});
|
||||
};
|
||||
|
||||
const updateTimeSlot = (
|
||||
dayIndex: number,
|
||||
slotIndex: number,
|
||||
type: "start" | "end",
|
||||
time: string
|
||||
) => {
|
||||
setAvailability((prev) => {
|
||||
const newAvailability = { ...prev };
|
||||
if (newAvailability[dayIndex] && newAvailability[dayIndex][slotIndex]) {
|
||||
const slot = { ...newAvailability[dayIndex][slotIndex] };
|
||||
if (type === "start") {
|
||||
slot.startTime = `${time}:00`;
|
||||
} else {
|
||||
slot.endTime = `${time}:00`;
|
||||
}
|
||||
newAvailability[dayIndex][slotIndex] = slot;
|
||||
}
|
||||
return newAvailability;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Convert availability object back to array format with day names
|
||||
const availabilityArray: Array<{
|
||||
days: string[]; // Day names like "Monday", "Tuesday"
|
||||
startTime: string; // Format: "09:00"
|
||||
endTime: string; // Format: "10:00"
|
||||
}> = [];
|
||||
|
||||
Object.keys(availability).forEach((dayIndexStr) => {
|
||||
const dayIndex = Number(dayIndexStr);
|
||||
const slots = availability[dayIndex];
|
||||
if (slots && slots.length > 0) {
|
||||
slots.forEach((slot) => {
|
||||
// Convert day number to day name
|
||||
const dayName = DAYS[dayIndex];
|
||||
|
||||
// Format time from "09:00:00" to "09:00" (remove seconds)
|
||||
const formatTimeForAPI = (time: string): string => {
|
||||
return time.substring(0, 5); // Take only HH:MM
|
||||
};
|
||||
|
||||
availabilityArray.push({
|
||||
days: [dayName],
|
||||
startTime: formatTimeForAPI(slot.startTime),
|
||||
endTime: formatTimeForAPI(slot.endTime),
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Format overrides for API (ensure times are in HH:MM format)
|
||||
const formattedOverrides = overrides.map((override) => ({
|
||||
date: override.date,
|
||||
startTime: override.startTime.substring(0, 5), // Ensure HH:MM format
|
||||
endTime: override.endTime.substring(0, 5), // Ensure HH:MM format
|
||||
}));
|
||||
|
||||
await CalComAPIService.updateSchedule(Number(id), {
|
||||
name: scheduleName,
|
||||
timeZone,
|
||||
availability: availabilityArray,
|
||||
isDefault: isDefault,
|
||||
overrides: formattedOverrides,
|
||||
});
|
||||
|
||||
Alert.alert("Success", "Schedule updated successfully", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to update schedule. Please try again.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetAsDefault = async () => {
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(Number(id), {
|
||||
isDefault: true,
|
||||
});
|
||||
setIsDefault(true);
|
||||
Alert.alert("Success", "Schedule set as default successfully");
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert("Delete Schedule", `Are you sure you want to delete "${scheduleName}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await CalComAPIService.deleteSchedule(Number(id));
|
||||
Alert.alert("Success", "Schedule deleted successfully", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to delete schedule. Please try again.");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleAddOverride = () => {
|
||||
setEditingOverride(null);
|
||||
setOverrideDate("");
|
||||
setOverrideStartTime("09:00");
|
||||
setOverrideEndTime("17:00");
|
||||
setIsUnavailable(false);
|
||||
setShowOverrideModal(true);
|
||||
};
|
||||
|
||||
const handleEditOverride = (index: number) => {
|
||||
const override = overrides[index];
|
||||
setEditingOverride(index);
|
||||
setOverrideDate(override.date);
|
||||
setOverrideStartTime(override.startTime);
|
||||
setOverrideEndTime(override.endTime);
|
||||
setIsUnavailable(override.startTime === "00:00" && override.endTime === "00:00");
|
||||
setShowOverrideModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteOverride = (index: number) => {
|
||||
Alert.alert("Delete Override", "Are you sure you want to delete this date override?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
setOverrides(overrides.filter((_, i) => i !== index));
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSaveOverride = () => {
|
||||
if (!overrideDate) {
|
||||
Alert.alert("Error", "Please select a date");
|
||||
return;
|
||||
}
|
||||
|
||||
const newOverride = {
|
||||
date: overrideDate,
|
||||
startTime: isUnavailable ? "00:00" : overrideStartTime,
|
||||
endTime: isUnavailable ? "00:00" : overrideEndTime,
|
||||
};
|
||||
|
||||
if (editingOverride !== null) {
|
||||
// Update existing override
|
||||
const updated = [...overrides];
|
||||
updated[editingOverride] = newOverride;
|
||||
setOverrides(updated);
|
||||
} else {
|
||||
// Add new override
|
||||
setOverrides([...overrides, newOverride]);
|
||||
}
|
||||
|
||||
setShowOverrideModal(false);
|
||||
};
|
||||
|
||||
const formatDateForDisplay = (dateStr: string): string => {
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
|
||||
<ActivityIndicator size="large" color="#000000" />
|
||||
<Text className="mt-4 text-base text-[#666]">Loading schedule...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTitle: () => (
|
||||
<Text style={{ fontSize: 20, fontWeight: "700" }}>Edit Availability</Text>
|
||||
),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => router.back()} className="ml-4">
|
||||
<Ionicons name="chevron-back" size={24} color="#000" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
className={`mr-4 min-w-[60px] items-center rounded-[10px] bg-black px-2 py-2 md:px-4 ${saving ? "opacity-60" : ""}`}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text className="text-base font-semibold text-white">Save</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<ScrollView
|
||||
className="flex-1 bg-[#f8f9fa]"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 200 }}
|
||||
>
|
||||
<View className="gap-4">
|
||||
{/* Schedule Name and Working Hours Display */}
|
||||
<View className="rounded-2xl bg-white p-6">
|
||||
<Text className="mb-3 text-xl font-bold text-[#333]">{scheduleName}</Text>
|
||||
{Object.keys(availability).length > 0 ? (
|
||||
<View>
|
||||
{formatAvailabilityDisplay(availability).map((line, index) => (
|
||||
<Text key={index} className="mb-1 text-base text-[#666]">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-base text-[#999]">No availability set</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Availability Schedule */}
|
||||
<View className="rounded-2xl bg-white p-6">
|
||||
<Text className="mb-4 text-xl font-bold text-[#333]">Availability</Text>
|
||||
{DAYS.map((day, dayIndex) => {
|
||||
const daySlots = availability[dayIndex] || [];
|
||||
const isEnabled = daySlots.length > 0;
|
||||
const firstSlot = daySlots[0];
|
||||
|
||||
return (
|
||||
<View
|
||||
key={dayIndex}
|
||||
className={`mb-3 border-b border-[#E5E5EA] pb-3 ${dayIndex === DAYS.length - 1 ? "mb-0 border-b-0 pb-0" : ""}`}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={() => toggleDay(dayIndex)}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#fff"
|
||||
style={{ transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }] }}
|
||||
/>
|
||||
<Text
|
||||
className="ml-1 text-base font-medium text-[#333]"
|
||||
style={{ width: 40 }}
|
||||
>
|
||||
{DAY_ABBREVIATIONS[dayIndex]}
|
||||
</Text>
|
||||
{isEnabled && firstSlot && (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setShowTimePicker({ dayIndex, slotIndex: 0, type: "start" })
|
||||
}
|
||||
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
|
||||
style={{ width: 85 }}
|
||||
>
|
||||
<Text className="text-center text-base text-[#333]">
|
||||
{formatTime12Hour(firstSlot.startTime)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-base text-[#666]">-</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setShowTimePicker({ dayIndex, slotIndex: 0, type: "end" })
|
||||
}
|
||||
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
|
||||
style={{ width: 85 }}
|
||||
>
|
||||
<Text className="text-center text-base text-[#333]">
|
||||
{formatTime12Hour(firstSlot.endTime)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{isEnabled && (
|
||||
<TouchableOpacity onPress={() => addTimeSlot(dayIndex)} className="p-1">
|
||||
<Ionicons name="add-circle-outline" size={20} color="#007AFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isEnabled && daySlots.length > 1 && (
|
||||
<View className="mt-2" style={{ marginLeft: 108 }}>
|
||||
{daySlots.slice(1).map((slot, slotIndex) => (
|
||||
<View key={slotIndex + 1} className="mb-2 flex-row items-center gap-2">
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setShowTimePicker({
|
||||
dayIndex,
|
||||
slotIndex: slotIndex + 1,
|
||||
type: "start",
|
||||
})
|
||||
}
|
||||
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
|
||||
style={{ width: 85 }}
|
||||
>
|
||||
<Text className="text-center text-base text-[#333]">
|
||||
{formatTime12Hour(slot.startTime)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-base text-[#666]">-</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setShowTimePicker({
|
||||
dayIndex,
|
||||
slotIndex: slotIndex + 1,
|
||||
type: "end",
|
||||
})
|
||||
}
|
||||
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
|
||||
style={{ width: 85 }}
|
||||
>
|
||||
<Text className="text-center text-base text-[#333]">
|
||||
{formatTime12Hour(slot.endTime)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => removeTimeSlot(dayIndex, slotIndex + 1)}
|
||||
className="p-1"
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Timezone */}
|
||||
<View className="rounded-2xl bg-white p-6">
|
||||
<Text className="mb-3 text-xl font-bold text-[#333]">Timezone</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowTimezoneModal(true)}
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
>
|
||||
<Text className="text-base text-[#333]">{timeZone}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Date Overrides */}
|
||||
<View className="rounded-2xl bg-white p-6">
|
||||
<Text className="mb-2 text-xl font-bold text-[#333]">Date overrides</Text>
|
||||
<Text className="mb-4 text-base text-[#666]">
|
||||
Add dates when your availability changes from your daily hours.
|
||||
</Text>
|
||||
|
||||
{overrides.length > 0 && (
|
||||
<View className="mb-4">
|
||||
{overrides.map((override, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className="mb-2 flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] p-3"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
{formatDateForDisplay(override.date)}
|
||||
</Text>
|
||||
{override.startTime === "00:00" && override.endTime === "00:00" ? (
|
||||
<Text className="text-sm text-[#666]">Unavailable (All day)</Text>
|
||||
) : (
|
||||
<Text className="text-sm text-[#666]">
|
||||
{formatTime12Hour(override.startTime + ":00")} -{" "}
|
||||
{formatTime12Hour(override.endTime + ":00")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TouchableOpacity onPress={() => handleEditOverride(index)} className="p-2">
|
||||
<Ionicons name="pencil-outline" size={20} color="#007AFF" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDeleteOverride(index)}
|
||||
className="p-2"
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleAddOverride}
|
||||
className="flex-row items-center justify-center rounded-lg border border-dashed border-[#E5E5EA] py-3"
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={20} color="#007AFF" />
|
||||
<Text className="ml-2 text-base font-medium text-[#007AFF]">Add an override</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Action Bar */}
|
||||
<GlassView
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingTop: 16,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: "#E5E5EA",
|
||||
paddingBottom: insets.bottom + 12,
|
||||
}}
|
||||
glassEffectStyle="clear"
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-base font-medium text-[#333]">Set as Default</Text>
|
||||
<Switch
|
||||
value={isDefault}
|
||||
onValueChange={handleSetAsDefault}
|
||||
disabled={isDefault}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-3">
|
||||
<GlassView
|
||||
className="overflow-hidden rounded-full bg-[rgba(255,255,255,0.1)]"
|
||||
glassEffectStyle="clear"
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="h-11 w-11 items-center justify-center"
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
</TouchableOpacity>
|
||||
</GlassView>
|
||||
</View>
|
||||
</View>
|
||||
</GlassView>
|
||||
</View>
|
||||
|
||||
{/* Timezone Modal */}
|
||||
<FullScreenModal
|
||||
visible={showTimezoneModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowTimezoneModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowTimezoneModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="max-h-[80%] w-[90%] max-w-[500px] rounded-2xl bg-white p-4"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Text className="text-xl font-bold text-[#333]">Select Timezone</Text>
|
||||
<TouchableOpacity onPress={() => setShowTimezoneModal(false)}>
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{timezones.map((tz) => (
|
||||
<TouchableOpacity
|
||||
key={tz}
|
||||
onPress={() => {
|
||||
setTimeZone(tz);
|
||||
setShowTimezoneModal(false);
|
||||
}}
|
||||
className="border-b border-[#E5E5EA] py-3"
|
||||
>
|
||||
<Text className="text-base text-[#333]">{tz}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
|
||||
{/* Time Picker Modal */}
|
||||
<FullScreenModal
|
||||
visible={!!showTimePicker}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowTimePicker(null)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowTimePicker(null)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="max-h-[80%] w-[90%] max-w-[500px] rounded-2xl bg-white p-4"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Text className="text-xl font-bold text-[#333]">
|
||||
Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setShowTimePicker(null)}>
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{TIME_OPTIONS.map((time) => {
|
||||
const currentTime =
|
||||
showTimePicker?.type === "start"
|
||||
? availability[showTimePicker.dayIndex]?.[
|
||||
showTimePicker.slotIndex
|
||||
]?.startTime.substring(0, 5)
|
||||
: availability[showTimePicker?.dayIndex || 0]?.[
|
||||
showTimePicker?.slotIndex || 0
|
||||
]?.endTime.substring(0, 5);
|
||||
const isSelected = currentTime === time;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={time}
|
||||
onPress={() => {
|
||||
if (showTimePicker) {
|
||||
updateTimeSlot(
|
||||
showTimePicker.dayIndex,
|
||||
showTimePicker.slotIndex,
|
||||
showTimePicker.type,
|
||||
time
|
||||
);
|
||||
setShowTimePicker(null);
|
||||
}
|
||||
}}
|
||||
className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#E3F2FD]" : ""}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-base ${isSelected ? "font-semibold text-[#007AFF]" : "text-[#333]"}`}
|
||||
>
|
||||
{time}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
|
||||
{/* Override Modal */}
|
||||
<FullScreenModal
|
||||
visible={showOverrideModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowOverrideModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowOverrideModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="max-h-[90%] w-[90%] max-w-[500px] rounded-2xl bg-white p-6"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Text className="text-xl font-bold text-[#333]">
|
||||
{editingOverride !== null ? "Edit Override" : "Add Override"}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setShowOverrideModal(false)}>
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
<View className="gap-4">
|
||||
{/* Date Picker */}
|
||||
<View>
|
||||
<Text className="mb-2 text-base font-semibold text-[#333]">
|
||||
Select the dates to override
|
||||
</Text>
|
||||
<TextInput
|
||||
value={overrideDate}
|
||||
onChangeText={setOverrideDate}
|
||||
placeholder="YYYY-MM-DD (e.g., 2024-05-20)"
|
||||
placeholderTextColor="#999"
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-[#333]"
|
||||
/>
|
||||
<Text className="mt-1 text-sm text-[#666]">Enter date in YYYY-MM-DD format</Text>
|
||||
</View>
|
||||
|
||||
{/* Unavailable Toggle */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-base font-semibold text-[#333]">
|
||||
Mark unavailable (All day)
|
||||
</Text>
|
||||
<Switch
|
||||
value={isUnavailable}
|
||||
onValueChange={setIsUnavailable}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Picker (only if not unavailable) */}
|
||||
{!isUnavailable && (
|
||||
<View>
|
||||
<Text className="mb-3 text-base font-semibold text-[#333]">
|
||||
Which hours are you free?
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowOverrideTimePicker({ type: "start" })}
|
||||
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3"
|
||||
>
|
||||
<Text className="text-center text-base text-[#333]">
|
||||
{formatTime12Hour(overrideStartTime + ":00")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-base text-[#666]">-</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowOverrideTimePicker({ type: "end" })}
|
||||
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3"
|
||||
>
|
||||
<Text className="text-center text-base text-[#333]">
|
||||
{formatTime12Hour(overrideEndTime + ":00")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleSaveOverride}
|
||||
className="mt-4 rounded-lg bg-black py-3"
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-white">
|
||||
Save Override
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
|
||||
{/* Override Time Picker Modal */}
|
||||
<FullScreenModal
|
||||
visible={!!showOverrideTimePicker}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowOverrideTimePicker(null)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowOverrideTimePicker(null)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="max-h-[60%] w-[90%] max-w-[500px] rounded-2xl bg-white p-6"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Text className="text-xl font-bold text-[#333]">
|
||||
Select {showOverrideTimePicker?.type === "start" ? "Start" : "End"} Time
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setShowOverrideTimePicker(null)}>
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{TIME_OPTIONS.map((time) => {
|
||||
const currentTime =
|
||||
showOverrideTimePicker?.type === "start" ? overrideStartTime : overrideEndTime;
|
||||
const isSelected = currentTime === time;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={time}
|
||||
onPress={() => {
|
||||
if (showOverrideTimePicker?.type === "start") {
|
||||
setOverrideStartTime(time);
|
||||
} else {
|
||||
setOverrideEndTime(time);
|
||||
}
|
||||
setShowOverrideTimePicker(null);
|
||||
}}
|
||||
className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#F0F9FF]" : ""}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-base ${isSelected ? "font-semibold text-[#007AFF]" : "text-[#333]"}`}
|
||||
>
|
||||
{formatTime12Hour(time + ":00")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
TextInput,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CalComAPIService, Booking } from "../services/calcom";
|
||||
import { SvgImage } from "../components/SvgImage";
|
||||
import { FullScreenModal } from "../components/FullScreenModal";
|
||||
import { getAppIconUrl } from "../utils/getAppIconUrl";
|
||||
import { getDefaultLocationIconUrl, defaultLocations } from "../utils/defaultLocations";
|
||||
|
||||
// Format date: "Tuesday, November 25, 2025"
|
||||
const formatDateFull = (dateString: string): string => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Format time: "9:40pm - 10:00pm"
|
||||
const formatTime12Hour = (dateString: string): string => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const period = hours >= 12 ? "pm" : "am";
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
const minStr = minutes.toString().padStart(2, "0");
|
||||
return `${hour12}:${minStr}${period}`;
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Get timezone from date string
|
||||
const getTimezone = (dateString: string): string => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return timeZone;
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Get initials from a name (e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS")
|
||||
const getInitials = (name: string): string => {
|
||||
if (!name) return "";
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.length === 1) {
|
||||
return parts[0].charAt(0).toUpperCase();
|
||||
}
|
||||
// Get first letter of first name and first letter of last name
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||
};
|
||||
|
||||
// Get location provider info
|
||||
const getLocationProvider = (location: string | undefined, metadata?: Record<string, any>) => {
|
||||
// Check metadata for videoCallUrl first
|
||||
const videoCallUrl = metadata?.videoCallUrl;
|
||||
const locationToCheck = videoCallUrl || location;
|
||||
|
||||
if (!locationToCheck) return null;
|
||||
|
||||
// Check if it's a video call URL
|
||||
if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) {
|
||||
// Try to detect provider from URL
|
||||
if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) {
|
||||
const iconUrl = getAppIconUrl("daily_video", "cal-video");
|
||||
return {
|
||||
label: "Cal Video",
|
||||
iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg",
|
||||
url: locationToCheck,
|
||||
};
|
||||
}
|
||||
// Check for other video providers by URL pattern
|
||||
const videoProviders = [
|
||||
{ pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" },
|
||||
{
|
||||
pattern: /meet\.google\.com/,
|
||||
label: "Google Meet",
|
||||
type: "google_video",
|
||||
appId: "google-meet",
|
||||
},
|
||||
{
|
||||
pattern: /teams\.microsoft\.com/,
|
||||
label: "Microsoft Teams",
|
||||
type: "office365_video",
|
||||
appId: "msteams",
|
||||
},
|
||||
];
|
||||
|
||||
for (const provider of videoProviders) {
|
||||
if (provider.pattern.test(locationToCheck)) {
|
||||
const iconUrl = getAppIconUrl(provider.type, provider.appId);
|
||||
return {
|
||||
label: provider.label,
|
||||
iconUrl: iconUrl,
|
||||
url: locationToCheck,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Generic link meeting
|
||||
const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg";
|
||||
return {
|
||||
label: "Link Meeting",
|
||||
iconUrl: linkIconUrl,
|
||||
url: locationToCheck,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video")
|
||||
if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) {
|
||||
const appId = locationToCheck.replace("integrations:", "");
|
||||
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,
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a default location type
|
||||
const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck);
|
||||
if (defaultLocation) {
|
||||
return {
|
||||
label: defaultLocation.label,
|
||||
iconUrl: defaultLocation.iconUrl,
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: return as plain text location
|
||||
return {
|
||||
label: locationToCheck,
|
||||
iconUrl: null,
|
||||
url: null,
|
||||
};
|
||||
};
|
||||
|
||||
export default function BookingDetail() {
|
||||
const router = useRouter();
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showActionsModal, setShowActionsModal] = useState(false);
|
||||
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
|
||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||
const [rescheduling, setRescheduling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
fetchBooking();
|
||||
}
|
||||
}, [uid]);
|
||||
|
||||
const fetchBooking = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const bookingData = await CalComAPIService.getBookingByUid(uid);
|
||||
console.log("Booking data received:", JSON.stringify(bookingData, null, 2));
|
||||
console.log("User:", bookingData.user);
|
||||
console.log("Hosts:", bookingData.hosts);
|
||||
console.log("Attendees:", bookingData.attendees);
|
||||
console.log("Recurring fields:", {
|
||||
recurringEventId: bookingData.recurringEventId,
|
||||
recurringBookingUid: (bookingData as any).recurringBookingUid,
|
||||
metadata: (bookingData as any).metadata,
|
||||
});
|
||||
setBooking(bookingData);
|
||||
} 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() },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinMeeting = () => {
|
||||
if (!booking?.location) return;
|
||||
|
||||
const provider = getLocationProvider(booking.location);
|
||||
if (provider?.url) {
|
||||
Linking.openURL(provider.url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReschedule = async () => {
|
||||
if (!booking) return;
|
||||
|
||||
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,
|
||||
reschedulingReason: rescheduleReason.trim() || undefined,
|
||||
});
|
||||
|
||||
Alert.alert("Success", "Reschedule request sent successfully");
|
||||
setShowRescheduleModal(false);
|
||||
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.");
|
||||
} finally {
|
||||
setRescheduling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
|
||||
<ActivityIndicator size="large" color="#000000" />
|
||||
<Text className="mt-4 text-base text-gray-500">Loading booking...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#f8f9fa] 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">
|
||||
{error || "Booking not found"}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="mt-6 rounded-lg bg-black px-6 py-3"
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text className="text-base font-semibold text-white">Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const startTime = booking.start || booking.startTime || "";
|
||||
const endTime = booking.end || booking.endTime || "";
|
||||
const dateFormatted = formatDateFull(startTime);
|
||||
const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
|
||||
const timezone = getTimezone(startTime);
|
||||
const locationProvider = getLocationProvider(booking.location, booking.responses);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTitle: booking?.title || "Booking Details",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="items-center justify-center rounded-full"
|
||||
style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#000" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowActionsModal(true)}
|
||||
className="items-center justify-center rounded-full"
|
||||
style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<View className="flex-1 bg-[#f8f9fa]">
|
||||
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, paddingBottom: 100 }}>
|
||||
{/* Title */}
|
||||
<View className="mb-3">
|
||||
<Text className="mb-2 text-2xl font-semibold text-[#333]">{booking.title}</Text>
|
||||
<Text className="text-base text-[#666]">
|
||||
{dateFormatted} {timeFormatted} ({timezone})
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Who Section */}
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-4 text-base font-medium text-[#666]">Who</Text>
|
||||
{/* Show host from user field or hosts array */}
|
||||
{(booking.user || (booking.hosts && booking.hosts.length > 0)) && (
|
||||
<View className="mb-4">
|
||||
{booking.user ? (
|
||||
<View className="flex-row items-start">
|
||||
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
|
||||
<Text className="text-base font-semibold text-white">
|
||||
{getInitials(booking.user.name)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<View className="mb-1 flex-row flex-wrap items-center">
|
||||
<Text className="text-base font-medium text-[#333]">
|
||||
{booking.user.name}
|
||||
</Text>
|
||||
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
|
||||
<Text className="text-xs font-medium text-white">host</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-sm text-[#666]">{booking.user.email}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : booking.hosts && booking.hosts.length > 0 ? (
|
||||
booking.hosts.map((host, hostIndex) => (
|
||||
<View
|
||||
key={hostIndex}
|
||||
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}
|
||||
>
|
||||
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
|
||||
<Text className="text-base font-semibold text-white">
|
||||
{getInitials(host.name || "Host")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<View className="mb-1 flex-row flex-wrap items-center">
|
||||
<Text className="text-base font-medium text-[#333]">
|
||||
{host.name || "Host"}
|
||||
</Text>
|
||||
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
|
||||
<Text className="text-xs font-medium text-white">host</Text>
|
||||
</View>
|
||||
</View>
|
||||
{host.email && <Text className="text-sm text-[#666]">{host.email}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
{booking.attendees && booking.attendees.length > 0 && (
|
||||
<View>
|
||||
{booking.attendees.map((attendee, index) => (
|
||||
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
|
||||
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
|
||||
<Text className="text-base font-semibold text-white">
|
||||
{getInitials(attendee.name)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
{attendee.name}
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">{attendee.email}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Where Section */}
|
||||
{locationProvider && (
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
|
||||
{locationProvider.url ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleJoinMeeting}
|
||||
className="flex-row flex-wrap items-center"
|
||||
>
|
||||
{locationProvider.iconUrl && (
|
||||
<SvgImage
|
||||
uri={locationProvider.iconUrl}
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
|
||||
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
|
||||
{locationProvider.url}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View className="flex-row items-center">
|
||||
{locationProvider.iconUrl && (
|
||||
<SvgImage
|
||||
uri={locationProvider.iconUrl}
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Recurring Event Section */}
|
||||
{(booking.recurringEventId || booking.recurringBookingUid) && (
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-2 text-base font-medium text-[#666]">Recurring Event</Text>
|
||||
<Text className="text-base text-[#666]">Every 2 weeks for 6 occurrences</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Description Section */}
|
||||
{booking.description && (
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-2 text-base font-medium text-[#666]">Description</Text>
|
||||
<Text className="text-base leading-6 text-[#666]">{booking.description}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Join Meeting Button */}
|
||||
{locationProvider?.url && (
|
||||
<TouchableOpacity
|
||||
onPress={handleJoinMeeting}
|
||||
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
|
||||
>
|
||||
{locationProvider.iconUrl && (
|
||||
<SvgImage
|
||||
uri={locationProvider.iconUrl}
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-base font-semibold text-white">
|
||||
Join {locationProvider.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Booking Actions Modal */}
|
||||
<FullScreenModal
|
||||
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>
|
||||
|
||||
{/* Reschedule Modal */}
|
||||
<FullScreenModal
|
||||
visible={showRescheduleModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowRescheduleModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowRescheduleModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 20 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 25,
|
||||
elevation: 24,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<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" />
|
||||
</View>
|
||||
<View className="flex-1 pt-1">
|
||||
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
|
||||
Reschedule request
|
||||
</Text>
|
||||
<Text className="text-sm text-[#6B7280]">
|
||||
Send a reschedule request to the organizer of 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>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
|
||||
<View className="flex-row justify-end gap-2 space-x-2">
|
||||
<TouchableOpacity
|
||||
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
|
||||
onPress={() => {
|
||||
setShowRescheduleModal(false);
|
||||
setRescheduleReason("");
|
||||
}}
|
||||
disabled={rescheduling}
|
||||
>
|
||||
<Text className="text-base font-medium text-[#374151]">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${rescheduling ? "opacity-60" : ""}`}
|
||||
onPress={handleReschedule}
|
||||
disabled={rescheduling}
|
||||
>
|
||||
<Text className="text-base font-medium text-white">Reschedule request</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
// Constants for Event Type Detail screen
|
||||
|
||||
export const bufferTimeOptions = [
|
||||
"No buffer time",
|
||||
"5 Minutes",
|
||||
"10 Minutes",
|
||||
"15 Minutes",
|
||||
"20 Minutes",
|
||||
"30 Minutes",
|
||||
"45 Minutes",
|
||||
"60 Minutes",
|
||||
"90 Minutes",
|
||||
"120 Minutes",
|
||||
];
|
||||
|
||||
export const timeUnitOptions = ["Minutes", "Hours", "Days"];
|
||||
|
||||
export const frequencyUnitOptions = ["Per day", "Per Month", "Per year"];
|
||||
|
||||
export const durationUnitOptions = ["Per day", "Per week", "Per month"];
|
||||
|
||||
export const slotIntervalOptions = [
|
||||
"Default",
|
||||
"5 Minutes",
|
||||
"10 Minutes",
|
||||
"15 Minutes",
|
||||
"20 Minutes",
|
||||
"30 Minutes",
|
||||
"45 Minutes",
|
||||
"60 Minutes",
|
||||
"75 Minutes",
|
||||
"90 Minutes",
|
||||
"105 Minutes",
|
||||
"120 Minutes",
|
||||
];
|
||||
|
||||
export const availableDurations = [
|
||||
"5 mins",
|
||||
"10 mins",
|
||||
"15 mins",
|
||||
"20 mins",
|
||||
"25 mins",
|
||||
"30 mins",
|
||||
"45 mins",
|
||||
"50 mins",
|
||||
"60 mins",
|
||||
"75 mins",
|
||||
"80 mins",
|
||||
"90 mins",
|
||||
"120 mins",
|
||||
"150 mins",
|
||||
"180 mins",
|
||||
"240 mins",
|
||||
"300 mins",
|
||||
"360 mins",
|
||||
"420 mins",
|
||||
"480 mins",
|
||||
];
|
||||
|
||||
export const defaultLocations = [
|
||||
{ label: "Attendee Address", type: "attendeeInPerson" as const },
|
||||
{ label: "Organizer Address", type: "inPerson" as const },
|
||||
{ label: "Link Meeting", type: "link" as const },
|
||||
{ label: "Attendee Phone Number", type: "phone" as const },
|
||||
{ label: "Organizer Phone Number", type: "userPhone" as const },
|
||||
];
|
||||
|
||||
export const tabs = [
|
||||
{ id: "basics", label: "Basics" },
|
||||
{ id: "availability", label: "Availability" },
|
||||
{ id: "limits", label: "Limits" },
|
||||
{ id: "advanced", label: "Advanced" },
|
||||
{ id: "recurring", label: "Recurring" },
|
||||
{ id: "apps", label: "Apps" },
|
||||
{ id: "webhooks", label: "Webhooks" },
|
||||
{ id: "private-links", label: "Private Links" },
|
||||
{ id: "seats", label: "Seats" },
|
||||
{ id: "colors", label: "Colors" },
|
||||
];
|
||||
@@ -0,0 +1,521 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user