Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3f676e57 | ||
|
|
3e17b1c5ef | ||
|
|
a466e71d4c | ||
|
|
5a7030d277 | ||
|
|
e72c25f02e | ||
|
|
06bc5facf3 | ||
|
|
9c8dc1b92c | ||
|
|
cfef92a7bc | ||
|
|
26149e2b04 | ||
|
|
3ca43ce3f3 | ||
|
|
7603ea6184 | ||
|
|
9c45da565e | ||
|
|
f84f1f5dda | ||
|
|
07f803e758 | ||
|
|
268ab882cd | ||
|
|
8052e6f6d4 | ||
|
|
99e9425257 | ||
|
|
471b2687c4 | ||
|
|
9f7152c4eb |
@@ -112,7 +112,7 @@ export const InstallAppButtonChild = ({
|
||||
);
|
||||
}}>
|
||||
<p>
|
||||
{team.name} {isInstalled && `(${t("installed")})`}
|
||||
{t(team.name)} {isInstalled && `(${t("installed")})`}
|
||||
</p>
|
||||
</DropdownItem>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.1.9",
|
||||
"version": "3.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
@@ -74,7 +74,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||
<DateOverrideInputDialog
|
||||
workingHours={workingHours}
|
||||
excludedDates={excludedDates}
|
||||
onChange={(ranges) => append({ ranges })}
|
||||
onChange={(ranges) => ranges.forEach((range) => append({ ranges: [range] }))}
|
||||
Trigger={
|
||||
<Button color="secondary" StartIcon={Plus} data-testid="add-override">
|
||||
{t("add_an_override")}
|
||||
|
||||
@@ -314,7 +314,7 @@ export default function Success(props: SuccessProps) {
|
||||
<Link
|
||||
href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
|
||||
className="hover:bg-subtle text-subtle hover:text-default mt-2 inline-flex px-1 py-2 text-sm dark:hover:bg-transparent">
|
||||
<ChevronLeft className="h-5 w-5" /> {t("back_to_bookings")}
|
||||
<ChevronLeft className="h-5 w-5 rtl:rotate-180" /> {t("back_to_bookings")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -394,7 +394,7 @@ export default function Success(props: SuccessProps) {
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left">
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
@@ -715,7 +715,7 @@ export default function Success(props: SuccessProps) {
|
||||
</div>
|
||||
{isGmail && (
|
||||
<Alert
|
||||
className="main -mb-20 mt-4 inline-block text-left sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
className="main -mb-20 mt-4 inline-block ltr:text-right rtl:text-right sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
severity="warning"
|
||||
message={
|
||||
<div>
|
||||
|
||||
@@ -1720,7 +1720,7 @@
|
||||
"identifier": "Identifier",
|
||||
"duplicate_email": "Email is duplicate",
|
||||
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment is on its way.",
|
||||
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
|
||||
"booking_confirmation_failed": "Booking confirmation failed",
|
||||
"not_enough_seats": "Not enough seats",
|
||||
@@ -1903,6 +1903,7 @@
|
||||
"first_event_type_webhook_description": "Create your first webhook for this event type",
|
||||
"install_app_on": "Install app on",
|
||||
"create_for": "Create for",
|
||||
"currency": "Currency",
|
||||
"setup_organization": "Setup an Organization",
|
||||
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
|
||||
"organization_banner_title": "Manage organizations with multiple teams",
|
||||
@@ -1996,6 +1997,7 @@
|
||||
"attendee_last_name_variable": "Attendee last name",
|
||||
"attendee_first_name_info": "The person booking's first name",
|
||||
"attendee_last_name_info": "The person booking's last name",
|
||||
"me": "Me",
|
||||
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1901,6 +1901,7 @@
|
||||
"first_event_type_webhook_description": "Créez votre premier webhook pour ce type d'événement",
|
||||
"install_app_on": "Installer l’application sur",
|
||||
"create_for": "Créer pour",
|
||||
"currency": "Devise",
|
||||
"setup_organization": "Configurer une organisation",
|
||||
"organization_banner_description": "Créez un environnement où vos équipes peuvent créer des applications partagées, des workflows et des types d'événements avec une planification round-robin et collective.",
|
||||
"organization_banner_title": "Gérer les organisations avec plusieurs équipes",
|
||||
@@ -1990,6 +1991,7 @@
|
||||
"no_other_teams_found_description": "Il n'y a pas d'autres équipes dans cette organisation.",
|
||||
"attendee_first_name_variable": "Prénom du participant",
|
||||
"attendee_last_name_variable": "Nom du participant",
|
||||
"me": "Moi",
|
||||
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
"verify_email_banner_body": "Verifique seu endereço de e-mail para garantir o fornecimento do melhor e-mail e calendário",
|
||||
"verify_email_email_header": "Verifique seu endereço de e-mail",
|
||||
"verify_email_email_button": "Verificar e-mail",
|
||||
"copy_somewhere_safe": "Salve esta chave de API em um lugar seguro. Você não será capaz de vê-la novamente.",
|
||||
"verify_email_email_body": "Por favor, verifique seu endereço de e-mail clicando no botão abaixo.",
|
||||
"verify_email_by_code_email_body": "Por favor, verifique seu endereço de e-mail usando o código abaixo.",
|
||||
"verify_email_email_link_text": "Aqui está o link caso você não goste de usar botões:",
|
||||
"email_verification_code": "Insira o código de verificação",
|
||||
"email_verification_code_placeholder": "Insira o código de verificação enviado para seu e-mail",
|
||||
"incorrect_email_verification_code": "O código de verificação está incorreto.",
|
||||
"email_sent": "E-mail enviado com sucesso",
|
||||
"email_not_sent": "Ocorreu um erro ao enviar o e-mail",
|
||||
"event_declined_subject": "Recusado: {{title}} em {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
|
||||
"event_request_declined": "Sua solicitação de reunião foi recusada",
|
||||
@@ -78,6 +84,7 @@
|
||||
"event_awaiting_approval_recurring": "Um evento recorrente aguarda a sua aprovação",
|
||||
"someone_requested_an_event": "Alguém solicitou o agendamento de um evento em sua agenda.",
|
||||
"someone_requested_password_reset": "Alguém solicitou um link para alterar sua senha.",
|
||||
"password_reset_email_sent": "Se estee-mail existir em nosso sistema, você receberá um e-mail de redefinição.",
|
||||
"password_reset_instructions": "Se você não fez essa solicitação, você pode ignorar este e-mail e sua senha não será alterada.",
|
||||
"event_awaiting_approval_subject": "Aguardando aprovação: {{title}} em {{date}}",
|
||||
"event_still_awaiting_approval": "Um evento está aguardando a sua aprovação",
|
||||
@@ -217,6 +224,9 @@
|
||||
"already_have_an_account": "Já tem uma conta?",
|
||||
"create_account": "Criar Conta",
|
||||
"confirm_password": "Confirmar senha",
|
||||
"confirm_auth_change": "Isso alterará a forma como você faz login",
|
||||
"reset_your_password": "Defina sua nova senha com as instruções enviadas para seu endereço de e-mail.",
|
||||
"email_change": "Volte a entrar com seu novo endereço de e-mail e senha.",
|
||||
"create_your_account": "Criar sua conta",
|
||||
"sign_up": "Cadastro",
|
||||
"youve_been_logged_out": "A sessão foi encerrada",
|
||||
@@ -243,6 +253,9 @@
|
||||
"all": "Todos os apps",
|
||||
"yours": "Sua conta",
|
||||
"available_apps": "Aplicativos disponíveis",
|
||||
"available_apps_lower_case": "Aplicativos disponíveis",
|
||||
"available_apps_desc": "Você não tem nenhum aplicativo instalado. Veja apps populares abaixo e explore mais em nossa <1>App Store</1>",
|
||||
"fixed_host_helper": "Adicione qualquer pessoa que precise participar do evento. <1>Saiba mais</1>",
|
||||
"check_email_reset_password": "Verifique o seu email. Enviamos um link para redefinir a sua senha.",
|
||||
"finish": "Finalizar",
|
||||
"organization_general_description": "Gerencie as configurações para o idioma e o fuso horário da sua equipe",
|
||||
@@ -406,6 +419,7 @@
|
||||
"booking_requested": "Reserva solicitada",
|
||||
"meeting_ended": "Reunião encerrada",
|
||||
"form_submitted": "Formulário enviado",
|
||||
"booking_paid": "Reserva paga",
|
||||
"event_triggers": "Gatilhos de eventos",
|
||||
"subscriber_url": "URL do assinante",
|
||||
"create_new_webhook": "Criar um novo webhook",
|
||||
@@ -506,6 +520,7 @@
|
||||
"your_name": "Seu nome",
|
||||
"your_full_name": "Seu nome completo",
|
||||
"no_name": "Sem nome",
|
||||
"enter_number_between_range": "Por favor, insira um número entre 1 e {{maxOccurences}}",
|
||||
"email_address": "Endereço de e-mail",
|
||||
"enter_valid_email": "Insira um e-mail válido",
|
||||
"location": "Local",
|
||||
@@ -842,6 +857,7 @@
|
||||
"team_view_user_availability_disabled": "O usuário precisa aceitar o convite para ver a disponibilidade",
|
||||
"set_as_away": "Definir-se como indisponível",
|
||||
"set_as_free": "Desativar status indisponível",
|
||||
"toggle_away_error": "Erro ao atualizar status de ausência",
|
||||
"user_away": "Este usuário está indisponível no momento.",
|
||||
"user_away_description": "A pessoa que você está tentando realizar uma reserva está indisponível no momento e, portanto, não está aceitando novas reservas.",
|
||||
"meet_people_with_the_same_tokens": "Conheça pessoas com os mesmos tokens",
|
||||
@@ -1865,6 +1881,9 @@
|
||||
"open_dialog_with_element_click": "Abra uma caixa de diálogo quando alguém clicar em um elemento.",
|
||||
"need_help_embedding": "Precisa de ajuda? Confira nosso guia para incorporar o Cal no Wix, Squarespace ou WordPress. Consulte as perguntas frequentes ou explore opções de incorporação avançadas.",
|
||||
"book_my_cal": "Reservar meu Cal",
|
||||
"first_name": "Primeiro nome",
|
||||
"last_name": "Sobrenome",
|
||||
"first_last_name": "Primeiro nome, Sobrenome",
|
||||
"invite_as": "Convidar como",
|
||||
"form_updated_successfully": "Formulário atualizado com êxito.",
|
||||
"disable_attendees_confirmation_emails": "Desative os e-mails de confirmação padrão para participantes",
|
||||
@@ -1878,6 +1897,7 @@
|
||||
"first_event_type_webhook_description": "Crie seu primeiro webhook para este tipo de evento",
|
||||
"install_app_on": "Instalar aplicativo em",
|
||||
"create_for": "Criar para",
|
||||
"currency": "Moeda",
|
||||
"setup_organization": "Definir uma Organização",
|
||||
"organization_banner_description": "Crie um ambiente onde suas equipes podem criar tipos de evento, fluxos de trabalho e aplicativos compartilhados com agendamento coletivo e round robin.",
|
||||
"organization_banner_title": "Gerenciar organizações com múltiplas equipes",
|
||||
@@ -1940,5 +1960,18 @@
|
||||
"insights_user_filter": "Usuário: {{userName}}",
|
||||
"insights_subtitle": "Veja insights de reserva em seus eventos",
|
||||
"custom_plan": "Plano personalizado",
|
||||
"select_time": "Selecione o horário",
|
||||
"select_date": "Selecione a data",
|
||||
"see_all_available_times": "Veja todos os horários disponíveis",
|
||||
"org_team_names_example": "ex.: Time de Marketing",
|
||||
"org_team_names_example_1": "ex.: Time de Marketing",
|
||||
"org_team_names_example_2": "ex: Time de Vendas",
|
||||
"org_team_names_example_3": "ex: Time de Design",
|
||||
"org_team_names_example_4": "ex: Time de Engenharia",
|
||||
"org_team_names_example_5": "ex: Time de Análise de Dados",
|
||||
"what_is_this_meeting_about": "De que se trata esta reunião?",
|
||||
"requires_booker_email_verification": "Requer verificação de e-mail do reservante",
|
||||
"verify_team_or_account": "Verificar {{teamOrAccount}}",
|
||||
"verify_account": "Verificar Conta",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adicione suas novas strings aqui em cima ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"team_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Team. Для работы с командой «{{teamName}}» необходимо изменить тариф.",
|
||||
"upgrade_banner_action": "Изменить",
|
||||
"team_upgraded_successfully": "Вы успешно перешли на новый тариф для своей команды!",
|
||||
"org_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Organization. Для работы с «{{teamName}}» нужно перейти на тариф Organization.",
|
||||
"org_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Organization. Для работы «{{teamName}}» нужно перейти на тариф Organization.",
|
||||
"org_upgraded_successfully": "Вы успешно перешли на тариф Organization!",
|
||||
"use_link_to_reset_password": "Используйте ссылку ниже, чтобы сбросить пароль",
|
||||
"hey_there": "Привет,",
|
||||
@@ -307,15 +307,15 @@
|
||||
"layout": "Макет",
|
||||
"bookerlayout_default_title": "Представление по умолчанию",
|
||||
"bookerlayout_description": "Вы можете выбрать несколько представлений; пользователи, оформляющие бронирование, могут переключать представления.",
|
||||
"bookerlayout_user_settings_title": "Макет забронированного события",
|
||||
"bookerlayout_user_settings_title": "Представление забронированного события",
|
||||
"bookerlayout_user_settings_description": "Вы можете выбрать несколько представлений; пользователи, оформляющие бронирование, могут переключать представления. Эту настройку можно переопределять для каждого события.",
|
||||
"bookerlayout_month_view": "Месяц",
|
||||
"bookerlayout_week_view": "Еженедельно",
|
||||
"bookerlayout_week_view": "Неделя",
|
||||
"bookerlayout_column_view": "Столбец",
|
||||
"bookerlayout_error_min_one_enabled": "Нужно выбрать по крайней мере один макет.",
|
||||
"bookerlayout_error_default_not_enabled": "Макет, который вы выбрали как представление по умолчанию, не входит в число доступных макетов.",
|
||||
"bookerlayout_error_unknown_layout": "Вы выбрали недопустимый макет.",
|
||||
"bookerlayout_override_global_settings": "Эту настройку можно указать для всех типов событий в <2>настройках / оформление</2> или <6>переопределить только для этого события</6>.",
|
||||
"bookerlayout_override_global_settings": "Эту настройку можно указать для всех типов событий в <2>настройках / оформление</2> или <6>указать только для этого события</6>.",
|
||||
"unexpected_error_try_again": "Произошла непредвиденная ошибка. Попробуйте еще раз.",
|
||||
"sunday_time_error": "Недопустимое время в воскресенье",
|
||||
"monday_time_error": "Недопустимое время в понедельник",
|
||||
@@ -551,11 +551,11 @@
|
||||
"team_description": "Краткое описание вашей команды. Этот текст будет показываться на странице вашей команды.",
|
||||
"org_description": "Краткое описание вашей организации. Этот текст будет виден на странице вашей организации.",
|
||||
"members": "Участники",
|
||||
"organization_members": "Сотрудники организации",
|
||||
"organization_members": "Участники, подключенные к тарифному плану Organization",
|
||||
"member": "Участник",
|
||||
"number_member_one": "Участники: {{count}}",
|
||||
"number_member_other": "Участники: {{count}}",
|
||||
"number_selected": "Выбрано: {{count}}",
|
||||
"number_selected": "выбрано: {{count}}",
|
||||
"owner": "Владелец",
|
||||
"admin": "Админ",
|
||||
"administrator_user": "Администратор",
|
||||
@@ -1151,7 +1151,7 @@
|
||||
"custom": "Пользовательский",
|
||||
"reminder": "Напоминание",
|
||||
"rescheduled": "Перенесено",
|
||||
"completed": "Выполнено",
|
||||
"completed": "Завершено",
|
||||
"reminder_email": "Напоминание: {{eventType}} с {{name}} {{date}}",
|
||||
"not_triggering_existing_bookings": "Не работает для уже созданных бронирований, так как при бронировании события пользователь должен будет ввести номер телефона.",
|
||||
"minute_one": "{{count}} минута",
|
||||
@@ -1327,7 +1327,7 @@
|
||||
"download_responses_description": "Скачать все ответы пользователей, заполнивших форму, в формате CSV.",
|
||||
"download": "Скачать",
|
||||
"download_recording": "Скачать запись",
|
||||
"recording_from_your_recent_call": "Запись вашего недавнего созвана в {{appName}} готова для скачивания",
|
||||
"recording_from_your_recent_call": "Запись вашего недавнего созвона в {{appName}} готова для скачивания",
|
||||
"create_your_first_form": "Создайте первую форму",
|
||||
"create_your_first_form_description": "Формы маршрутизации позволяют задавать пользователям уточняющие вопросы, чтобы перенаправлять их к соответствующему лицу или типу события.",
|
||||
"create_your_first_webhook": "Создайте свой первый вебхук",
|
||||
@@ -1659,9 +1659,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Удалить конфигурацию {{connectionType}}? Участники команды, входящие в Cal.com через {{connectionType}}, больше не смогут получить к нему доступ.",
|
||||
"organizer_timezone": "Часовой пояс организатора",
|
||||
"email_user_cta": "Посмотреть приглашение",
|
||||
"email_no_user_invite_heading": "Вас пригласили в {{appName}} {{entity}}",
|
||||
"email_no_user_invite_heading": "Вас пригласили в {{entity}} {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} пригласил(-а) вас в команду в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого пользователи и целые команды могут планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} пригласил(а) вас в команду {{entity}} `{{teamName}}` в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого вы и ваша компания {{entity}} можете планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} пригласил(а) вас в команду {{entity}} `{{teamName}}` в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого вы и ваша {{entity}} можете планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_no_user_invite_steps_intro": "Всего несколько шагов — и вы сможете оперативно и без стресса планировать встречи с {{entity}}.",
|
||||
"email_no_user_step_one": "Выберите имя пользователя",
|
||||
"email_no_user_step_two": "Подключите аккаунт календаря",
|
||||
@@ -1687,7 +1687,7 @@
|
||||
"attendee_no_longer_attending": "Участник отказался от посещения вашего мероприятия",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} отказался(-ась) от посещения мероприятия. Этот промежуток времени снова свободен",
|
||||
"create_event_on": "Создать событие в календаре",
|
||||
"create_routing_form_on": "Создать форму маршрутизации",
|
||||
"create_routing_form_on": "Создать форму маршрутизации в",
|
||||
"default_app_link_title": "Установить ссылку на приложение по умолчанию",
|
||||
"default_app_link_description": "Если установить ссылку на приложение по умолчанию, она будет присваиваться всем вновь создаваемым событиям.",
|
||||
"organizer_default_conferencing_app": "Приложение организатора по умолчанию",
|
||||
@@ -1876,16 +1876,16 @@
|
||||
"connect_google_workspace": "Подключить Google Workspace",
|
||||
"google_workspace_admin_tooltip": "Эту функцию может использовать только администратор Workspace",
|
||||
"first_event_type_webhook_description": "Создайте первый вебхук для этого типа событий",
|
||||
"install_app_on": "Установить приложение на",
|
||||
"install_app_on": "Установить приложение в",
|
||||
"create_for": "Создать для",
|
||||
"setup_organization": "Настроить организацию",
|
||||
"organization_banner_description": "Создайте среду, в которой ваши команды смогут открывать совместный доступ к приложениям, рабочим процессам и типам событий, составляя расписание совместно или по очереди.",
|
||||
"organization_banner_description": "Создавайте рабочие среды, в которых ваши команды смогут открывать совместный доступ к приложениям, рабочим процессам и типам событий, составляя расписание совместно или по очереди.",
|
||||
"organization_banner_title": "Управляйте организациями с несколькими командами",
|
||||
"set_up_your_organization": "Настройте организацию",
|
||||
"organizations_description": "Организация – это общая рабочая среда, в которой команды могут создавать общие типы событий, приложения, рабочие процессы и многое другое.",
|
||||
"must_enter_organization_name": "Необходимо ввести название организации",
|
||||
"must_enter_organization_admin_email": "Необходимо ввести адрес электронной почты вашей организации",
|
||||
"admin_email": "Адрес электронной почты вашей организации",
|
||||
"admin_email": "Адрес электронной почты в организации",
|
||||
"admin_username": "Имя пользователя администратора",
|
||||
"organization_name": "Название организации",
|
||||
"organization_url": "URL-адрес организации",
|
||||
@@ -1912,7 +1912,7 @@
|
||||
"organization_admin_invited_body": "Станьте частью команды {{orgName}} – и вы сможете уделять внимание встречам, а не их планированию!",
|
||||
"duplicated_slugs_warning": "Не удалось создать следующие команды из-за дублирующихся слагов: {{slugs}}",
|
||||
"team_names_empty": "Имена команд не могут быть пустыми",
|
||||
"team_names_repeated": "Имена команд не могут быть повторяться",
|
||||
"team_names_repeated": "Имена команд должны быть уникальными",
|
||||
"user_belongs_organization": "Пользователь относится к организации",
|
||||
"org_no_teams_yet": "У этой организации еще нет команд",
|
||||
"org_no_teams_yet_description": "Если вы администратор, не забудьте создать команды, которые будут отображаться здесь.",
|
||||
|
||||
@@ -11,6 +11,7 @@ export const AppSetupMap = {
|
||||
zapier: dynamic(() => import("../../zapier/pages/setup")),
|
||||
closecom: dynamic(() => import("../../closecom/pages/setup")),
|
||||
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
|
||||
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
||||
};
|
||||
|
||||
export const AppSetupPage = (props: { slug: string }) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export const EventTypeAddonMap = {
|
||||
giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
|
||||
gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
|
||||
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")),
|
||||
paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")),
|
||||
plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
|
||||
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
|
||||
stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
|
||||
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
|
||||
import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
|
||||
import { appKeysSchema as paypal_zod_ts } from "./paypal/zod";
|
||||
import { appKeysSchema as plausible_zod_ts } from "./plausible/zod";
|
||||
import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod";
|
||||
import { appKeysSchema as routing_forms_zod_ts } from "./routing-forms/zod";
|
||||
@@ -43,6 +44,7 @@ export const appKeysSchemas = {
|
||||
metapixel: metapixel_zod_ts,
|
||||
office365calendar: office365calendar_zod_ts,
|
||||
office365video: office365video_zod_ts,
|
||||
paypal: paypal_zod_ts,
|
||||
plausible: plausible_zod_ts,
|
||||
qr_code: qr_code_zod_ts,
|
||||
"routing-forms": routing_forms_zod_ts,
|
||||
|
||||
@@ -32,6 +32,7 @@ import mirotalk_config_json from "./mirotalk/config.json";
|
||||
import n8n_config_json from "./n8n/config.json";
|
||||
import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata";
|
||||
import office365video_config_json from "./office365video/config.json";
|
||||
import paypal_config_json from "./paypal/config.json";
|
||||
import ping_config_json from "./ping/config.json";
|
||||
import pipedream_config_json from "./pipedream/config.json";
|
||||
import plausible_config_json from "./plausible/config.json";
|
||||
@@ -99,6 +100,7 @@ export const appStoreMetadata = {
|
||||
n8n: n8n_config_json,
|
||||
office365calendar: office365calendar__metadata_ts,
|
||||
office365video: office365video_config_json,
|
||||
paypal: paypal_config_json,
|
||||
ping: ping_config_json,
|
||||
pipedream: pipedream_config_json,
|
||||
plausible: plausible_config_json,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
|
||||
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
|
||||
import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
|
||||
import { appDataSchema as paypal_zod_ts } from "./paypal/zod";
|
||||
import { appDataSchema as plausible_zod_ts } from "./plausible/zod";
|
||||
import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod";
|
||||
import { appDataSchema as routing_forms_zod_ts } from "./routing-forms/zod";
|
||||
@@ -43,6 +44,7 @@ export const appDataSchemas = {
|
||||
metapixel: metapixel_zod_ts,
|
||||
office365calendar: office365calendar_zod_ts,
|
||||
office365video: office365video_zod_ts,
|
||||
paypal: paypal_zod_ts,
|
||||
plausible: plausible_zod_ts,
|
||||
qr_code: qr_code_zod_ts,
|
||||
"routing-forms": routing_forms_zod_ts,
|
||||
|
||||
@@ -32,6 +32,7 @@ export const apiHandlers = {
|
||||
n8n: import("./n8n/api"),
|
||||
office365calendar: import("./office365calendar/api"),
|
||||
office365video: import("./office365video/api"),
|
||||
paypal: import("./paypal/api"),
|
||||
ping: import("./ping/api"),
|
||||
pipedream: import("./pipedream/api"),
|
||||
plausible: import("./plausible/api"),
|
||||
|
||||
@@ -14,6 +14,7 @@ const appStore = {
|
||||
office365calendar: () => import("./office365calendar"),
|
||||
office365video: () => import("./office365video"),
|
||||
plausible: () => import("./plausible"),
|
||||
paypal: () => import("./paypal"),
|
||||
salesforce: () => import("./salesforce"),
|
||||
zohocrm: () => import("./zohocrm"),
|
||||
sendgrid: () => import("./sendgrid"),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
items:
|
||||
- 1.jpeg
|
||||
- 2.jpeg
|
||||
- 3.jpeg
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import config from "../config.json";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session?.user?.id) {
|
||||
return res.status(401).json({ message: "You must be logged in to do this" });
|
||||
}
|
||||
const appType = config.type;
|
||||
try {
|
||||
const alreadyInstalled = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: appType,
|
||||
userId: req.session.user.id,
|
||||
},
|
||||
});
|
||||
if (alreadyInstalled) {
|
||||
throw new Error("Already installed");
|
||||
}
|
||||
const installation = await prisma.credential.create({
|
||||
data: {
|
||||
type: appType,
|
||||
key: {},
|
||||
userId: req.session.user.id,
|
||||
appId: "paypal",
|
||||
},
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
throw new Error("Unable to create user credential for Paypal");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500);
|
||||
}
|
||||
|
||||
return res.status(200).json({ url: "/apps/paypal/setup" });
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
// Look if it's get
|
||||
if (req.method !== "GET") {
|
||||
throw new Error("Invalid method");
|
||||
}
|
||||
|
||||
// Look if bookingUid it's provided in query params using zod
|
||||
const parseRequest = captureRequestSchema.safeParse(req.query);
|
||||
if (!parseRequest.success) {
|
||||
throw new Error("Request is malformed");
|
||||
}
|
||||
|
||||
// Get bookingUid and token from query params
|
||||
const { bookingUid, token } = parseRequest.data;
|
||||
|
||||
// Get booking credentials
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
const credentials = await findPaymentCredentials(booking?.id);
|
||||
|
||||
if (!credentials) {
|
||||
throw new Error("Credentials not found");
|
||||
}
|
||||
|
||||
// Get paypal instance
|
||||
const paypalClient = new Paypal(credentials);
|
||||
|
||||
// capture payment
|
||||
const capture = await paypalClient.captureOrder(token);
|
||||
|
||||
if (!capture) {
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=failed`);
|
||||
}
|
||||
if (IS_PRODUCTION) {
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=success`);
|
||||
} else {
|
||||
// For cal.dev, paypal sandbox doesn't send webhooks
|
||||
const updateBooking = prisma.booking.update({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
data: {
|
||||
paid: true,
|
||||
},
|
||||
});
|
||||
const updatePayment = prisma.payment.update({
|
||||
where: {
|
||||
id: booking?.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
await Promise.all([updateBooking, updatePayment]);
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=success`);
|
||||
}
|
||||
return;
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
|
||||
res.status(200).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
res.redirect(`/booking/${req.query.bookingUid}?paypalPaymentStatus=failed`);
|
||||
}
|
||||
}
|
||||
|
||||
const captureRequestSchema = z.object({
|
||||
bookingUid: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "@calcom/web/pages/api/integrations/paypal/webhook";
|
||||
export { default as capture } from "./capture";
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import { currencyOptions } from "@calcom/app-store/paypal/pages/setup";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, Select, TextField } from "@calcom/ui";
|
||||
|
||||
import type { appDataSchema } from "../zod";
|
||||
import { PaypalPaymentOptions as paymentOptions } from "../zod";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const { asPath } = useRouter();
|
||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
currencyOptions.find((c) => c.value === currency) || {
|
||||
label: currencyOptions[0].label,
|
||||
value: currencyOptions[0].value,
|
||||
}
|
||||
);
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
|
||||
label: paymentOptions[0].label,
|
||||
value: paymentOptions[0].value,
|
||||
};
|
||||
const seatsEnabled = !!eventType.seatsPerTimeSlot;
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
const { t } = useLocale();
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={WEBAPP_URL + asPath}
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(enabled) => {
|
||||
setRequirePayment(enabled);
|
||||
}}
|
||||
description={<>Add Paypal payment to your events</>}>
|
||||
<>
|
||||
{recurringEventDefined ? (
|
||||
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
||||
) : (
|
||||
requirePayment && (
|
||||
<>
|
||||
<div className="mt-2 block items-center sm:flex">
|
||||
<TextField
|
||||
label="Price"
|
||||
labelSrOnly
|
||||
addOnLeading="$"
|
||||
addOnSuffix={currency || "No selected currency"}
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
type="number"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value) * 100);
|
||||
if (currency) {
|
||||
setAppData("currency", currency);
|
||||
}
|
||||
}}
|
||||
value={price > 0 ? price / 100 : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
{t("currency")}
|
||||
</label>
|
||||
<Select
|
||||
variant="default"
|
||||
options={currencyOptions}
|
||||
value={selectedCurrency}
|
||||
className="text-black"
|
||||
defaultValue={selectedCurrency}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setSelectedCurrency(e);
|
||||
setAppData("currency", e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
Payment option
|
||||
</label>
|
||||
<Select<Option>
|
||||
defaultValue={
|
||||
paymentOptionSelectValue
|
||||
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
|
||||
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
|
||||
}
|
||||
options={paymentOptions.map((option) => {
|
||||
return { ...option, label: t(option.label) || option.label };
|
||||
})}
|
||||
onChange={(input) => {
|
||||
if (input) setAppData("paymentOption", input.value);
|
||||
}}
|
||||
className="mb-1 h-[38px] w-full"
|
||||
isDisabled={seatsEnabled}
|
||||
/>
|
||||
</div>
|
||||
{seatsEnabled && paymentOption === "HOLD" && (
|
||||
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
||||
@@ -0,0 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import z from "zod";
|
||||
|
||||
interface IPaypalPaymentComponentProps {
|
||||
payment: {
|
||||
// Will be parsed on render
|
||||
data: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// Create zod schema for data
|
||||
const PaymentPaypalDataSchema = z.object({
|
||||
order: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
links: z.array(
|
||||
z.object({
|
||||
href: z.string(),
|
||||
rel: z.string(),
|
||||
method: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
capture: z.object({}).optional(),
|
||||
});
|
||||
|
||||
export const PaypalPaymentComponent = (props: IPaypalPaymentComponentProps) => {
|
||||
const { payment } = props;
|
||||
const { data } = payment;
|
||||
const wrongUrl = (
|
||||
<>
|
||||
<p className="mt-3 text-center">Couldn't obtain payment URL</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const parsedData = PaymentPaypalDataSchema.safeParse(data);
|
||||
if (!parsedData.success || !parsedData.data?.order?.links) {
|
||||
return wrongUrl;
|
||||
}
|
||||
const paymentUrl = parsedData.data.order.links.find(
|
||||
(link) => link.rel === "approve" || link.rel === "payer-action"
|
||||
)?.href;
|
||||
if (!paymentUrl) {
|
||||
return wrongUrl;
|
||||
}
|
||||
return (
|
||||
<div className="mt-4 flex h-full w-full flex-col items-center justify-center">
|
||||
<Link
|
||||
href={`${paymentUrl}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent bg-[#ffc439] px-12 py-2 text-base
|
||||
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
Pay with
|
||||
<img src="/api/app-store/paypal/paypal-logo.svg" alt="Paypal" className="mx-2 mb-1 mt-2 w-16" />
|
||||
<span />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Paypal",
|
||||
"slug": "paypal",
|
||||
"type": "paypal_payment",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://example.com/link",
|
||||
"variant": "payment",
|
||||
"categories": ["payment"],
|
||||
"publisher": "Cal.com",
|
||||
"email": "support@cal.com",
|
||||
"description": "Paypal payment app by Cal.com",
|
||||
"extendsFeature": "EventType",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"imageSrc": "icon.svg",
|
||||
"__template": "event-type-app-card",
|
||||
"dirName": "paypal"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
|
||||
import { paymentOptionEnum } from "../zod";
|
||||
|
||||
export const paypalCredentialKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
secret_key: z.string(),
|
||||
webhook_id: z.string(),
|
||||
});
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private credentials: z.infer<typeof paypalCredentialKeysSchema>;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
this.credentials = paypalCredentialKeysSchema.parse(credentials.key);
|
||||
}
|
||||
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const paypalClient = new Paypal({
|
||||
clientId: this.credentials.client_id,
|
||||
secretKey: this.credentials.secret_key,
|
||||
});
|
||||
const orderResult = await paypalClient.createOrder({
|
||||
referenceId: uid,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
returnUrl: `${WEBAPP_URL}/api/integrations/paypal/capture?bookingUid=${booking.uid}`,
|
||||
cancelUrl: `${WEBAPP_URL}/payment/${uid}`,
|
||||
});
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid,
|
||||
app: {
|
||||
connect: {
|
||||
slug: "paypal",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
externalId: orderResult.id,
|
||||
currency: payment.currency,
|
||||
data: Object.assign({}, { order: orderResult }) as unknown as Prisma.InputJsonValue,
|
||||
fee: 0,
|
||||
refunded: false,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
async update(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async refund(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async collectCard(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: number,
|
||||
_bookerEmail: string,
|
||||
paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
// Ensure that the payment service can support the passed payment option
|
||||
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
|
||||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const paypalClient = new Paypal({
|
||||
clientId: this.credentials.client_id,
|
||||
secretKey: this.credentials.secret_key,
|
||||
});
|
||||
const preference = await paypalClient.createOrder({
|
||||
referenceId: uid,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
returnUrl: `${WEBAPP_URL}/booking/${booking.uid}`,
|
||||
cancelUrl: `${WEBAPP_URL}/payment/${uid}`,
|
||||
intent: "AUTHORIZE",
|
||||
});
|
||||
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid,
|
||||
app: {
|
||||
connect: {
|
||||
slug: "paypal",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
data: Object.assign({}, preference) as unknown as Prisma.InputJsonValue,
|
||||
fee: 0,
|
||||
refunded: false,
|
||||
success: false,
|
||||
paymentOption: paymentOption || "ON_BOOKING",
|
||||
externalId: preference?.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
chargeCard(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: number
|
||||
): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getPaymentPaidStatus(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getPaymentDetails(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
afterPayment(
|
||||
event: CalendarEvent,
|
||||
booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
},
|
||||
paymentData: Payment
|
||||
): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
deletePayment(paymentId: number): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
class Paypal {
|
||||
url: string;
|
||||
clientId: string;
|
||||
secretKey: string;
|
||||
accessToken: string | null = null;
|
||||
expiresAt: number | null = null;
|
||||
|
||||
constructor({ clientId, secretKey }: { clientId: string; secretKey: string }) {
|
||||
this.url = IS_PRODUCTION ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
this.clientId = clientId;
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<void> {
|
||||
if (this.accessToken && this.expiresAt && this.expiresAt > Date.now()) {
|
||||
return;
|
||||
}
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.secretKey}`).toString("base64")}`,
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.append("grant_type", "client_credentials");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (response.ok) {
|
||||
const { access_token, expires_in } = await response.json();
|
||||
this.accessToken = access_token;
|
||||
this.expiresAt = Date.now() + expires_in;
|
||||
} else {
|
||||
console.error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Orders
|
||||
async createOrder({
|
||||
referenceId,
|
||||
amount,
|
||||
currency,
|
||||
returnUrl,
|
||||
cancelUrl,
|
||||
intent = "CAPTURE",
|
||||
}: {
|
||||
referenceId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
returnUrl: string;
|
||||
cancelUrl: string;
|
||||
intent?: "CAPTURE" | "AUTHORIZE";
|
||||
}): Promise<CreateOrderResponse> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"PayPal-Request-Id": uuidv4(),
|
||||
};
|
||||
|
||||
const createOrderRequestBody: CreateOrderRequestBody = {
|
||||
intent,
|
||||
purchase_units: [
|
||||
{
|
||||
reference_id: referenceId,
|
||||
amount: {
|
||||
currency_code: currency,
|
||||
value: (amount / 100).toString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
payment_source: {
|
||||
paypal: {
|
||||
experience_context: {
|
||||
user_action: "PAY_NOW",
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v2/checkout/orders`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(createOrderRequestBody),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const createOrderResponse: CreateOrderResponse = await response.json();
|
||||
return createOrderResponse;
|
||||
} else {
|
||||
console.error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return {} as CreateOrderResponse;
|
||||
}
|
||||
|
||||
async captureOrder(orderId: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
const captureResult = await fetch(`${this.url}/v2/checkout/orders/${orderId}/capture`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (captureResult.ok) {
|
||||
const result = await captureResult.json();
|
||||
if (result.body.status === "COMPLETED") {
|
||||
// Get payment reference id
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: orderId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
bookingId: true,
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment?.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
data: Object.assign(
|
||||
{},
|
||||
{ ...(payment?.data as Record<string, string | number>), capture: result.body.id }
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Update booking as paid
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: payment.bookingId,
|
||||
},
|
||||
data: {
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async createWebhook(): Promise<boolean | string> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
const body = {
|
||||
url: `${WEBAPP_URL}/api/integrations/paypal/webhook`,
|
||||
event_types: [
|
||||
{
|
||||
name: "CHECKOUT.ORDER.APPROVED",
|
||||
},
|
||||
{
|
||||
name: "CHECKOUT.ORDER.COMPLETED",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
return result.id as string;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async listWebhooks(): Promise<string[]> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { webhooks } = await response.json();
|
||||
|
||||
return webhooks
|
||||
.filter((webhook: { id: string; url: string }) => {
|
||||
return webhook.url.includes("api/integrations/paypal/webhook");
|
||||
})
|
||||
.map((webhook: { id: string }) => webhook.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async deleteWebhook(webhookId: string): Promise<boolean> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks/${webhookId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async test(): Promise<boolean> {
|
||||
// Always get a new access token
|
||||
try {
|
||||
await this.getAccessToken();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async verifyWebhook(options: WebhookEventVerifyRequest): Promise<void> {
|
||||
const url = `${this.url}/v1/notifications/verify-webhook-signature`;
|
||||
const parseRequest = webhookEventVerifyRequestSchema.safeParse(options);
|
||||
|
||||
// Webhook event should be parsable
|
||||
if (!parseRequest.success) {
|
||||
console.error(parseRequest.error);
|
||||
throw new Error("Request is malformed");
|
||||
}
|
||||
|
||||
const stringy = JSON.stringify({
|
||||
auth_algo: options.body.auth_algo,
|
||||
cert_url: options.body.cert_url,
|
||||
transmission_id: options.body.transmission_id,
|
||||
transmission_sig: options.body.transmission_sig,
|
||||
transmission_time: options.body.transmission_time,
|
||||
webhook_id: options.body.webhook_id,
|
||||
});
|
||||
|
||||
const bodyToString = stringy.slice(0, -1) + `,"webhook_event":${options.body.webhook_event}` + "}";
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
},
|
||||
body: bodyToString,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.verification_status !== "SUCCESS") {
|
||||
throw data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Paypal;
|
||||
|
||||
interface PurchaseUnit {
|
||||
amount: {
|
||||
currency_code: string;
|
||||
value: string;
|
||||
};
|
||||
reference_id: string;
|
||||
}
|
||||
|
||||
interface ExperienceContext {
|
||||
payment_method_preference?: string;
|
||||
payment_method_selected?: string;
|
||||
brand_name?: string;
|
||||
locale?: string;
|
||||
landing_page?: string;
|
||||
shipping_preference?: string;
|
||||
user_action: string;
|
||||
return_url: string;
|
||||
cancel_url: string;
|
||||
}
|
||||
|
||||
interface PaymentSource {
|
||||
paypal: {
|
||||
experience_context: ExperienceContext;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateOrderRequestBody {
|
||||
purchase_units: PurchaseUnit[];
|
||||
intent: string;
|
||||
payment_source?: PaymentSource;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
href: string;
|
||||
rel: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
interface CreateOrderResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
payment_source: PaymentSource;
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
const webhookEventVerifyRequestSchema = z.object({
|
||||
body: z
|
||||
.object({
|
||||
auth_algo: z.string(),
|
||||
cert_url: z.string(),
|
||||
transmission_id: z.string(),
|
||||
transmission_sig: z.string(),
|
||||
transmission_time: z.string(),
|
||||
webhook_event: z.string(),
|
||||
webhook_id: z.string(),
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
|
||||
export type WebhookEventVerifyRequest = z.infer<typeof webhookEventVerifyRequestSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./PaymentService";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/paypal",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Paypal payment app by Cal.com"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
|
||||
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
let clientId = "";
|
||||
let secretKey = "";
|
||||
const appKeys = await getAppKeysFromSlug("paypal");
|
||||
if (typeof appKeys.client_id === "string" && typeof appKeys.secret_key === "string") {
|
||||
clientId = appKeys.client_id;
|
||||
secretKey = appKeys.secret_key;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
clientId,
|
||||
secretKey,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, showToast, TextField } from "@calcom/ui";
|
||||
|
||||
export interface PayPalSetupProps {
|
||||
public_key: string;
|
||||
access_token: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export const currencyOptions = [
|
||||
{ label: "United States dollar", value: "USD" },
|
||||
{ label: "Australian dollar", value: "AUD" },
|
||||
{ label: "Brazilian real 2", value: "BRL" },
|
||||
{ label: "Canadian dollar", value: "CAD" },
|
||||
{ label: "Chinese Renmenbi 3", value: "CNY" },
|
||||
{ label: "Czech koruna", value: "CZK" },
|
||||
{ label: "Danish krone", value: "DKK" },
|
||||
{ label: "Euro", value: "EUR" },
|
||||
{ label: "Hong Kong dollar", value: "HKD" },
|
||||
{ label: "Hungarian forint 1", value: "HUF" },
|
||||
{ label: "Israeli new shekel", value: "ILS" },
|
||||
{ label: "Japanese yen 1", value: "JPY" },
|
||||
{ label: "Malaysian ringgit 3", value: "MYR" },
|
||||
{ label: "Mexican peso", value: "MXN" },
|
||||
{ label: "New Taiwan dollar 1", value: "TWD" },
|
||||
{ label: "New Zealand dollar", value: "NZD" },
|
||||
{ label: "Norwegian krone", value: "NOK" },
|
||||
{ label: "Philippine peso", value: "PHP" },
|
||||
{ label: "Polish złoty", value: "PLN" },
|
||||
{ label: "Pound sterling", value: "GBP" },
|
||||
{ label: "Russian ruble", value: "RUB" },
|
||||
{ label: "Singapore dollar", value: "SGD" },
|
||||
{ label: "Swedish krona", value: "SEK" },
|
||||
{ label: "Swiss franc", value: "CHF" },
|
||||
{ label: "Thai baht", value: "THB" },
|
||||
];
|
||||
|
||||
export default function PayPalSetup(props: PayPalSetupProps) {
|
||||
const [newClientId, setNewClientId] = useState("");
|
||||
const [newSecretKey, setNewSecretKey] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment" });
|
||||
const paypalPaymentAppCredentials = integrations.data?.items.find((item) => item.type === "paypal_payment");
|
||||
const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [false];
|
||||
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
|
||||
const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("keys_have_been_saved"), "success");
|
||||
router.push("/event-types");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const saveKeys = async (key: { clientId: string; secretKey: string }) => {
|
||||
if (!key.clientId || !key.secretKey || key.clientId === key.secretKey) {
|
||||
return false;
|
||||
}
|
||||
if (typeof credentialId !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Test credentials before saving
|
||||
const paypalClient = new Paypal({ clientId: key.clientId, secretKey: key.secretKey });
|
||||
const test = await paypalClient.test();
|
||||
if (!test) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete all existing webhooks
|
||||
const webhooksToDelete = await paypalClient.listWebhooks();
|
||||
if (webhooksToDelete) {
|
||||
for (const webhook of webhooksToDelete) {
|
||||
await paypalClient.deleteWebhook(webhook);
|
||||
}
|
||||
}
|
||||
|
||||
// Create webhook for this installation
|
||||
const webhookId = await paypalClient.createWebhook();
|
||||
if (!webhookId) {
|
||||
// @TODO: make a button that tries to create the webhook again
|
||||
console.log("Failed to create webhook", webhookId);
|
||||
return false;
|
||||
}
|
||||
saveKeysMutation.mutate({
|
||||
credentialId,
|
||||
key: {
|
||||
client_id: key.clientId,
|
||||
secret_key: key.secretKey,
|
||||
webhook_id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (integrations.isLoading) {
|
||||
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default flex h-screen">
|
||||
{showContent ? (
|
||||
<div className="bg-default border-subtle m-auto max-w-[43em] overflow-auto rounded border pb-10 md:p-10">
|
||||
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
|
||||
<div className="invisible md:visible">
|
||||
<img className="h-11" src="/api/app-store/paypal/icon.svg" alt="Paypal Payment Logo" />
|
||||
<p className="text-default mt-5 text-lg">Paypal</p>
|
||||
</div>
|
||||
<form autoComplete="off" className="mt-5">
|
||||
<TextField
|
||||
label="Client Id"
|
||||
type="text"
|
||||
name="client_id"
|
||||
id="client_id"
|
||||
value={newClientId}
|
||||
onChange={(e) => setNewClientId(e.target.value)}
|
||||
role="presentation"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Secret Key"
|
||||
type="password"
|
||||
name="access_token"
|
||||
id="access_token"
|
||||
value={newSecretKey}
|
||||
autoComplete="new-password"
|
||||
role="presentation"
|
||||
onChange={(e) => setNewSecretKey(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Button to submit */}
|
||||
<div className="mt-5 flex flex-row justify-end">
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
saveKeys({
|
||||
clientId: newClientId,
|
||||
secretKey: newSecretKey,
|
||||
})
|
||||
}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<p className="text-lgf text-default mt-5 font-bold">Setup instructions</p>
|
||||
|
||||
<ol className="text-default ml-1 list-decimal pl-2">
|
||||
{/* @TODO: translate */}
|
||||
<li>
|
||||
Log into your Paypal Developer account and create a new app{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://developer.paypal.com/dashboard/applications/"
|
||||
className="text-orange-600 underline">
|
||||
{t("here")}
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Choose a name for your application.</li>
|
||||
<li>Select Online payments solution.</li>
|
||||
<li>Choose "No" for "Using online platform".</li>
|
||||
<li>CheckoutAPI as integration product.</li>
|
||||
<li>Accept terms and Create APP.</li>
|
||||
<li>Go back to dashboard, click on new app and copy the credentials.</li>
|
||||
<li>Paste them on the required field and save them.</li>
|
||||
<li>You're all setup.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5 mt-5">
|
||||
<div>Paypal</div>
|
||||
<div className="mt-3">
|
||||
<Link href="/apps/paypal" passHref={true} legacyBehavior>
|
||||
<Button>{t("go_to_app_store")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48">
|
||||
<path fill="#001C64" d="M37.972 13.82c.107-5.565-4.485-9.837-10.799-9.837H14.115a1.278 1.278 0 0 0-1.262 1.079L7.62 37.758a1.038 1.038 0 0 0 1.025 1.2h7.737l-1.21 7.572a1.038 1.038 0 0 0 1.026 1.2H22.5c.305 0 .576-.11.807-.307.231-.198.269-.471.316-.772l1.85-10.885c.047-.3.2-.69.432-.888.231-.198.433-.306.737-.307H30.5c6.183 0 11.43-4.394 12.389-10.507.678-4.34-1.182-8.287-4.916-10.244Z"/>
|
||||
<path fill="#0070E0" d="m18.056 26.9-1.927 12.22-1.21 7.664a1.038 1.038 0 0 0 1.026 1.2h6.67a1.278 1.278 0 0 0 1.261-1.079l1.758-11.14a1.277 1.277 0 0 1 1.261-1.078h3.927c6.183 0 11.429-4.51 12.388-10.623.68-4.339-1.504-8.286-5.238-10.244-.01.462-.05.923-.121 1.38-.959 6.112-6.206 10.623-12.389 10.623h-6.145a1.277 1.277 0 0 0-1.261 1.077Z"/>
|
||||
<path fill="#003087" d="M16.128 39.12h-7.76a1.037 1.037 0 0 1-1.025-1.2l5.232-33.182a1.277 1.277 0 0 1 1.262-1.078h13.337c6.313 0 10.905 4.595 10.798 10.16-1.571-.824-3.417-1.295-5.44-1.295H21.413a1.278 1.278 0 0 0-1.261 1.078L18.057 26.9l-1.93 12.22Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="101" height="32" preserveAspectRatio="xMinYMin meet">
|
||||
<path fill="#003087" d="M12.237 2.8h-7.8c-.5 0-1 .4-1.1.9l-3.1 20c-.1.4.2.7.6.7h3.7c.5 0 1-.4 1.1-.9l.8-5.4c.1-.5.5-.9 1.1-.9h2.5c5.1 0 8.1-2.5 8.9-7.4.3-2.1 0-3.8-1-5-1.1-1.3-3.1-2-5.7-2Zm.9 7.3c-.4 2.8-2.6 2.8-4.6 2.8h-1.2l.8-5.2c0-.3.3-.5.6-.5h.5c1.4 0 2.7 0 3.4.8.5.4.7 1.1.5 2.1ZM35.437 10h-3.7c-.3 0-.6.2-.6.5l-.2 1-.3-.4c-.8-1.2-2.6-1.6-4.4-1.6-4.1 0-7.6 3.1-8.3 7.5-.4 2.2.1 4.3 1.4 5.7 1.1 1.3 2.8 1.9 4.7 1.9 3.3 0 5.2-2.1 5.2-2.1l-.2 1c-.1.4.2.8.6.8h3.4c.5 0 1-.4 1.1-.9l2-12.8c.1-.2-.3-.6-.7-.6Zm-5.1 7.2c-.4 2.1-2 3.6-4.2 3.6-1.1 0-1.9-.3-2.5-1-.6-.7-.8-1.6-.6-2.6.3-2.1 2.1-3.6 4.2-3.6 1.1 0 1.9.4 2.5 1 .5.7.7 1.6.6 2.6ZM55.337 10h-3.7c-.4 0-.7.2-.9.5l-5.2 7.6-2.2-7.3c-.1-.5-.6-.8-1-.8h-3.7c-.4 0-.8.4-.6.9l4.1 12.1-3.9 5.4c-.3.4 0 1 .5 1h3.7c.4 0 .7-.2.9-.5l12.5-18c.3-.3 0-.9-.5-.9Z"/>
|
||||
<path fill="#009cde" d="M67.737 2.8h-7.8c-.5 0-1 .4-1.1.9l-3.1 19.9c-.1.4.2.7.6.7h4c.4 0 .7-.3.7-.6l.9-5.7c.1-.5.5-.9 1.1-.9h2.5c5.1 0 8.1-2.5 8.9-7.4.3-2.1 0-3.8-1-5-1.2-1.2-3.1-1.9-5.7-1.9Zm.9 7.3c-.4 2.8-2.6 2.8-4.6 2.8h-1.2l.8-5.2c0-.3.3-.5.6-.5h.5c1.4 0 2.7 0 3.4.8.5.4.6 1.1.5 2.1ZM90.937 10h-3.7c-.3 0-.6.2-.6.5l-.2 1-.3-.4c-.8-1.2-2.6-1.6-4.4-1.6-4.1 0-7.6 3.1-8.3 7.5-.4 2.2.1 4.3 1.4 5.7 1.1 1.3 2.8 1.9 4.7 1.9 3.3 0 5.2-2.1 5.2-2.1l-.2 1c-.1.4.2.8.6.8h3.4c.5 0 1-.4 1.1-.9l2-12.8c0-.2-.3-.6-.7-.6Zm-5.2 7.2c-.4 2.1-2 3.6-4.2 3.6-1.1 0-1.9-.3-2.5-1-.6-.7-.8-1.6-.6-2.6.3-2.1 2.1-3.6 4.2-3.6 1.1 0 1.9.4 2.5 1 .6.7.8 1.6.6 2.6ZM95.337 3.3l-3.2 20.3c-.1.4.2.7.6.7h3.2c.5 0 1-.4 1.1-.9l3.2-19.9c.1-.4-.2-.7-.6-.7h-3.6c-.4 0-.6.2-.7.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
|
||||
|
||||
const paymentOptionSchema = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const paymentOptionsSchema = z.array(paymentOptionSchema);
|
||||
|
||||
export const PaypalPaymentOptions = [
|
||||
{
|
||||
label: "on_booking_option",
|
||||
value: "ON_BOOKING",
|
||||
},
|
||||
// @TODO: not required right now
|
||||
// {
|
||||
// label: "hold_option",
|
||||
// value: "HOLD",
|
||||
// },
|
||||
];
|
||||
|
||||
type PaymentOption = (typeof PaypalPaymentOptions)[number]["value"];
|
||||
const VALUES: [PaymentOption, ...PaymentOption[]] = [
|
||||
PaypalPaymentOptions[0].value,
|
||||
...PaypalPaymentOptions.slice(1).map((option) => option.value),
|
||||
];
|
||||
export const paymentOptionEnum = z.enum(VALUES);
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: z.string().optional(),
|
||||
})
|
||||
);
|
||||
export const appKeysSchema = z.object({});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Booking, Payment, Prisma, PaymentOption } from "@prisma/client";
|
||||
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
@@ -12,7 +12,7 @@ import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
import { paymentOptionEnum } from "../zod";
|
||||
import { createPaymentLink } from "./client";
|
||||
import { retrieveOrCreateStripeCustomerByEmail } from "./customer";
|
||||
import type { StripeSetupIntentData, StripePaymentData } from "./server";
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "./server";
|
||||
|
||||
const stripeCredentialKeysSchema = z.object({
|
||||
stripe_user_id: z.string(),
|
||||
@@ -38,6 +38,13 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getPayment(where: Prisma.PaymentWhereInput) {
|
||||
const payment = await prisma.payment.findFirst({ where });
|
||||
if (!payment) throw new Error("Payment not found");
|
||||
if (!payment.externalId) throw new Error("Payment externalId not found");
|
||||
return { ...payment, externalId: payment.externalId };
|
||||
}
|
||||
|
||||
/* This method is for creating charges at the time of booking */
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
@@ -52,7 +59,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
@@ -81,7 +88,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
|
||||
const paymentData = await prisma?.payment.create({
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid: uuidv4(),
|
||||
app: {
|
||||
@@ -97,7 +104,6 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
externalId: paymentIntent.id,
|
||||
|
||||
data: Object.assign({}, paymentIntent, {
|
||||
stripe_publishable_key: this.credentials.stripe_publishable_key,
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
@@ -131,7 +137,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
@@ -161,7 +167,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
|
||||
const paymentData = await prisma?.payment.create({
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid: uuidv4(),
|
||||
app: {
|
||||
@@ -177,7 +183,6 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
externalId: setupIntent.id,
|
||||
|
||||
data: Object.assign(
|
||||
{},
|
||||
{
|
||||
@@ -202,7 +207,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
|
||||
async chargeCard(payment: Payment, _bookingId?: Booking["id"]): Promise<Payment> {
|
||||
try {
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
@@ -280,17 +285,11 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
|
||||
async refund(paymentId: Payment["id"]): Promise<Payment> {
|
||||
try {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
id: paymentId,
|
||||
success: true,
|
||||
refunded: false,
|
||||
},
|
||||
const payment = await this.getPayment({
|
||||
id: paymentId,
|
||||
success: true,
|
||||
refunded: false,
|
||||
});
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
const refund = await this.stripe.refunds.create(
|
||||
{
|
||||
payment_intent: payment.externalId,
|
||||
@@ -345,15 +344,9 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
|
||||
async deletePayment(paymentId: Payment["id"]): Promise<boolean> {
|
||||
try {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
id: paymentId,
|
||||
},
|
||||
const payment = await this.getPayment({
|
||||
id: paymentId,
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
const stripeAccount = (payment.data as unknown as StripePaymentData).stripeAccount;
|
||||
|
||||
if (!stripeAccount) {
|
||||
|
||||
@@ -151,7 +151,7 @@ module.exports = {
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("tailwind-scrollbar"),
|
||||
require("tailwind-scrollbar")({ nocompatible: true }),
|
||||
require("tailwindcss-radix")(),
|
||||
require("@savvywombat/tailwindcss-grid-areas"),
|
||||
plugin(({ addVariant }) => {
|
||||
@@ -168,6 +168,6 @@ module.exports = {
|
||||
}),
|
||||
],
|
||||
variants: {
|
||||
scrollbar: ["rounded", "dark"],
|
||||
scrollbar: ["dark"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -497,6 +497,70 @@
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
<td style="direction: rtl; font-size: 0px; padding: 0px; text-align: center">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: right;
|
||||
direction: rtl;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="vertical-align: top"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
padding-top: 32px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="border-collapse: collapse; border-spacing: 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 89px">
|
||||
<a href="{{base_url}}" target="_blank">
|
||||
<img
|
||||
height="19"
|
||||
src="https://app.cal.com/emails/logo.png"
|
||||
style="
|
||||
border: 0;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
height: 19px;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
"
|
||||
width="89" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -207,7 +207,7 @@ const BookerComponent = ({
|
||||
<BookerSection
|
||||
area="header"
|
||||
className={classNames(
|
||||
layout === BookerLayouts.MONTH_VIEW && "fixed right-4 top-4 z-10",
|
||||
layout === BookerLayouts.MONTH_VIEW && "fixed top-4 z-10 ltr:right-4 rtl:left-4",
|
||||
(layout === BookerLayouts.COLUMN_VIEW || layout === BookerLayouts.WEEK_VIEW) &&
|
||||
"bg-default dark:bg-muted sticky top-0 z-10"
|
||||
)}>
|
||||
@@ -280,7 +280,7 @@ const BookerComponent = ({
|
||||
layout === BookerLayouts.COLUMN_VIEW
|
||||
}
|
||||
className={classNames(
|
||||
"border-subtle flex h-full w-full flex-col px-5 py-3 pb-0 md:border-l",
|
||||
"border-subtle rtl:border-default flex h-full w-full flex-col px-5 py-3 pb-0 rtl:border-r ltr:md:border-l",
|
||||
layout === BookerLayouts.MONTH_VIEW &&
|
||||
"scroll-bar h-full overflow-auto md:w-[var(--booker-timeslots-width)]",
|
||||
layout !== BookerLayouts.MONTH_VIEW && "sticky top-0"
|
||||
|
||||
@@ -71,7 +71,7 @@ export const EventMeta = () => {
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(event.description) }} />
|
||||
</EventMetaBlock>
|
||||
)}
|
||||
<div className="space-y-4 font-medium">
|
||||
<div className="space-y-4 font-medium rtl:-mr-2">
|
||||
{rescheduleUid && bookingData && (
|
||||
<EventMetaBlock icon={Calendar}>
|
||||
{t("former_time")}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function Header({
|
||||
isMobile: boolean;
|
||||
enabledLayouts: BookerLayouts[];
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { t, i18n } = useLocale();
|
||||
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
|
||||
const selectedDateString = useBookerStore((state) => state.selectedDate);
|
||||
const setSelectedDate = useBookerStore((state) => state.setSelectedDate);
|
||||
@@ -66,13 +66,13 @@ export function Header({
|
||||
const isSameYear = () => {
|
||||
return selectedDate.format("YYYY") === endDate.format("YYYY");
|
||||
};
|
||||
|
||||
const formattedMonth = new Intl.DateTimeFormat(i18n.language, { month: "short" });
|
||||
const FormattedSelectedDateRange = () => {
|
||||
return (
|
||||
<h3 className="min-w-[150px] text-base font-semibold leading-4">
|
||||
{selectedDate.format("MMM D")}
|
||||
{formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")}
|
||||
{!isSameYear() && <span className="text-subtle">, {selectedDate.format("YYYY")} </span>}-{" "}
|
||||
{isSameMonth() ? endDate.format("D") : endDate.format("MMM D")},{" "}
|
||||
{!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "}
|
||||
<span className="text-subtle">
|
||||
{isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")}
|
||||
</span>
|
||||
@@ -81,11 +81,12 @@ export function Header({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-default relative z-10 flex border-b border-l px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="border-default relative z-10 flex border-b px-5 py-4 ltr:border-l rtl:border-r">
|
||||
<div className="flex items-center gap-5 rtl:flex-grow">
|
||||
<FormattedSelectedDateRange />
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
className="group rtl:ml-1 rtl:rotate-180"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon={ChevronLeft}
|
||||
@@ -93,6 +94,7 @@ export function Header({
|
||||
onClick={() => addToSelectedDate(-extraDays - 1)}
|
||||
/>
|
||||
<Button
|
||||
className="group rtl:mr-1 rtl:rotate-180"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon={ChevronRight}
|
||||
@@ -101,7 +103,7 @@ export function Header({
|
||||
/>
|
||||
{selectedDateMin3DaysDifference && (
|
||||
<Button
|
||||
className="capitalize"
|
||||
className="capitalize ltr:ml-2 rtl:mr-2"
|
||||
color="secondary"
|
||||
onClick={() => setSelectedDate(today.format("YYYY-MM-DD"))}>
|
||||
{t("today")}
|
||||
@@ -111,15 +113,15 @@ export function Header({
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<TimeFormatToggle />
|
||||
<div className="fixed right-4 top-4">
|
||||
<div className="fixed top-4 ltr:right-4 rtl:left-4">
|
||||
<LayoutToggleWithData />
|
||||
</div>
|
||||
{/*
|
||||
This second layout toggle is hidden, but needed to reserve the correct spot in the DIV
|
||||
for the fixed toggle above to fit into. If we wouldn't make it fixed in this view, the transition
|
||||
would be really weird, because the element is positioned fixed in the month view, and then
|
||||
when switching layouts wouldn't anymmore, causing it to animate from the center to the top right,
|
||||
while it actuall already was on place. That's why we have this element twice.
|
||||
when switching layouts wouldn't anymore, causing it to animate from the center to the top right,
|
||||
while it actually already was on place. That's why we have this element twice.
|
||||
*/}
|
||||
<div className="pointer-events-none opacity-0" aria-hidden>
|
||||
<LayoutToggleWithData />
|
||||
|
||||
@@ -69,7 +69,7 @@ export const AvailableTimes = ({
|
||||
</span>
|
||||
|
||||
{showTimeFormatToggle && (
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto rtl:mr-auto">
|
||||
<TimeFormatToggle />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function AvailableEventLocations({ locations }: { locations: LocationObje
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : filteredLocations.length === 1 ? (
|
||||
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm">
|
||||
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm rtl:mr-2">
|
||||
{filteredLocations}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -86,7 +86,7 @@ export const EventMetaBlock = ({
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>{!!Icon && <Icon className="relative z-20 mr-2 mt-[2px] h-4 w-4 flex-shrink-0" />}</>
|
||||
<>{!!Icon && <Icon className="relative z-20 mr-2 mt-[2px] h-4 w-4 flex-shrink-0 rtl:ml-2" />}</>
|
||||
)}
|
||||
<div className={classNames("relative z-10 max-w-full break-words", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
@@ -128,9 +128,9 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
|
||||
case EventDetailBlocks.LOCATION:
|
||||
if (!event?.locations?.length) return null;
|
||||
return (
|
||||
<React.Fragment key={block}>
|
||||
<EventMetaBlock key={block}>
|
||||
<AvailableEventLocations locations={event.locations} />
|
||||
</React.Fragment>
|
||||
</EventMetaBlock>
|
||||
);
|
||||
|
||||
case EventDetailBlocks.REQUIRES_CONFIRMATION:
|
||||
|
||||
@@ -20,7 +20,9 @@ export default function getLocationsOptionsForSelect(
|
||||
const type = eventLocation.type;
|
||||
|
||||
return {
|
||||
label: t(locationString),
|
||||
// XYZ: is considered a namespace in i18next https://www.i18next.com/principles/namespaces and thus it get's cleaned up.
|
||||
// Beacause there can be a URL in here, simply don't translate it if it starts with http: or https:. This would allow us to keep supporting namespaces if we plan to use them
|
||||
label: locationString.search(/^https?:/) !== -1 ? locationString : t(locationString),
|
||||
value: type,
|
||||
inputPlaceholder: t(eventLocation?.attendeeInputPlaceholder || ""),
|
||||
};
|
||||
|
||||
@@ -503,7 +503,7 @@ async function handler(req: CustomRequest) {
|
||||
};
|
||||
|
||||
const successPayment = bookingToDelete.payment.find((payment) => payment.success);
|
||||
if (!successPayment) {
|
||||
if (!successPayment?.externalId) {
|
||||
throw new Error("Cannot reject a booking without a successful payment");
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ export type DatePickerProps = {
|
||||
onChange: (date: Dayjs) => void;
|
||||
/** Fires when the month is changed. */
|
||||
onMonthChange?: (date: Dayjs) => void;
|
||||
/** which date is currently selected (not tracked from here) */
|
||||
selected?: Dayjs | null;
|
||||
/** which date or dates are currently selected (not tracked from here) */
|
||||
selected?: Dayjs | Dayjs[] | null;
|
||||
/** defaults to current date. */
|
||||
minDate?: Dayjs;
|
||||
/** Furthest date selectable in the future, default = UNLIMITED */
|
||||
@@ -147,11 +147,16 @@ const Days = ({
|
||||
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
|
||||
|
||||
const isActive = (day: dayjs.Dayjs) => {
|
||||
// for selecting a range of dates
|
||||
if (Array.isArray(selected)) {
|
||||
return Array.isArray(selected) && selected?.some((e) => yyyymmdd(e) === yyyymmdd(day));
|
||||
}
|
||||
|
||||
if (selected && yyyymmdd(selected) === yyyymmdd(day)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// for multiple dates select
|
||||
// for selecting multiple dates for an event
|
||||
if (
|
||||
eventSlug &&
|
||||
selectedDatesAndTimes &&
|
||||
@@ -241,7 +246,7 @@ const DatePicker = ({
|
||||
<div className="flex">
|
||||
<Button
|
||||
className={classNames(
|
||||
"group p-1 opacity-70 hover:opacity-100",
|
||||
"group p-1 opacity-70 hover:opacity-100 rtl:rotate-180",
|
||||
!browsingDate.isAfter(dayjs()) &&
|
||||
"disabled:text-bookinglighter hover:bg-background hover:opacity-70"
|
||||
)}
|
||||
@@ -253,7 +258,7 @@ const DatePicker = ({
|
||||
StartIcon={ChevronLeft}
|
||||
/>
|
||||
<Button
|
||||
className="group p-1 opacity-70 hover:opacity-100"
|
||||
className="group p-1 opacity-70 hover:opacity-100 rtl:rotate-180"
|
||||
onClick={() => changeMonth(+1)}
|
||||
data-testid="incrementMonth"
|
||||
color="minimal"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
type Props = {
|
||||
days: dayjs.Dayjs[];
|
||||
@@ -9,10 +10,14 @@ type Props = {
|
||||
};
|
||||
|
||||
export function DateValues({ days, containerNavRef }: Props) {
|
||||
const { i18n } = useLocale();
|
||||
const formatDate = (date: dayjs.Dayjs): string => {
|
||||
return new Intl.DateTimeFormat(i18n.language, { weekday: "short" }).format(date.toDate());
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={containerNavRef}
|
||||
className="bg-default dark:bg-muted border-b-subtle sticky top-[var(--calendar-dates-sticky-offset,0px)] z-[80] flex-none border-b sm:pr-8">
|
||||
className="bg-default dark:bg-muted border-b-subtle rtl:border-r-default sticky top-[var(--calendar-dates-sticky-offset,0px)] z-[80] flex-none border-b border-r sm:pr-8">
|
||||
<div className="text-subtle flex text-sm leading-6 sm:hidden" data-dayslength={days.length}>
|
||||
{days.map((day) => {
|
||||
const isToday = dayjs().isSame(day, "day");
|
||||
@@ -34,7 +39,7 @@ export function DateValues({ days, containerNavRef }: Props) {
|
||||
})}
|
||||
</div>
|
||||
<div className="text-subtle -mr-px hidden auto-cols-fr text-sm leading-6 sm:flex ">
|
||||
<div className="border-default col-end-1 w-14 border-l" />
|
||||
<div className="border-default col-end-1 w-14 ltr:border-l" />
|
||||
{days.map((day) => {
|
||||
const isToday = dayjs().isSame(day, "day");
|
||||
return (
|
||||
@@ -45,7 +50,7 @@ export function DateValues({ days, containerNavRef }: Props) {
|
||||
isToday && "font-bold"
|
||||
)}>
|
||||
<span>
|
||||
{day.format("ddd")}{" "}
|
||||
{formatDate(day)}{" "}
|
||||
<span
|
||||
className={classNames(
|
||||
"items-center justify-center p-1",
|
||||
|
||||
@@ -107,7 +107,7 @@ function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
|
||||
{!isDisabled && hoverEventDuration !== 0 && (
|
||||
<div
|
||||
className={classNames(
|
||||
"opacity-4 bg-brand-default hover:bg-brand-default text-brand dark:border-emphasis absolute hidden rounded-[4px] border-[1px] border-gray-900 px-[6px] py-1 text-xs font-semibold leading-5 group-hover:flex group-hover:cursor-pointer",
|
||||
"opacity-4 bg-brand-default hover:bg-brand-default text-brand dark:border-emphasis absolute hidden rounded-[4px] p-[6px] text-xs font-semibold leading-5 group-hover:flex group-hover:cursor-pointer",
|
||||
hoverEventDuration && hoverEventDuration > 15 && "items-start pt-3",
|
||||
hoverEventDuration && hoverEventDuration < 15 && "items-center"
|
||||
)}
|
||||
|
||||
@@ -26,14 +26,14 @@ export const HorizontalLines = ({
|
||||
<div className="row-end-1 h-[--calendar-offset-top] " ref={containerOffsetRef} />
|
||||
{hours.map((hour) => (
|
||||
<div key={`${id}-${hour.get("hour")}`}>
|
||||
<div className="text-muted sticky left-0 z-20 -ml-14 -mt-2.5 w-14 pr-2 text-right text-xs leading-5">
|
||||
<div className="text-muted sticky left-0 z-20 -ml-14 -mt-2.5 w-14 pr-2 text-right text-xs leading-5 rtl:-mr-14">
|
||||
{/* We need to force the minute to zero, because otherwise in ex GMT+5.5, it would show :30 minute times (but at the positino of :00) */}
|
||||
{hour.minute(0).format(timeFormat)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div key={`${id}-${finalHour}`}>
|
||||
<div className="text-muted sticky left-0 z-20 -ml-14 -mt-2.5 w-14 pr-2 text-right text-xs leading-5">
|
||||
<div className="text-muted sticky left-0 z-20 -ml-14 -mt-2.5 w-14 pr-2 text-right text-xs leading-5 rtl:-mr-14">
|
||||
{finalHour}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import type dayjs from "@calcom/dayjs";
|
||||
|
||||
export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
|
||||
const isRTL = () => {
|
||||
const userLocale = navigator.language;
|
||||
const userLanguage = new Intl.Locale(userLocale).language;
|
||||
return ["ar", "he", "fa", "ur"].includes(userLanguage);
|
||||
};
|
||||
|
||||
const direction = isRTL() ? "rtl" : "ltr";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="divide-default pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid
|
||||
auto-cols-auto grid-rows-1 divide-x sm:pr-8">
|
||||
auto-cols-auto grid-rows-1 divide-x sm:pr-8"
|
||||
dir={direction}
|
||||
style={{
|
||||
direction: direction,
|
||||
}}>
|
||||
{days.map((_, i) => (
|
||||
<div
|
||||
key={`Key_vertical_${i}`}
|
||||
className="row-span-full"
|
||||
style={{
|
||||
gridColumnStart: i + 1,
|
||||
gridColumnStart: isRTL() ? days.length - i : i + 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { z } from "zod";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
@@ -69,7 +68,7 @@ const OrgProfileView = () => {
|
||||
onSuccess: (org) => {
|
||||
if (org) {
|
||||
form.setValue("name", org.name || "");
|
||||
form.setValue("slug", org.slug || "");
|
||||
form.setValue("slug", org.slug || org.metadata?.requestedSlug || "");
|
||||
form.setValue("logo", org.logo || "");
|
||||
form.setValue("bio", org.bio || "");
|
||||
if (org.slug === null && (org?.metadata as Prisma.JsonObject)?.requestedSlug) {
|
||||
@@ -89,20 +88,8 @@ const OrgProfileView = () => {
|
||||
!currentOrganisation.bio ||
|
||||
!currentOrganisation.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
showToast(t("your_org_disbanded_successfully"), "success");
|
||||
router.push(`${WEBAPP_URL}/teams`);
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgBranding) return null;
|
||||
|
||||
function deleteTeam() {
|
||||
if (currentOrganisation?.id) deleteTeamMutation.mutate({ teamId: currentOrganisation.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Meta title={t("profile")} description={t("profile_org_description")} />
|
||||
@@ -168,15 +155,21 @@ const OrgProfileView = () => {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="slug"
|
||||
label={t("org_url")}
|
||||
value={currentOrganisation.slug ?? ""}
|
||||
disabled
|
||||
addOnSuffix={`.${subdomainSuffix()}`}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="slug"
|
||||
label={t("org_url")}
|
||||
value={value}
|
||||
disabled
|
||||
addOnSuffix={`.${subdomainSuffix()}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Label>{t("about")}</Label>
|
||||
<Editor
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import getRawBody from "raw-body";
|
||||
import * as z from "zod";
|
||||
|
||||
import { paypalCredentialKeysSchema } from "@calcom/app-store/paypal/lib";
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function getEventType(id: number) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaymentSuccess(
|
||||
payload: z.infer<typeof eventSchema>,
|
||||
rawPayload: string,
|
||||
webhookHeaders: WebHookHeadersType
|
||||
) {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: payload?.resource?.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
bookingId: true,
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: payment.bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
eventType: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
uid: true,
|
||||
paid: true,
|
||||
destinationCalendar: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
|
||||
// Probably booking it's already paid from /capture but we need to send confirmation email
|
||||
const foundCredentials = await findPaymentCredentials(booking.id);
|
||||
if (!foundCredentials) throw new HttpCode({ statusCode: 204, message: "No credentials found" });
|
||||
const { webhookId, ...credentials } = foundCredentials;
|
||||
|
||||
const paypalClient = new Paypal(credentials);
|
||||
await paypalClient.getAccessToken();
|
||||
await paypalClient.verifyWebhook({
|
||||
body: {
|
||||
auth_algo: webhookHeaders["paypal-auth-algo"],
|
||||
cert_url: webhookHeaders["paypal-cert-url"],
|
||||
transmission_id: webhookHeaders["paypal-transmission-id"],
|
||||
transmission_sig: webhookHeaders["paypal-transmission-sig"],
|
||||
transmission_time: webhookHeaders["paypal-transmission-time"],
|
||||
webhook_id: webhookId,
|
||||
webhook_event: rawPayload,
|
||||
},
|
||||
});
|
||||
|
||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
||||
}
|
||||
|
||||
const { user } = booking;
|
||||
|
||||
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||
...getCalEventResponses({
|
||||
booking: booking,
|
||||
bookingFields: booking.eventType?.bookingFields || null,
|
||||
}),
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name!,
|
||||
timeZone: user.timeZone,
|
||||
language: { translate: t, locale: user.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
|
||||
};
|
||||
|
||||
if (booking.location) evt.location = booking.location;
|
||||
|
||||
const bookingData: Prisma.BookingUpdateInput = {
|
||||
paid: true,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
if (isConfirmed) {
|
||||
const eventManager = new EventManager(user);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
}
|
||||
|
||||
if (eventTypeRaw?.requiresConfirmation) {
|
||||
delete bookingData.status;
|
||||
}
|
||||
|
||||
if (!payment?.success) {
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (booking.status === "PENDING") {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: bookingData,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
|
||||
await handleConfirmation({ user, evt, prisma, bookingId: booking.id, booking, paid: true });
|
||||
} else {
|
||||
await sendScheduledEmails({ ...evt });
|
||||
}
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const bodyRaw = await getRawBody(req);
|
||||
const headers = req.headers;
|
||||
const bodyAsString = bodyRaw.toString();
|
||||
|
||||
const parseHeaders = webhookHeadersSchema.safeParse(headers);
|
||||
if (!parseHeaders.success) {
|
||||
console.error(parseHeaders.error);
|
||||
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
|
||||
}
|
||||
const parse = eventSchema.safeParse(JSON.parse(bodyAsString));
|
||||
if (!parse.success) {
|
||||
console.error(parse.error);
|
||||
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
|
||||
}
|
||||
|
||||
const { data: parsedPayload } = parse;
|
||||
|
||||
if (parsedPayload.event_type === "CHECKOUT.ORDER.APPROVED") {
|
||||
return await handlePaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
|
||||
}
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
console.error(`Webhook Error: ${err.message}`);
|
||||
res.status(200).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return a response to acknowledge receipt of the event
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
const resourceSchema = z
|
||||
.object({
|
||||
create_time: z.string(),
|
||||
id: z.string(),
|
||||
payment_source: z.object({
|
||||
paypal: z.object({}).optional(),
|
||||
}),
|
||||
intent: z.string(),
|
||||
payer: z.object({
|
||||
email_address: z.string(),
|
||||
payer_id: z.string(),
|
||||
address: z.object({
|
||||
country_code: z.string(),
|
||||
}),
|
||||
}),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const eventSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
create_time: z.string(),
|
||||
resource_type: z.string(),
|
||||
event_type: z.string(),
|
||||
summary: z.string(),
|
||||
resource: resourceSchema,
|
||||
status: z.string().optional(),
|
||||
event_version: z.string(),
|
||||
resource_version: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const webhookHeadersSchema = z
|
||||
.object({
|
||||
"paypal-auth-algo": z.string(),
|
||||
"paypal-cert-url": z.string(),
|
||||
"paypal-transmission-id": z.string(),
|
||||
"paypal-transmission-sig": z.string(),
|
||||
"paypal-transmission-time": z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type WebHookHeadersType = z.infer<typeof webhookHeadersSchema>;
|
||||
|
||||
export const findPaymentCredentials = async (
|
||||
bookingId: number
|
||||
): Promise<{ clientId: string; secretKey: string; webhookId: string }> => {
|
||||
try {
|
||||
// @TODO: what about team bookings with paypal?
|
||||
const userFromBooking = await prisma?.booking.findFirst({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userFromBooking) throw new Error("No user found");
|
||||
|
||||
const credentials = await prisma?.credential.findFirst({
|
||||
where: {
|
||||
appId: "paypal",
|
||||
userId: userFromBooking?.userId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
if (!credentials) {
|
||||
throw new Error("No credentials found");
|
||||
}
|
||||
const parsedCredentials = paypalCredentialKeysSchema.safeParse(credentials?.key);
|
||||
if (!parsedCredentials.success) {
|
||||
throw new Error("Credentials malformed");
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: parsedCredentials.data.client_id,
|
||||
secretKey: parsedCredentials.data.secret_key,
|
||||
webhookId: parsedCredentials.data.webhook_id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return {
|
||||
clientId: "",
|
||||
secretKey: "",
|
||||
webhookId: "",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import type { SyntheticEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -17,13 +16,14 @@ import type { EventType } from ".prisma/client";
|
||||
|
||||
type Props = {
|
||||
payment: Omit<Payment, "id" | "fee" | "success" | "refunded" | "externalId" | "data"> & {
|
||||
data: StripePaymentData | StripeSetupIntentData;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
eventType: { id: number; successRedirectUrl: EventType["successRedirectUrl"] };
|
||||
user: { username: string | null };
|
||||
location?: string | null;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
type States =
|
||||
@@ -162,26 +162,18 @@ const ELEMENT_STYLES_DARK: stripejs.Appearance = {
|
||||
};
|
||||
|
||||
export default function PaymentComponent(props: Props) {
|
||||
const stripePromise = getStripe((props.payment.data as StripePaymentData).stripe_publishable_key);
|
||||
const paymentOption = props.payment.paymentOption;
|
||||
const stripePromise = getStripe(props.payment.data.stripe_publishable_key as any);
|
||||
const [darkMode, setDarkMode] = useState<boolean>(false);
|
||||
let clientSecret: string | null;
|
||||
|
||||
useEffect(() => {
|
||||
setDarkMode(window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
}, []);
|
||||
|
||||
if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) {
|
||||
clientSecret = props.payment.data.setupIntent.client_secret;
|
||||
} else if (!("setupIntent" in props.payment.data)) {
|
||||
clientSecret = props.payment.data.client_secret;
|
||||
}
|
||||
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: clientSecret!,
|
||||
clientSecret: props.clientSecret,
|
||||
appearance: darkMode ? ELEMENT_STYLES_DARK : ELEMENT_STYLES,
|
||||
}}>
|
||||
<PaymentForm {...props} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -15,7 +16,20 @@ import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { CreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { PaymentPageProps } from "../pages/payment";
|
||||
import PaymentComponent from "./Payment";
|
||||
|
||||
const StripePaymentComponent = dynamic(() => import("./Payment"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const PaypalPaymentComponent = dynamic(
|
||||
() =>
|
||||
import("@calcom/app-store/paypal/components/PaypalPaymentComponent").then(
|
||||
(m) => m.PaypalPaymentComponent
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
const { t, i18n } = useLocale();
|
||||
@@ -119,7 +133,8 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
<div className="text-default mt-4 text-center dark:text-gray-300">{t("paid")}</div>
|
||||
)}
|
||||
{props.payment.appId === "stripe" && !props.payment.success && (
|
||||
<PaymentComponent
|
||||
<StripePaymentComponent
|
||||
clientSecret={props.clientSecret}
|
||||
payment={props.payment}
|
||||
eventType={props.eventType}
|
||||
user={props.user}
|
||||
@@ -128,6 +143,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
bookingUid={props.booking.uid}
|
||||
/>
|
||||
)}
|
||||
{props.payment.appId === "paypal" && !props.payment.success && (
|
||||
<PaypalPaymentComponent payment={props.payment} />
|
||||
)}
|
||||
{props.payment.refunded && (
|
||||
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Payment } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
import { ssrInit } from "../../../../../apps/web/server/lib/ssr";
|
||||
|
||||
export type PaymentPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
@@ -88,9 +88,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
if (!rawPayment) return { notFound: true };
|
||||
|
||||
const { data, booking: _booking, ...restPayment } = rawPayment;
|
||||
|
||||
const payment = {
|
||||
...restPayment,
|
||||
data: data as unknown as StripePaymentData | StripeSetupIntentData,
|
||||
data: data as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (!_booking) return { notFound: true };
|
||||
@@ -135,7 +136,28 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
booking,
|
||||
trpcState: ssr.dehydrate(),
|
||||
payment,
|
||||
clientSecret: getClientSecretFromPayment(payment),
|
||||
profile,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
|
||||
return !!x && typeof x === "object" && key in x;
|
||||
}
|
||||
|
||||
function getClientSecretFromPayment(
|
||||
payment: Omit<Partial<Payment>, "data"> & { data: Record<string, unknown> }
|
||||
) {
|
||||
if (
|
||||
payment.paymentOption === "HOLD" &&
|
||||
hasStringProp(payment.data, "setupIntent") &&
|
||||
hasStringProp(payment.data.setupIntent, "client_secret")
|
||||
) {
|
||||
return payment.data.setupIntent.client_secret;
|
||||
}
|
||||
if (hasStringProp(payment.data, "client_secret")) {
|
||||
return payment.data.client_secret;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ const getUserAdminTeams = async ({
|
||||
},
|
||||
}),
|
||||
},
|
||||
orderBy: {
|
||||
orgUsers: { _count: "desc" },
|
||||
},
|
||||
});
|
||||
|
||||
if (teams.length && getUserInfo) {
|
||||
@@ -76,7 +79,7 @@ const getUserAdminTeams = async ({
|
||||
if (user) {
|
||||
const userObject = {
|
||||
id: user.id,
|
||||
name: user.name || "Nameless",
|
||||
name: user.name || "me",
|
||||
logo: user?.avatar === "" ? null : user?.avatar,
|
||||
isUser: true,
|
||||
...(includeCredentials && { credentials: user.credentials }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import type { WorkingHours } from "@calcom/types/schedule";
|
||||
@@ -48,10 +49,26 @@ const DateOverrideForm = ({
|
||||
value[0].end.getUTCMinutes() === 0
|
||||
);
|
||||
|
||||
const [date, setDate] = useState<Dayjs | null>(value ? dayjs.utc(value[0].start) : null);
|
||||
const [selectedDates, setSelectedDates] = useState<Dayjs[]>(value ? [dayjs.utc(value[0].start)] : []);
|
||||
|
||||
const onDateChange = (newDate: Dayjs) => {
|
||||
// If clicking on a selected date unselect it
|
||||
if (selectedDates.some((date) => yyyymmdd(date) === yyyymmdd(newDate))) {
|
||||
setSelectedDates(selectedDates.filter((date) => yyyymmdd(date) !== yyyymmdd(newDate)));
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's not editing we can allow multiple select
|
||||
if (!value) {
|
||||
setSelectedDates((prev) => [...prev, newDate]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDates([newDate]);
|
||||
};
|
||||
|
||||
const defaultRanges = (workingHours || []).reduce((dayRanges: TimeRange[], workingHour) => {
|
||||
if (date && workingHour.days.includes(date.day())) {
|
||||
if (selectedDates[0] && workingHour.days.includes(selectedDates[0].day())) {
|
||||
dayRanges.push({
|
||||
start: dayjs.utc().startOf("day").add(workingHour.startTime, "minute").toDate(),
|
||||
end: dayjs.utc().startOf("day").add(workingHour.endTime, "minute").toDate(),
|
||||
@@ -91,31 +108,49 @@ const DateOverrideForm = ({
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
if (!date) return;
|
||||
if (selectedDates.length === 0) return;
|
||||
|
||||
const datesInRanges: TimeRange[] = [];
|
||||
|
||||
if (!datesUnavailable) {
|
||||
selectedDates.map((date) => {
|
||||
values.range.map((item) => {
|
||||
datesInRanges.push({
|
||||
start: date
|
||||
.hour(item.start.getUTCHours())
|
||||
.minute(item.start.getUTCMinutes())
|
||||
.utc(true)
|
||||
.toDate(),
|
||||
end: date.hour(item.end.getUTCHours()).minute(item.end.getUTCMinutes()).utc(true).toDate(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onChange(
|
||||
(datesUnavailable
|
||||
? [
|
||||
{
|
||||
datesUnavailable
|
||||
? selectedDates.map((date) => {
|
||||
return {
|
||||
start: date.utc(true).startOf("day").toDate(),
|
||||
end: date.utc(true).startOf("day").add(1, "day").toDate(),
|
||||
},
|
||||
]
|
||||
: values.range
|
||||
).map((item) => ({
|
||||
start: date.hour(item.start.getUTCHours()).minute(item.start.getUTCMinutes()).utc(true).toDate(),
|
||||
end: date.hour(item.end.getUTCHours()).minute(item.end.getUTCMinutes()).utc(true).toDate(),
|
||||
}))
|
||||
};
|
||||
})
|
||||
: datesInRanges
|
||||
);
|
||||
onClose();
|
||||
}}
|
||||
className="p-6 sm:flex sm:p-0">
|
||||
<div className={classNames(date && "sm:border-subtle w-full sm:border-r sm:pr-6", "sm:p-4 md:p-8")}>
|
||||
<div
|
||||
className={classNames(
|
||||
selectedDates[0] && "sm:border-subtle w-full sm:border-r sm:pr-6",
|
||||
"sm:p-4 md:p-8"
|
||||
)}>
|
||||
<DialogHeader title={t("date_overrides_dialog_title")} />
|
||||
<DatePicker
|
||||
excludedDates={excludedDates}
|
||||
weekStart={0}
|
||||
selected={date}
|
||||
onChange={(day) => setDate(day)}
|
||||
selected={selectedDates}
|
||||
onChange={(day) => onDateChange(day)}
|
||||
onMonthChange={(newMonth) => {
|
||||
setBrowsingDate(newMonth);
|
||||
}}
|
||||
@@ -123,7 +158,7 @@ const DateOverrideForm = ({
|
||||
locale={isLocaleReady ? i18n.language : "en"}
|
||||
/>
|
||||
</div>
|
||||
{date && (
|
||||
{selectedDates[0] && (
|
||||
<div className="relative mt-8 flex w-full flex-col sm:mt-0 sm:p-4 md:p-8">
|
||||
<div className="mb-4 flex-grow space-y-4">
|
||||
<p className="text-medium text-emphasis text-sm">{t("date_overrides_dialog_which_hours")}</p>
|
||||
@@ -148,7 +183,7 @@ const DateOverrideForm = ({
|
||||
className="ml-2"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={!date}
|
||||
disabled={selectedDates.length === 0}
|
||||
data-testid="add-override-submit-btn">
|
||||
{value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")}
|
||||
</Button>
|
||||
|
||||
@@ -24,6 +24,7 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, DESKTOP_APP_LINK, JOIN_DISCORD, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import getBrandColours from "@calcom/lib/getBrandColours";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useIsomorphicLayoutEffect } from "@calcom/lib/hooks/useIsomorphicLayoutEffect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
|
||||
@@ -111,7 +112,6 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
||||
const { data: session, status } = useSession();
|
||||
const loading = status === "loading";
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPublic) {
|
||||
return;
|
||||
@@ -307,6 +307,8 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||
const { t } = useLocale();
|
||||
const { data: user } = useMeQuery();
|
||||
const utils = trpc.useContext();
|
||||
const bookerUrl = useBookerUrl();
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
@@ -370,7 +372,7 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||
)}>
|
||||
<Avatar
|
||||
size={small ? "xs" : "xsm"}
|
||||
imageSrc={WEBAPP_URL + "/" + user.username + "/avatar.png"}
|
||||
imageSrc={bookerUrl + "/" + user.username + "/avatar.png"}
|
||||
alt={user.username || "Nameless User"}
|
||||
className="overflow-hidden"
|
||||
/>
|
||||
@@ -828,7 +830,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
|
||||
<div className="flex h-full flex-col justify-between py-3 lg:pt-4">
|
||||
<header className="items-center justify-between md:hidden lg:flex">
|
||||
{!isOrgBrandingDataFetched ? null : orgBranding ? (
|
||||
<Link href="/event-types" className="px-1.5">
|
||||
<Link href="/settings/organizations/profile" className="px-1.5">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Avatar
|
||||
alt={`${orgBranding.name} logo`}
|
||||
|
||||
@@ -117,6 +117,29 @@ describe("buildDateRanges", () => {
|
||||
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
|
||||
});
|
||||
});
|
||||
it("should handle day shifts correctly", () => {
|
||||
const item = [
|
||||
{
|
||||
date: new Date(Date.UTC(2023, 7, 15)),
|
||||
startTime: new Date(Date.UTC(2023, 5, 12, 9, 0)), // 9 AM
|
||||
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
|
||||
},
|
||||
{
|
||||
date: new Date(Date.UTC(2023, 7, 15)),
|
||||
startTime: new Date(Date.UTC(2023, 5, 12, 19, 0)), // 7 PM
|
||||
endTime: new Date(Date.UTC(2023, 5, 12, 21, 0)), // 9 PM
|
||||
},
|
||||
];
|
||||
|
||||
const timeZone = "Pacific/Honolulu";
|
||||
|
||||
const dateFrom = dayjs.tz("2023-08-15", "Europe/Brussels").startOf("day");
|
||||
const dateTo = dayjs.tz("2023-08-15", "Europe/Brussels").endOf("day");
|
||||
|
||||
const result = buildDateRanges({ availability: item, timeZone, dateFrom, dateTo });
|
||||
// this happened only on Europe/Brussels, Europe/Amsterdam was 2023-08-15T17:00:00-10:00 (as it should be)
|
||||
expect(result[0].end.format()).not.toBe("2023-08-14T17:00:00-10:00");
|
||||
});
|
||||
it("should return correct date ranges with full day unavailable date override", () => {
|
||||
const items = [
|
||||
{
|
||||
|
||||
@@ -56,16 +56,23 @@ export function processWorkingHours({
|
||||
}
|
||||
|
||||
export function processDateOverride({ item, timeZone }: { item: DateOverride; timeZone: string }) {
|
||||
const date = dayjs.utc(item.date);
|
||||
|
||||
const startTime = dayjs(item.startTime).utc().subtract(dayjs().tz(timeZone).utcOffset(), "minute");
|
||||
const endTime = dayjs(item.endTime).utc().subtract(dayjs().tz(timeZone).utcOffset(), "minute");
|
||||
|
||||
const diffDays = startTime.startOf("day").diff(dayjs.utc(item.startTime).startOf("day"), "day");
|
||||
|
||||
const startDate = dayjs
|
||||
.utc(item.date)
|
||||
.startOf("day")
|
||||
.add(item.startTime.getUTCHours(), "hours")
|
||||
.add(item.startTime.getUTCMinutes(), "minutes")
|
||||
.second(0)
|
||||
.tz(timeZone, true);
|
||||
const endDate = dayjs
|
||||
.utc(item.date)
|
||||
.startOf("day")
|
||||
.add(item.endTime.getUTCHours(), "hours")
|
||||
.add(item.endTime.getUTCMinutes(), "minutes")
|
||||
.second(0)
|
||||
.tz(timeZone, true);
|
||||
return {
|
||||
start: date.add(diffDays, "day").hour(startTime.hour()).minute(startTime.minute()).second(0).tz(timeZone),
|
||||
end: date.add(diffDays, "day").hour(endTime.hour()).minute(endTime.minute()).second(0).tz(timeZone),
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -28,11 +29,14 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
|
||||
},
|
||||
});
|
||||
|
||||
const metadata = teamMetadataSchema.parse(membership?.team.metadata);
|
||||
|
||||
return {
|
||||
user: {
|
||||
role: membership?.role,
|
||||
accepted: membership?.accepted,
|
||||
},
|
||||
...membership?.team,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-grow flex-col sm:flex-row">
|
||||
<div className="ml-3 ">
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<h3 className="text-sm font-medium">{props.title}</h3>
|
||||
<div className="text-sm">{props.message}</div>
|
||||
</div>
|
||||
|
||||
@@ -265,7 +265,7 @@ const InstallAppButtonChild = ({
|
||||
);
|
||||
}}>
|
||||
<p className="text-left">
|
||||
{team.name} {isInstalledTeamOrUser && `(${t("installed")})`}
|
||||
{t(team.name)} {isInstalledTeamOrUser && `(${t("installed")})`}
|
||||
</p>
|
||||
</DropdownItem>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ export type ButtonProps = ButtonBaseProps &
|
||||
);
|
||||
|
||||
export const buttonClasses = cva(
|
||||
"inline-flex items-center text-sm font-medium relative rounded-md transition-colors disabled:cursor-not-allowed",
|
||||
"whitespace-nowrap inline-flex items-center text-sm font-medium relative rounded-md transition-colors disabled:cursor-not-allowed",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T
|
||||
{...props}
|
||||
onValueChange={onValueChange}
|
||||
className={classNames(
|
||||
"min-h-9 border-default bg-default relative inline-flex gap-0.5 rounded-md border p-1",
|
||||
"min-h-9 border-default bg-default relative inline-flex gap-0.5 rounded-md border p-1 rtl:flex-row-reverse",
|
||||
props.className,
|
||||
isFullWidth && "w-full"
|
||||
)}>
|
||||
|
||||
@@ -4426,6 +4426,15 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/paypal@workspace:packages/app-store/paypal":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/paypal@workspace:packages/app-store/paypal"
|
||||
dependencies:
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/types": "*"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/ping@workspace:packages/app-store/ping":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/ping@workspace:packages/app-store/ping"
|
||||
|
||||
Reference in New Issue
Block a user