mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* feat: add @coss/ui/icons package and migrate static icon usages
Create a new icon system in @coss/ui/icons that wraps lucide-react icons
with controlled props (size and className only). Migrate 44 component
files from <Icon name="xxx" /> to <XxxIcon /> pattern.
This is Phase 1 of the icon migration, covering only static icon names.
Dynamic icon usages (e.g., name={variable}) are skipped for now.
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate more static icons to @coss/ui/icons (batch 2)
Migrated 18 files from <Icon name="xxx" /> to <XxxIcon /> pattern:
- Event meta, insights, shell, user table bulk actions
- Webhooks, blocklist, form-builder, form components
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate more static icons to @coss/ui/icons (batch 3)
Migrated 7 files:
- CancelBooking, AddGuestsDialog, ChargeCardDialog
- ReassignDialog, RescheduleDialog
- Categories, Slider
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate more static icons to @coss/ui/icons (batch 4)
Migrated 6 files:
- Organizations: AboutOrganizationForm, PaymentStatusView, profile
- Workflows: CallDetailsSheet, SkeletonLoaderList, TimeTimeUnitInput
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate more static icons to @coss/ui/icons (batch 5)
Migrated 4 files:
- TwoFactorModalHeader
- App setups: alby, make, btcpayserver
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 6)
- confirmDialog.tsx: clock icon
- ics-feedcalendar/Setup.tsx: trash, plus icons
- hitpay/Setup.tsx: info icon
- paypal/Setup.tsx: circle-alert icons
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 7)
- webhook-edit-view.tsx: external-link icon
- webhook-new-view.tsx: external-link icon
- UserDropdown.tsx: chevron-down, user, settings, moon, map, circle-help, download, blocks, log-out icons
- TopNav.tsx: settings icon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 8)
- EmailInviteForm.tsx: x icon
- InviteOptions.tsx: mail, upload, link icons
- SkeletonLoader.tsx: search, clock, user icons
- Embed.tsx: sun, arrow-left icons
- CalendarSwitch.tsx: arrow-left, rotate-cw icons
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 9)
- apps-view.tsx: search icon
- categories-view.tsx: arrow-left, arrow-right icons
- DatePicker.tsx: calendar icon
- EditLocationDialog.tsx: map-pin icon
- AppPage.tsx: circle-alert, book-open, external-link, mail, file, shield, flag icons
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 10)
- LicenseSelection.tsx: loader, check icons
- AppConnectionItem.tsx: circle-alert, arrow-right icons
- oauth-client-form/index.tsx: info icons (x2)
- auth/error/page.tsx: x icon
- troubleshoot/layout.tsx: loader icon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 11)
- UpgradeTipWrapper.tsx: users, refresh-ccw, user-plus icons
- PbacOptInModal.tsx: shield-check, lock, users, shield icons
- AdvancedPermissionGroup.tsx: chevron-right, info icons
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 12)
- AdminAppsList.tsx: pencil icon
- AvailableTimes.tsx: calendar-x-2 icon
- BookingDetailsSheet.tsx: external-link (x2), repeat icons
- BookingCalendarContainer.tsx: chevron-left, chevron-right icons
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 13)
- videos-single-view.tsx: chevron-right icon
- TroubleshooterSidebar.tsx: arrow-left icon
- RuleBuilder.tsx: filter icon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 14)
- FieldMappingBuilder.tsx: arrow-right, link icons
- IntegrationAttributeSyncCard.tsx: key icon
- onboarding-view.tsx: loader icon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static icons to @coss/ui/icons (batch 15)
- team-profile-view.tsx: copy icon
- DateRangeFilter.tsx: check icon
- ColumnVisibilityButton.tsx: check icon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static Icon to @coss/ui/icons (batch 16)
- EventWorkflowsTab.tsx: lock
- event-types-listing-view.tsx: search
- onboarding-migrate-members-browser-view.tsx: arrow-left, arrow-right, rotate-cw, lock, ellipsis-vertical, check, user-plus
- onboarding-migrate-teams-browser-view.tsx: arrow-left, arrow-right, rotate-cw, lock, ellipsis-vertical, check, arrow-right, users
- EventAvailabilityTab.tsx: globe, user
- onboarding-teams-browser-view.tsx: arrow-left, arrow-right, rotate-cw, lock, ellipsis-vertical, check, arrow-right, users
- FormBuilder.tsx: mail, phone, arrow-up, arrow-down
- AIEventController.tsx: info
- EventTeamAssignmentTab.tsx: x
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static Icon to @coss/ui/icons (batch 17)
- EventAdvancedTab.tsx: pencil, info
- HostLocations.tsx: triangle-alert, loader
- EventTypeLayout.tsx: loader
- Locations.tsx: x, corner-down-right, check
- EditWeightsForAllTeamMembers.tsx: upload, search, info, chevron-down
- WorkflowPage.tsx: pencil
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static Icon to @coss/ui/icons (batch 18)
- TestPhoneCallDialog.tsx: phone
- WorkflowListPage.tsx: link
- UsersTable.tsx: building, lock
- TeamList.tsx: building, paintbrush, chart-line, user-plus, users, pencil
- bookings-single-view.tsx: chevron-left, check, calendar, x, external-link
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate static Icon to @coss/ui/icons (batch 19)
- TeamsListing.tsx: users, refresh-ccw, user-plus, mail, video, eye-off, info
- ConnectionInfo.tsx: clipboard
- IntercomContactForm.tsx: message-circle, loader, send
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: migrate static Icon to @coss/ui/icons in teams components
- EventTypesList.tsx: check → CheckIcon
- MemberInvitationModal.tsx: user, users, building → UserIcon, UsersIcon, BuildingIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: migrate static Icon to @coss/ui/icons in workflow components
- ImportNumberDialog.tsx: info → InfoIcon
- VoiceSelector.tsx: user → UserIcon
- IncomingCallsTab.tsx: info, phone-incoming → InfoIcon, PhoneIncomingIcon
- PhoneNumberTab.tsx: loader, phone → LoaderIcon, PhoneIcon
- VoiceSelectionDialog.tsx: pause, play, user → PauseIcon, PlayIcon, UserIcon
- WebCallDialog.tsx: loader, phone, phone-off, triangle-alert, trash, message-circle → new icons (kept Icon import for dynamic mic icon)
- WorkflowDetailsPage.tsx: zap, arrow-right → ZapIcon, ArrowRightIcon
- AddActionDialog.tsx: info → InfoIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: migrate static Icon to @coss/ui/icons in WorkflowStepContainer
- info → InfoIcon (2 instances)
- phone → PhoneIcon (2 instances)
- circle-help → CircleHelpIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: migrate static Icon to @coss/ui/icons in data-table and org components
- attributes-list-view.tsx: tags → TagsIcon
- AddFilterButton.tsx: plus, eye-off → PlusIcon, EyeOffIcon
- BaseSelectFilterOptions.tsx: check → CheckIcon
- DataTable.tsx: arrow-up, arrow-down, check, chevrons-up-down, eye-off → new icons
- booking-dry-run-success-view.tsx: check → CheckIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: migrate static Icon to @coss/ui/icons in username and routing components
- UsernameTextfield.tsx: check → CheckIcon
- PremiumTextfield.tsx: check, external-link → CheckIcon, ExternalLinkIcon (kept Icon for star with fill prop)
- Header.tsx (routing-forms): pencil, menu, waypoints → PencilIcon, MenuIcon, WaypointsIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate Icon to component icons in routing-forms pages
Batch 25: Migrate static Icon usages to @coss/ui/icons components.
Files changed:
- Forms.tsx: file-text, shuffle, chart-bar, circle-check, mail, download → FileTextIcon, ShuffleIcon, ChartBarIcon, CircleCheckIcon, MailIcon, DownloadIcon
- FormEdit.tsx: chevron-down, menu → ChevronDownIcon, MenuIcon
- IncompleteBooking.tsx: globe → GlobeIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate Icon to component icons in UI package
Batch 26: Migrate static Icon usages to @coss/ui/icons components.
Files changed:
- ProgressToast.tsx: file-down, x → FileDownIcon, XIcon
- showToast.tsx: check, info, x → CheckIcon, InfoIcon, XIcon
- InfoBadge.tsx: info → InfoIcon
- MeetingTimeInTimezones.tsx: globe → GlobeIcon
- InputError.tsx: info → InfoIcon
- ConfirmationDialogContent.tsx: circle-alert, check → CircleAlertIcon, CheckIcon
- CalendarSwitch.tsx: arrow-left, rotate-cw → ArrowLeftIcon, RotateCwIcon
- ArrowButton.tsx: arrow-up, arrow-down → ArrowUpIcon, ArrowDownIcon
- FileUploader.tsx: upload → UploadIcon (kept Icon for dynamic)
- Checkbox.tsx: check → CheckIcon
- EditableHeading.tsx: pencil → PencilIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate Icon to component icons in UI and app-store packages
Batch 27: Migrate static Icon usages to @coss/ui/icons components.
Files changed:
- MultiEmail.tsx: x → XIcon (converted width to size)
- AppListCard.tsx: circle-alert → CircleAlertIcon
- SplitButton.tsx: chevron-down → ChevronDownIcon (kept Icon for dynamic)
- Calendar.tsx: chevron-left, chevron-right → ChevronLeftIcon, ChevronRightIcon
- AppDependencyComponent.tsx: check, circle-x, arrow-right → CheckIcon, CircleXIcon, ArrowRightIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate Icon to component icons in form input components
Batch 28: Migrate static Icon usages to @coss/ui/icons components.
Files changed:
- TextField.tsx: info → InfoIcon (kept Icon for x with onClick)
- Input.tsx: eye, eye-off, search → EyeIcon, EyeOffIcon, SearchIcon
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: use @coss/ui/src/icons in packages with legacy moduleResolution
Packages like packages/ui and packages/app-store use a legacy moduleResolution
setting that doesn't support subpath exports. This changes the import path from
@coss/ui/icons to @coss/ui/src/icons for these packages.
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate Icon to component icons in UI form and editor components
Migrates static Icon usages:
- MultiOptionInput: x (2x), arrow-up, arrow-down
- components (select): check
- AddVariablesDropdown: chevron-down (3x)
- ToolbarPlugin: chevron-down
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* refactor: migrate Icon to component icons in settings and platform atoms
Migrates static Icon usages:
- SettingsLayoutAppDirClient: chevron-down (2x), chevron-right (2x), arrow-left
- platform/atoms dialog: x
- platform/atoms toast: x
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: resolve @coss/ui/icons module resolution for legacy moduleResolution
Add typesVersions to @coss/ui package.json to help TypeScript resolve
subpath exports even with legacy moduleResolution: "node" setting.
Change imports from @coss/ui/src/icons to @coss/ui/icons to avoid
double-src path issue when Next.js applies import maps.
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: localize 'Select field type' fallback label in FormEdit
Address Cubic AI review feedback (confidence 9/10) by using t() for
the hardcoded 'Select field type' string to ensure proper i18n support.
Co-Authored-By: unknown <>
* fix: add typesVersions wildcards for @coss/ui subpath imports
Add lib/*, components/*, and hooks/* to typesVersions to support
legacy moduleResolution with all package subpath patterns.
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: resolve missing Icon references in settings and workflows
- Add Icon import for dynamic icon usages in SettingsLayoutAppDirClient
- Replace static Icon usages with component icons (InfoIcon, PhoneIcon)
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: resolve icon component type errors
- Add onClick prop support to IconProps
- Fix size prop type (use number instead of string)
- Rename local MailOpenIcon to MailOpenIconWrapper to avoid shadowing
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* fix: add data-testid to icon components for test compatibility
- Add testId parameter to createIcon function
- Auto-generate data-testid for all icons (e.g., "check-icon", "search-icon")
- Remove outdated mock for @calcom/ui/components/icon in tests
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* Update package.json
* Update package.json
* fix: migrate Icon to CalendarDaysIcon in videos-single-view.tsx
Co-Authored-By: unknown <>
---------
Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
607 lines
19 KiB
TypeScript
607 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import dayjs from "@calcom/dayjs";
|
||
import {
|
||
RECORDING_DEFAULT_ICON,
|
||
TRANSCRIPTION_STOPPED_ICON,
|
||
WEBAPP_URL,
|
||
WEBSITE_URL,
|
||
} from "@calcom/lib/constants";
|
||
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/dayjs";
|
||
import { emailRegex } from "@calcom/lib/emailSchema";
|
||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||
import classNames from "@calcom/ui/classNames";
|
||
import { Badge } from "@calcom/ui/components/badge";
|
||
import { Button } from "@calcom/ui/components/button";
|
||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||
import { Input } from "@calcom/ui/components/form";
|
||
import { CalendarDaysIcon, ChevronRightIcon } from "@coss/ui/icons";
|
||
import type { DailyCall } from "@daily-co/daily-js";
|
||
import DailyIframe from "@daily-co/daily-js";
|
||
import { DailyProvider, useDailyEvent } from "@daily-co/daily-react";
|
||
import type { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { CalVideoPremiumFeatures } from "../cal-video-premium-features";
|
||
|
||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||
|
||
export default function JoinCall(props: PageProps) {
|
||
const { t } = useLocale();
|
||
const {
|
||
meetingUrl,
|
||
meetingPassword,
|
||
booking,
|
||
hasTeamPlan,
|
||
calVideoLogo,
|
||
loggedInUserName,
|
||
overrideName,
|
||
showRecordingButton,
|
||
enableAutomaticTranscription,
|
||
enableAutomaticRecordingForOrganizer,
|
||
showTranscriptionButton,
|
||
rediectAttendeeToOnExit,
|
||
requireEmailForGuests,
|
||
isLoggedInUserPartOfMeeting,
|
||
} = props;
|
||
const [daily, setDaily] = useState<DailyCall | null>(null);
|
||
const [guestCredentials, setGuestCredentials] = useState<{
|
||
meetingPassword: string;
|
||
meetingUrl: string;
|
||
userName: string;
|
||
} | null>(null);
|
||
|
||
const userNameForCall = overrideName ?? loggedInUserName ?? undefined;
|
||
const hideLoginModal =
|
||
!!userNameForCall && (requireEmailForGuests ? !!loggedInUserName && isLoggedInUserPartOfMeeting : true);
|
||
const [isCallFrameReady, setIsCallFrameReady] = useState<boolean>(false);
|
||
|
||
const activeMeetingPassword = guestCredentials?.meetingPassword ?? meetingPassword;
|
||
const activeMeetingUrl = guestCredentials?.meetingUrl ?? meetingUrl;
|
||
const activeUserName = guestCredentials?.userName ?? userNameForCall;
|
||
|
||
const createCallFrame = useCallback(
|
||
(userName?: string, password?: string, url?: string) => {
|
||
let callFrame: DailyCall | undefined;
|
||
|
||
try {
|
||
callFrame = DailyIframe.createFrame({
|
||
theme: {
|
||
colors: {
|
||
accent: "#292929",
|
||
accentText: "#FFF",
|
||
background: "#111111",
|
||
backgroundAccent: "#111111",
|
||
baseText: "#FFF",
|
||
border: "#292929",
|
||
mainAreaBg: "#111111",
|
||
mainAreaBgAccent: "#1A1A1A",
|
||
mainAreaText: "#FFF",
|
||
supportiveText: "#FFF",
|
||
},
|
||
},
|
||
showLeaveButton: true,
|
||
iframeStyle: {
|
||
position: "fixed",
|
||
width: "100%",
|
||
height: "100%",
|
||
},
|
||
url: url ?? meetingUrl,
|
||
userName: userName,
|
||
...(typeof (password ?? meetingPassword) === "string" && { token: password ?? meetingPassword }),
|
||
...(hasTeamPlan && {
|
||
customTrayButtons: {
|
||
...(showRecordingButton
|
||
? {
|
||
recording: {
|
||
label: t("record"),
|
||
tooltip: t("start_or_stop_recording"),
|
||
iconPath: RECORDING_DEFAULT_ICON,
|
||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||
},
|
||
}
|
||
: {}),
|
||
...(showTranscriptionButton
|
||
? {
|
||
transcription: {
|
||
label: t("transcribe"),
|
||
tooltip: t("transcription_powered_by_ai"),
|
||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||
},
|
||
}
|
||
: {}),
|
||
},
|
||
}),
|
||
});
|
||
|
||
if (userName) {
|
||
callFrame.setUserName(userName);
|
||
}
|
||
|
||
return callFrame;
|
||
} catch (_err) {
|
||
return DailyIframe.getCallInstance();
|
||
}
|
||
},
|
||
[meetingUrl, meetingPassword, hasTeamPlan, showRecordingButton, showTranscriptionButton, t]
|
||
);
|
||
useEffect(() => {
|
||
if (!hideLoginModal && !guestCredentials) {
|
||
return;
|
||
}
|
||
|
||
let callFrame: DailyCall | null = null;
|
||
|
||
try {
|
||
callFrame = createCallFrame(activeUserName, activeMeetingPassword, activeMeetingUrl) ?? null;
|
||
setDaily(callFrame);
|
||
setIsCallFrameReady(true);
|
||
|
||
callFrame?.join();
|
||
} catch (error) {
|
||
console.error("Failed to create or join call:", error);
|
||
}
|
||
|
||
return () => {
|
||
if (callFrame) {
|
||
try {
|
||
callFrame.destroy();
|
||
} catch (error) {
|
||
console.error("Error destroying call frame:", error);
|
||
}
|
||
}
|
||
setDaily(null);
|
||
setIsCallFrameReady(false);
|
||
};
|
||
}, [
|
||
hideLoginModal,
|
||
activeUserName,
|
||
activeMeetingPassword,
|
||
activeMeetingUrl,
|
||
createCallFrame,
|
||
guestCredentials,
|
||
]);
|
||
|
||
return (
|
||
<DailyProvider callObject={daily}>
|
||
{isCallFrameReady && (
|
||
<div
|
||
className="mx-auto hidden sm:block"
|
||
style={{ zIndex: 2, left: "30%", position: "absolute", bottom: 100, width: "auto" }}>
|
||
<CalVideoPremiumFeatures
|
||
showRecordingButton={showRecordingButton}
|
||
enableAutomaticRecordingForOrganizer={enableAutomaticRecordingForOrganizer}
|
||
enableAutomaticTranscription={enableAutomaticTranscription}
|
||
showTranscriptionButton={showTranscriptionButton}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div style={{ zIndex: 2, position: "relative" }}>
|
||
{calVideoLogo ? (
|
||
<img
|
||
className="fixed z-10 hidden aspect-square h-16 min-h-16 w-16 min-w-16 rounded-full sm:inline-block"
|
||
src={calVideoLogo}
|
||
alt="My Org Logo"
|
||
style={{
|
||
top: 32,
|
||
left: 32,
|
||
}}
|
||
/>
|
||
) : (
|
||
<img
|
||
className="fixed z-10 hidden h-5 sm:inline-block"
|
||
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
|
||
alt="Logo"
|
||
style={{
|
||
top: 47,
|
||
left: 20,
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
{!hideLoginModal && (
|
||
<LogInOverlay
|
||
isOpen={!hideLoginModal}
|
||
bookingUid={booking.uid}
|
||
bookingTitle={booking.title}
|
||
hostName={booking.user?.name ?? ""}
|
||
loggedInUserName={loggedInUserName ?? undefined}
|
||
overrideName={overrideName}
|
||
requireEmailForGuests={requireEmailForGuests}
|
||
onGuestCredentialsReceived={setGuestCredentials}
|
||
meetingPassword={meetingPassword}
|
||
meetingUrl={meetingUrl}
|
||
/>
|
||
)}
|
||
|
||
<VideoMeetingInfo booking={booking} rediectAttendeeToOnExit={rediectAttendeeToOnExit} />
|
||
</DailyProvider>
|
||
);
|
||
}
|
||
|
||
interface ProgressBarProps {
|
||
startTime: string;
|
||
endTime: string;
|
||
}
|
||
|
||
function ProgressBar(props: ProgressBarProps) {
|
||
const { t } = useLocale();
|
||
const { startTime, endTime } = props;
|
||
const currentTime = dayjs().second(0).millisecond(0);
|
||
const startingTime = dayjs(startTime).second(0).millisecond(0);
|
||
const isPast = currentTime.isAfter(startingTime);
|
||
const currentDifference = dayjs().diff(startingTime, "minutes");
|
||
const startDuration = dayjs(endTime).diff(startingTime, "minutes");
|
||
const [duration, setDuration] = useState(() => {
|
||
if (currentDifference >= 0 && isPast) {
|
||
return startDuration - currentDifference;
|
||
} else {
|
||
return startDuration;
|
||
}
|
||
});
|
||
|
||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
useEffect(() => {
|
||
const now = dayjs();
|
||
const remainingMilliseconds = (60 - now.get("seconds")) * 1000 - now.get("milliseconds");
|
||
|
||
timeoutRef.current = setTimeout(() => {
|
||
const past = dayjs().isAfter(startingTime);
|
||
|
||
if (past) {
|
||
setDuration((prev) => prev - 1);
|
||
}
|
||
|
||
intervalRef.current = setInterval(() => {
|
||
if (dayjs().isAfter(startingTime)) {
|
||
setDuration((prev) => prev - 1);
|
||
}
|
||
}, 60000);
|
||
}, remainingMilliseconds);
|
||
|
||
return () => {
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current);
|
||
timeoutRef.current = null;
|
||
}
|
||
if (intervalRef.current) {
|
||
clearInterval(intervalRef.current);
|
||
intervalRef.current = null;
|
||
}
|
||
};
|
||
}, [startingTime]);
|
||
|
||
const prev = startDuration - duration;
|
||
const percentage = prev * (100 / startDuration);
|
||
return (
|
||
<div>
|
||
<p>
|
||
{duration} {t("minutes")}
|
||
</p>
|
||
<div className="relative h-2 max-w-xl overflow-hidden rounded-full">
|
||
<div className="absolute h-full w-full bg-gray-500/10" />
|
||
<div className={classNames("relative h-full bg-green-500")} style={{ width: `${percentage}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface LogInOverlayProps {
|
||
isOpen: boolean;
|
||
bookingUid: string;
|
||
bookingTitle: string;
|
||
hostName: string;
|
||
loggedInUserName?: string;
|
||
overrideName?: string;
|
||
requireEmailForGuests?: boolean;
|
||
onGuestCredentialsReceived: (credentials: {
|
||
meetingPassword: string;
|
||
meetingUrl: string;
|
||
userName: string;
|
||
}) => void;
|
||
meetingPassword?: string;
|
||
meetingUrl: string;
|
||
}
|
||
|
||
export function LogInOverlay(props: LogInOverlayProps) {
|
||
const { t } = useLocale();
|
||
const {
|
||
bookingUid,
|
||
bookingTitle,
|
||
hostName,
|
||
isOpen: _open,
|
||
loggedInUserName,
|
||
overrideName,
|
||
requireEmailForGuests = false,
|
||
onGuestCredentialsReceived,
|
||
meetingPassword,
|
||
meetingUrl,
|
||
} = props;
|
||
|
||
const [isOpen, setIsOpen] = useState(_open);
|
||
const [userName, setUserName] = useState(overrideName ?? loggedInUserName ?? "");
|
||
const [userEmail, setUserEmail] = useState("");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const handleContinueAsGuest = useCallback(async () => {
|
||
const trimmedName = userName.trim();
|
||
const trimmedEmail = userEmail.trim();
|
||
|
||
if (!trimmedName) {
|
||
setError(t("please_enter_name"));
|
||
return;
|
||
}
|
||
|
||
if (requireEmailForGuests) {
|
||
if (!trimmedEmail) {
|
||
setError(t("please_enter_name_and_email"));
|
||
return;
|
||
}
|
||
|
||
if (!emailRegex.test(trimmedEmail)) {
|
||
setError(t("invalid_email_address"));
|
||
return;
|
||
}
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// Only create guest session if email is required and provided
|
||
if (requireEmailForGuests && trimmedEmail) {
|
||
const csrfResponse = await fetch("/api/csrf", { cache: "no-store" });
|
||
const { csrfToken } = await csrfResponse.json();
|
||
|
||
const response = await fetch("/api/video/guest-session", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
bookingUid,
|
||
email: trimmedEmail,
|
||
name: trimmedName,
|
||
csrfToken,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
const errorKey = errorData.error;
|
||
throw new Error(errorKey || "Failed to create guest session");
|
||
}
|
||
|
||
const { meetingPassword, meetingUrl } = await response.json();
|
||
|
||
onGuestCredentialsReceived({
|
||
meetingPassword,
|
||
meetingUrl,
|
||
userName: trimmedName,
|
||
});
|
||
|
||
setIsOpen(false);
|
||
} else {
|
||
// If email not required, use existing credentials from SSR props
|
||
if (!meetingPassword) {
|
||
throw new Error("Meeting password not available");
|
||
}
|
||
|
||
onGuestCredentialsReceived({
|
||
meetingPassword,
|
||
meetingUrl,
|
||
userName: trimmedName,
|
||
});
|
||
|
||
setIsOpen(false);
|
||
}
|
||
} catch (error) {
|
||
const errorKey = error instanceof Error ? error.message : "failed_to_join_call";
|
||
const errorMessage = t(errorKey) || errorKey;
|
||
setError(errorMessage);
|
||
setIsLoading(false);
|
||
}
|
||
}, [
|
||
userName,
|
||
userEmail,
|
||
bookingUid,
|
||
requireEmailForGuests,
|
||
t,
|
||
onGuestCredentialsReceived,
|
||
meetingPassword,
|
||
meetingUrl,
|
||
]);
|
||
|
||
const handleKeyDown = useCallback(
|
||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === "Enter" && userName.trim() && !isLoading) {
|
||
handleContinueAsGuest();
|
||
}
|
||
},
|
||
[userName, isLoading, handleContinueAsGuest]
|
||
);
|
||
|
||
const handleUserNameChange = useCallback(
|
||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setUserName(e.target.value);
|
||
if (error) {
|
||
setError(null);
|
||
}
|
||
},
|
||
[error]
|
||
);
|
||
|
||
const handleEmailChange = useCallback(
|
||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setUserEmail(e.target.value);
|
||
if (error) {
|
||
setError(null);
|
||
}
|
||
},
|
||
[error]
|
||
);
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||
<DialogContent className="p-6 sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
|
||
<div className="flex flex-col gap-5">
|
||
<div className="flex items-center justify-between rounded-lg border border-subtle p-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-subtle">
|
||
<CalendarDaysIcon className="h-5 w-5 text-default" />
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-emphasis text-sm">{bookingTitle}</p>
|
||
<p className="text-subtle text-xs">{t("hosted_by", { name: hostName })}</p>
|
||
</div>
|
||
</div>
|
||
<Badge variant="success" withDot>
|
||
{t("ready")}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="font-bold text-emphasis text-lg">{t("ready_to_join")}</h3>
|
||
<p className="text-sm text-subtle">{t("enter_name_to_join_call")}</p>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3">
|
||
<Input
|
||
type="text"
|
||
placeholder={t("your_name")}
|
||
className="w-full"
|
||
value={userName}
|
||
onChange={handleUserNameChange}
|
||
onKeyDown={handleKeyDown}
|
||
disabled={isLoading}
|
||
autoFocus
|
||
/>
|
||
|
||
{requireEmailForGuests && (
|
||
<Input
|
||
type="email"
|
||
placeholder={t("email_address")}
|
||
className="w-full"
|
||
value={userEmail}
|
||
onChange={handleEmailChange}
|
||
onKeyDown={handleKeyDown}
|
||
disabled={isLoading}
|
||
/>
|
||
)}
|
||
|
||
<Button
|
||
color="primary"
|
||
className="w-full justify-center"
|
||
onClick={handleContinueAsGuest}
|
||
loading={isLoading}>
|
||
{t("join_call_as_guest")}
|
||
</Button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="rounded-md bg-error p-3">
|
||
<p className="font-medium text-error text-sm">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-center text-sm text-subtle">
|
||
<a
|
||
href={`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL}/video/${bookingUid}`}
|
||
className="text-emphasis underline">
|
||
{t("sign_in")}
|
||
</a>{" "}
|
||
{t("to_track_no_shows")}
|
||
</p>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
interface VideoMeetingInfo {
|
||
booking: PageProps["booking"];
|
||
rediectAttendeeToOnExit?: string | null;
|
||
}
|
||
|
||
export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||
const [open, setOpen] = useState(false);
|
||
const { booking, rediectAttendeeToOnExit } = props;
|
||
const { t } = useLocale();
|
||
|
||
const endTime = new Date(booking.endTime);
|
||
const startTime = new Date(booking.startTime);
|
||
const timeZone = booking.user?.timeZone;
|
||
|
||
useDailyEvent("left-meeting", () => {
|
||
if (rediectAttendeeToOnExit) {
|
||
window.location.href = rediectAttendeeToOnExit;
|
||
}
|
||
});
|
||
|
||
return (
|
||
<aside
|
||
className={classNames(
|
||
"no-scrollbar fixed top-0 left-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 scroll-bar scrollbar-track-w-20 overflow-x-hidden! w-full max-w-64 overflow-scroll border-subtle border-r bg-default p-4 prose-h3:font-cal prose-a:text-emphasis prose-h3:text-emphasis text-emphasis shadow-sm backdrop-blur-lg">
|
||
<h3>{t("what")}:</h3>
|
||
<p>{booking.title}</p>
|
||
<h3>{t("invitee_timezone")}:</h3>
|
||
<p>{timeZone}</p>
|
||
<h3>{t("when")}:</h3>
|
||
<p suppressHydrationWarning={true}>
|
||
{formatToLocalizedDate(startTime)} <br />
|
||
{formatToLocalizedTime({ date: startTime, timeZone })}
|
||
</p>
|
||
<h3>{t("time_left")}</h3>
|
||
<ProgressBar key={String(open)} endTime={endTime.toISOString()} startTime={startTime.toISOString()} />
|
||
|
||
<h3>{t("who")}:</h3>
|
||
<p>
|
||
{booking?.user?.name} - {t("organizer")}
|
||
{!booking?.eventType?.hideOrganizerEmail && (
|
||
<>
|
||
: <a href={`mailto:${booking?.user?.email}`}>{booking?.user?.email}</a>
|
||
</>
|
||
)}
|
||
</p>
|
||
|
||
{booking.attendees.length
|
||
? booking.attendees.map((attendee) => (
|
||
<p key={attendee.id}>
|
||
{attendee.name} – <a href={`mailto:${attendee.email}`}>{attendee.email}</a>
|
||
</p>
|
||
))
|
||
: null}
|
||
|
||
{booking.description && (
|
||
<>
|
||
<h3>{t("description")}:</h3>
|
||
|
||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized via markdownToSafeHTML */}
|
||
<div
|
||
className="prose-sm prose prose-invert"
|
||
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(booking.description) }}
|
||
/>
|
||
</>
|
||
)}
|
||
</main>
|
||
<div className="flex items-center justify-center">
|
||
<button
|
||
aria-label={`${open ? "close" : "open"} booking description sidebar`}
|
||
className="h-20 w-6 rounded-r-md border border-gray-300/20 border-l-0 bg-black/60 text-white shadow-sm backdrop-blur-lg"
|
||
onClick={() => setOpen(!open)}>
|
||
<ChevronRightIcon
|
||
aria-hidden
|
||
className={classNames(open && "rotate-180", "w-5 transition-all duration-300 ease-in-out")}
|
||
/>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|