Flow, UX and other improvements for hash my url feature (#2644)
* added toast feedback * updated flow * locale * updated locale data * removed unused booking call for reschedule flow * fixed hashedURL test * test adjustment * further test changes * added check in test to click check only if unchecked * Added private link quick copy button * fixed spacing * fix lint * consistency * moved create hash function out of component render Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
5464d4c010
commit
9322b4ab4c
4 changed files with 60 additions and 36 deletions
|
@ -26,7 +26,9 @@ import { useRouter } from "next/router";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Controller, Noop, useForm, UseFormReturn } from "react-hook-form";
|
import { Controller, Noop, useForm, UseFormReturn } from "react-hook-form";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
import short, { generate } from "short-uuid";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
import { v5 as uuidv5 } from "uuid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { SelectGifInput } from "@calcom/app-store/giphy/components";
|
import { SelectGifInput } from "@calcom/app-store/giphy/components";
|
||||||
|
@ -281,6 +283,14 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||||
|
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||||
|
|
||||||
|
const generateHashedLink = (id: number) => {
|
||||||
|
const translator = short();
|
||||||
|
const seed = `${id}:${new Date().getTime()}`;
|
||||||
|
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||||
|
return uid;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTokens = async () => {
|
const fetchTokens = async () => {
|
||||||
|
@ -315,6 +325,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
console.log(tokensList); // Just here to make sure it passes the gc hook. Can remove once actual use is made of tokensList.
|
console.log(tokensList); // Just here to make sure it passes the gc hook. Can remove once actual use is made of tokensList.
|
||||||
|
|
||||||
fetchTokens();
|
fetchTokens();
|
||||||
|
|
||||||
|
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0].id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
||||||
|
@ -461,9 +473,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
team ? `team/${team.slug}` : eventType.users[0].username
|
team ? `team/${team.slug}` : eventType.users[0].username
|
||||||
}/${eventType.slug}`;
|
}/${eventType.slug}`;
|
||||||
|
|
||||||
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${
|
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||||
eventType.hashedLink ? eventType.hashedLink.link : "xxxxxxxxxxxxxxxxx"
|
|
||||||
}/${eventType.slug}`;
|
|
||||||
|
|
||||||
const mapUserToValue = ({
|
const mapUserToValue = ({
|
||||||
id,
|
id,
|
||||||
|
@ -495,7 +505,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
currency: string;
|
currency: string;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
hideCalendarNotes: boolean;
|
hideCalendarNotes: boolean;
|
||||||
hashedLink: boolean;
|
hashedLink: string | undefined;
|
||||||
locations: { type: LocationType; address?: string; link?: string }[];
|
locations: { type: LocationType; address?: string; link?: string }[];
|
||||||
customInputs: EventTypeCustomInput[];
|
customInputs: EventTypeCustomInput[];
|
||||||
users: string[];
|
users: string[];
|
||||||
|
@ -1368,27 +1378,31 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
<Controller
|
<Controller
|
||||||
name="hashedLink"
|
name="hashedLink"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.hashedLink ? true : false}
|
defaultValue={hashedUrl}
|
||||||
render={() => (
|
render={() => (
|
||||||
<>
|
<>
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
id="hashedLink"
|
id="hashedLinkCheck"
|
||||||
name="hashedLink"
|
name="hashedLinkCheck"
|
||||||
label={t("hashed_link")}
|
label={t("private_link")}
|
||||||
description={t("hashed_link_description")}
|
description={t("private_link_description")}
|
||||||
defaultChecked={eventType.hashedLink ? true : false}
|
defaultChecked={eventType.hashedLink ? true : false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setHashedLinkVisible(e?.target.checked);
|
setHashedLinkVisible(e?.target.checked);
|
||||||
formMethods.setValue("hashedLink", e?.target.checked);
|
formMethods.setValue(
|
||||||
|
"hashedLink",
|
||||||
|
e?.target.checked ? hashedUrl : undefined
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hashedLinkVisible && (
|
{hashedLinkVisible && (
|
||||||
<div className="block items-center sm:flex">
|
<div className="!mt-1 block items-center sm:flex">
|
||||||
<div className="min-w-48 mb-4 sm:mb-0"></div>
|
<div className="min-w-48 mb-4 sm:mb-0"></div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="relative mt-1 flex w-full">
|
<div className="relative mt-1 flex w-full">
|
||||||
<input
|
<input
|
||||||
disabled
|
disabled
|
||||||
|
name="hashedLink"
|
||||||
data-testid="generated-hash-url"
|
data-testid="generated-hash-url"
|
||||||
type="text"
|
type="text"
|
||||||
className=" grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm"
|
className=" grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm"
|
||||||
|
@ -1403,9 +1417,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
<Button
|
<Button
|
||||||
color="minimal"
|
color="minimal"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(placeholderHashedLink);
|
||||||
if (eventType.hashedLink) {
|
if (eventType.hashedLink) {
|
||||||
navigator.clipboard.writeText(placeholderHashedLink);
|
showToast(t("private_link_copied"), "success");
|
||||||
showToast("Link copied!", "success");
|
} else {
|
||||||
|
showToast(t("enabled_after_update_description"), "warning");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -1836,6 +1852,22 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
|
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
|
||||||
{t("copy_link")}
|
{t("copy_link")}
|
||||||
</button>
|
</button>
|
||||||
|
{hashedLinkVisible && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(placeholderHashedLink);
|
||||||
|
if (eventType.hashedLink) {
|
||||||
|
showToast(t("private_link_copied"), "success");
|
||||||
|
} else {
|
||||||
|
showToast(t("enabled_after_update_description"), "warning");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
|
||||||
|
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
|
||||||
|
{t("copy_private_link")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<EmbedButton
|
<EmbedButton
|
||||||
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900"
|
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900"
|
||||||
eventTypeId={eventType.id}
|
eventTypeId={eventType.id}
|
||||||
|
|
|
@ -28,25 +28,19 @@ test.describe("hash my url", () => {
|
||||||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
||||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
await page.click('//*[@data-testid="show-advanced-settings"]');
|
||||||
// we wait for the hashedLink setting to load
|
// we wait for the hashedLink setting to load
|
||||||
await page.waitForSelector('//*[@id="hashedLink"]');
|
await page.waitForSelector('//*[@id="hashedLinkCheck"]');
|
||||||
await page.click('//*[@id="hashedLink"]');
|
// ignore if it is already checked, and click if unchecked
|
||||||
|
const isChecked = await page.isChecked('//*[@id="hashedLinkCheck"]');
|
||||||
|
!isChecked && (await page.click('//*[@id="hashedLinkCheck"]'));
|
||||||
|
// we wait for the hashedLink setting to load
|
||||||
|
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
|
||||||
|
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||||
// click update
|
// click update
|
||||||
await page.focus('//button[@type="submit"]');
|
await page.focus('//button[@type="submit"]');
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("book using generated url hash", async ({ page }) => {
|
test("book using generated url hash", async ({ page }) => {
|
||||||
// await page.pause();
|
|
||||||
await page.goto("/event-types");
|
|
||||||
// We wait until loading is finished
|
|
||||||
await page.waitForSelector('[data-testid="event-types"]');
|
|
||||||
await page.click('//ul[@data-testid="event-types"]/li[1]');
|
|
||||||
// We wait for the page to load
|
|
||||||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
|
||||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
|
||||||
// we wait for the hashedLink setting to load
|
|
||||||
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
|
|
||||||
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
|
||||||
await page.goto($url);
|
await page.goto($url);
|
||||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
await bookTimeSlot(page);
|
await bookTimeSlot(page);
|
||||||
|
|
|
@ -500,6 +500,7 @@
|
||||||
"user_from_team": "{{user}} from {{team}}",
|
"user_from_team": "{{user}} from {{team}}",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"link_copied": "Link copied!",
|
"link_copied": "Link copied!",
|
||||||
|
"private_link_copied": "Private link copied!",
|
||||||
"link_shared": "Link shared!",
|
"link_shared": "Link shared!",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
@ -614,8 +615,9 @@
|
||||||
"starting": "Starting",
|
"starting": "Starting",
|
||||||
"disable_guests": "Disable Guests",
|
"disable_guests": "Disable Guests",
|
||||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||||
"hashed_link": "Generate hashed URL",
|
"private_link": "Generate private URL",
|
||||||
"hashed_link_description": "Generate a hashed URL to share without exposing your Cal username",
|
"copy_private_link": "Copy private link",
|
||||||
|
"private_link_description": "Generate a private URL to share without exposing your Cal username",
|
||||||
"invitees_can_schedule": "Invitees can schedule",
|
"invitees_can_schedule": "Invitees can schedule",
|
||||||
"date_range": "Date Range",
|
"date_range": "Date Range",
|
||||||
"calendar_days": "calendar days",
|
"calendar_days": "calendar days",
|
||||||
|
@ -779,6 +781,7 @@
|
||||||
"you_will_only_view_it_once": "You will not be able to view it again once you close this modal.",
|
"you_will_only_view_it_once": "You will not be able to view it again once you close this modal.",
|
||||||
"copy_to_clipboard": "Copy to clipboard",
|
"copy_to_clipboard": "Copy to clipboard",
|
||||||
"enabled_after_update": "Enabled after update",
|
"enabled_after_update": "Enabled after update",
|
||||||
|
"enabled_after_update_description": "The private link will work after saving",
|
||||||
"confirm_delete_api_key": "Revoke this API key",
|
"confirm_delete_api_key": "Revoke this API key",
|
||||||
"revoke_api_key": "Revoke API key",
|
"revoke_api_key": "Revoke API key",
|
||||||
"api_key_copied": "API key copied!",
|
"api_key_copied": "API key copied!",
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma } from "@prisma/client";
|
import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma } from "@prisma/client";
|
||||||
import short from "short-uuid";
|
|
||||||
import { v5 as uuidv5 } from "uuid";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
|
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
|
||||||
|
@ -88,7 +86,7 @@ const EventTypeUpdateInput = _EventTypeModel
|
||||||
}),
|
}),
|
||||||
users: z.array(stringOrNumber).optional(),
|
users: z.array(stringOrNumber).optional(),
|
||||||
schedule: z.number().optional(),
|
schedule: z.number().optional(),
|
||||||
hashedLink: z.boolean(),
|
hashedLink: z.string(),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.merge(
|
.merge(
|
||||||
|
@ -318,19 +316,16 @@ export const eventTypesRouter = createProtectedRouter()
|
||||||
if (hashedLink) {
|
if (hashedLink) {
|
||||||
// check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection
|
// check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection
|
||||||
if (!connectedLink) {
|
if (!connectedLink) {
|
||||||
const translator = short();
|
|
||||||
const seed = `${input.eventName}:${input.id}:${new Date().getTime()}`;
|
|
||||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
|
||||||
// create a hashed link
|
// create a hashed link
|
||||||
await ctx.prisma.hashedLink.upsert({
|
await ctx.prisma.hashedLink.upsert({
|
||||||
where: {
|
where: {
|
||||||
eventTypeId: input.id,
|
eventTypeId: input.id,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
link: uid,
|
link: hashedLink,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
link: uid,
|
link: hashedLink,
|
||||||
eventType: {
|
eventType: {
|
||||||
connect: { id: input.id },
|
connect: { id: input.id },
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue