Compare commits

...
19 Commits
Author SHA1 Message Date
Peer Richelsen 1b3f676e57 Update package.json
release.yml / release (push) Canceled after 0s
2023-08-14 15:48:05 +02:00
Crowdin Bot 3e17b1c5ef New Crowdin translations by Github Action 2023-08-14 08:40:53 +00:00
Alex van Andel a466e71d4c chore: Simplified date overrides (#10728)
* chore: Simplified date overrides

* Fixed a test that had a date override that wasn't at midnight utc

* Wrote test that showed a fixed Europe/Brussels

* Lint fix
2023-08-14 09:38:08 +01:00
Crowdin Bot 5a7030d277 New Crowdin translations by Github Action 2023-08-14 06:44:24 +00:00
DanilaandUdit Takkar e72c25f02e chore: remove tailwind-scrollbar warning (#10523)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-08-14 08:41:56 +02:00
Peer Richelsen 06bc5facf3 chore: fix refund i18n message (#10731) 2023-08-13 14:26:25 +01:00
Nafees NazikandHariom Balhara 9c8dc1b92c fix: link escaping in booking page (#10360)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-08-12 21:31:43 +00:00
Joe Au-YeungandPeer Richelsen cfef92a7bc fix: App Install Dropdown Sort Properly [CAL-2285] (#10672)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-12 21:30:15 +00:00
Crowdin Bot 26149e2b04 New Crowdin translations by Github Action 2023-08-12 21:23:20 +00:00
ABDERRAHMANI IDRISSI HAMZAandOmar López 3ca43ce3f3 style: Fix text wrapping issue in button (#10725)
Co-authored-by: Omar López <zomars@me.com>
2023-08-12 23:21:09 +02:00
RaghulandRaghul D 7603ea6184 fix: border issue for time slots (#10577)
Co-authored-by: Raghul D <v-raghuld@microsoft.com>
2023-08-12 23:20:49 +02:00
Pradumn KumarandAlex van Andel 9c45da565e feat: add range of dates for availability over-ride (#10462)
* feat: add range of dates for availability over-ride

* chore: changed range select to multiple select

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-08-12 07:22:44 +00:00
Hariom Balhara f84f1f5dda Fix avatar for org in Shell top (#10712) 2023-08-12 00:19:27 +00:00
zomars 07f803e758 fix: paypal build fixes 2023-08-11 17:02:36 -07:00
Leo Giovanetti 268ab882cd Fixing org slug (#10538) 2023-08-11 23:57:22 +00:00
zomars 8052e6f6d4 fix: merge conflict 2023-08-11 16:56:38 -07:00
Anik Dhabal BabuandPeer Richelsen 99e9425257 fix: RTL issues on booking pages + email confirmation (#10526)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-11 16:39:25 -07:00
471b2687c4 feat: app paypal payment (#8797)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2023-08-11 23:29:48 +00:00
Peer Richelsen 9f7152c4eb link to org settings (#10718) 2023-08-11 21:27:29 +00:00
68 changed files with 1958 additions and 159 deletions
@@ -112,7 +112,7 @@ export const InstallAppButtonChild = ({
);
}}>
<p>
{team.name} {isInstalled && `(${t("installed")})`}
{t(team.name)} {isInstalled && `(${t("installed")})`}
</p>
</DropdownItem>
);
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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")}
+3 -3
View File
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+15 -15
View File
@@ -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"),
+1
View File
@@ -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"),
+8
View File
@@ -0,0 +1,8 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
---
{DESCRIPTION}
+42
View File
@@ -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" });
}
+94
View File
@@ -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(),
});
+3
View File
@@ -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&apos;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>
);
};
+18
View File
@@ -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"
}
+2
View File
@@ -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);
}
}
+395
View File
@@ -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>;
+1
View File
@@ -0,0 +1 @@
export * from "./PaymentService";
+14
View File
@@ -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 &quot;No&quot; for &quot;Using online platform&quot;.</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&apos;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

+38
View File
@@ -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) {
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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");
}
+10 -5
View File
@@ -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>
+5 -3
View File
@@ -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`}
+23
View File
@@ -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 = [
{
+16 -9
View File
@@ -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,
};
};
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -265,7 +265,7 @@ const InstallAppButtonChild = ({
);
}}>
<p className="text-left">
{team.name} {isInstalledTeamOrUser && `(${t("installed")})`}
{t(team.name)} {isInstalledTeamOrUser && `(${t("installed")})`}
</p>
</DropdownItem>
);
+1 -1
View File
@@ -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"
)}>
+9
View File
@@ -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"