From 21867c9cd43b69dfd9b1857cea3291edd56adc03 Mon Sep 17 00:00:00 2001 From: Shrey Gupta Date: Mon, 2 May 2022 02:12:35 +0530 Subject: [PATCH] feat(app-store): Add Giphy app (#2580) --- .env.example | 6 + apps/web/pages/event-types/[type].tsx | 58 ++++++- apps/web/pages/success.tsx | 12 +- apps/web/public/static/locales/en/common.json | 2 + packages/app-store/components.tsx | 1 + packages/app-store/giphyother/_metadata.ts | 26 +++ packages/app-store/giphyother/api/add.ts | 43 +++++ packages/app-store/giphyother/api/index.ts | 2 + packages/app-store/giphyother/api/search.ts | 63 ++++++++ .../components/InstallAppButton.tsx | 17 ++ .../giphyother/components/SearchDialog.tsx | 151 ++++++++++++++++++ .../giphyother/components/SelectGifInput.tsx | 52 ++++++ .../app-store/giphyother/components/index.ts | 2 + packages/app-store/giphyother/index.ts | 4 + .../app-store/giphyother/lib/giphyManager.ts | 20 +++ packages/app-store/giphyother/lib/index.ts | 1 + packages/app-store/giphyother/package.json | 14 ++ packages/app-store/giphyother/static/icon.svg | 14 ++ packages/app-store/index.ts | 2 + packages/app-store/metadata.ts | 2 + 20 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 packages/app-store/giphyother/_metadata.ts create mode 100644 packages/app-store/giphyother/api/add.ts create mode 100644 packages/app-store/giphyother/api/index.ts create mode 100644 packages/app-store/giphyother/api/search.ts create mode 100644 packages/app-store/giphyother/components/InstallAppButton.tsx create mode 100644 packages/app-store/giphyother/components/SearchDialog.tsx create mode 100644 packages/app-store/giphyother/components/SelectGifInput.tsx create mode 100644 packages/app-store/giphyother/components/index.ts create mode 100644 packages/app-store/giphyother/index.ts create mode 100644 packages/app-store/giphyother/lib/giphyManager.ts create mode 100644 packages/app-store/giphyother/lib/index.ts create mode 100644 packages/app-store/giphyother/package.json create mode 100644 packages/app-store/giphyother/static/icon.svg diff --git a/.env.example b/.env.example index 9577aaa857..11d8a452fa 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ # - STRIPE # - TANDEM # - ZOOM +# - GIPHY # - LICENSE ************************************************************************************************* # Set this value to 'agree' to accept our license: @@ -168,4 +169,9 @@ TANDEM_BASE_URL="https://tandem.chat" # @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret ZOOM_CLIENT_ID= ZOOM_CLIENT_SECRET= + +# - GIPHY +# Used for the Giphy integration +# @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key +GIPHY_API_KEY= # ********************************************************************************************************* diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index c844b25fe0..1c4d4cfcb5 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -29,6 +29,7 @@ import { FormattedNumber, IntlProvider } from "react-intl"; import { JSONObject } from "superjson/dist/types"; import { z } from "zod"; +import { SelectGifInput } from "@calcom/app-store/giphyother/components"; import getApps, { getLocationOptions, hasIntegration } from "@calcom/app-store/utils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; @@ -199,7 +200,15 @@ const EventTypePage = (props: inferSSRProps) => { prefix: t("indefinitely_into_future"), }, ]; - const { eventType, locationOptions, team, teamMembers, hasPaymentIntegration, currency } = props; + const { + eventType, + locationOptions, + team, + teamMembers, + hasPaymentIntegration, + currency, + hasGiphyIntegration, + } = props; const router = useRouter(); @@ -496,6 +505,7 @@ const EventTypePage = (props: inferSSRProps) => { externalId: string; }; successRedirectUrl: string; + giphyThankYouPage: string; }>({ defaultValues: { locations: eventType.locations || [], @@ -914,6 +924,7 @@ const EventTypePage = (props: inferSSRProps) => { periodDates, periodCountCalendarDays, smartContractAddress, + giphyThankYouPage, beforeBufferTime, afterBufferTime, locations, @@ -931,11 +942,10 @@ const EventTypePage = (props: inferSSRProps) => { id: eventType.id, beforeEventBuffer: beforeBufferTime, afterEventBuffer: afterBufferTime, - metadata: smartContractAddress - ? { - smartContractAddress, - } - : "", + metadata: { + ...(smartContractAddress ? { smartContractAddress } : {}), + ...(giphyThankYouPage ? { giphyThankYouPage } : {}), + }, }); }} className="space-y-6"> @@ -1725,6 +1735,39 @@ const EventTypePage = (props: inferSSRProps) => { )} + {hasGiphyIntegration && ( + <> +
+
+
+ +
+ +
+
+
+
+
+
+ { + formMethods.setValue("giphyThankYouPage", url); + }} + /> +
+
+
+
+
+
+
+ + )} {/* )} */} @@ -2076,6 +2119,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => : false, }; + const hasGiphyIntegration = !!credentials.find((credential) => credential.type === "giphy_other"); + // backwards compat if (eventType.users.length === 0 && !eventType.team) { const fallbackUser = await prisma.user.findUnique({ @@ -2133,6 +2178,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => team: eventTypeObject.team || null, teamMembers, hasPaymentIntegration, + hasGiphyIntegration, currency, }, }; diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx index 65cda6e253..224a74d6c3 100644 --- a/apps/web/pages/success.tsx +++ b/apps/web/pages/success.tsx @@ -159,6 +159,8 @@ export default function Success(props: inferSSRProps) host: props.profile.name || "Nameless", t, }; + const metadata = props.eventType?.metadata as { giphyThankYouPage: string }; + const giphyImage = metadata?.giphyThankYouPage; const eventName = getEventName(eventNameObject); const needsConfirmation = eventType.requiresConfirmation && reschedule != "true"; @@ -245,8 +247,13 @@ export default function Success(props: inferSSRProps) aria-modal="true" aria-labelledby="modal-headline">
-
- {!needsConfirmation && } +
+ {giphyImage && !needsConfirmation && {"Gif} + {!giphyImage && !needsConfirmation && } {needsConfirmation && }
@@ -468,6 +475,7 @@ const getEventTypesFromDB = async (typeId: number) => { hideBranding: true, }, }, + metadata: true, }, }); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 84f69e9055..33476b7698 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -768,6 +768,8 @@ "edit_booking": "Edit booking", "reschedule_booking": "Reschedule booking", "former_time": "Former time", + "confirmation_page_gif": "Gif for confirmation page", + "search": "Search", "impersonate":"Impersonate", "impersonate_user_tip":"All uses of this feature is audited.", "impersonating_user_warning":"Impersonating username \"{{user}}\".", diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx index 3a529c3e6a..a40da13069 100644 --- a/packages/app-store/components.tsx +++ b/packages/app-store/components.tsx @@ -23,6 +23,7 @@ export const InstallAppButtonMap = { wipemycalother: dynamic(() => import("./wipemycalother/components/InstallAppButton")), jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")), huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")), + giphyother: dynamic(() => import("./giphyother/components/InstallAppButton")), }; export const InstallAppButton = ( diff --git a/packages/app-store/giphyother/_metadata.ts b/packages/app-store/giphyother/_metadata.ts new file mode 100644 index 0000000000..35dcf5deb6 --- /dev/null +++ b/packages/app-store/giphyother/_metadata.ts @@ -0,0 +1,26 @@ +import type { App } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Giphy", + description: _package.description, + installed: !!process.env.GIPHY_API_KEY, + category: "other", + // If using static next public folder, can then be referenced from the base URL (/). + imageSrc: "/api/app-store/giphyother/icon.svg", + logo: "/api/app-store/giphyother/icon.svg", + publisher: "Cal.com", + rating: 0, + reviews: 0, + slug: "giphy", + title: "Giphy", + trending: true, + type: "giphy_other", + url: "https://cal.com/apps/giphy", + variant: "other", + verified: true, + email: "help@cal.com", +} as App; + +export default metadata; diff --git a/packages/app-store/giphyother/api/add.ts b/packages/app-store/giphyother/api/add.ts new file mode 100644 index 0000000000..c49218c041 --- /dev/null +++ b/packages/app-store/giphyother/api/add.ts @@ -0,0 +1,43 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +/** + * This is an example endpoint for an app, these will run under `/api/integrations/[...args]` + * @param req + * @param res + */ +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 = "giphy_other"; + 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, + }, + }); + if (!installation) { + throw new Error("Unable to create user credential for giphy"); + } + } 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/installed" }); +} diff --git a/packages/app-store/giphyother/api/index.ts b/packages/app-store/giphyother/api/index.ts new file mode 100644 index 0000000000..8a66776473 --- /dev/null +++ b/packages/app-store/giphyother/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as search } from "./search"; diff --git a/packages/app-store/giphyother/api/search.ts b/packages/app-store/giphyother/api/search.ts new file mode 100644 index 0000000000..58d3b9d82b --- /dev/null +++ b/packages/app-store/giphyother/api/search.ts @@ -0,0 +1,63 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z, ZodError } from "zod"; + +import prisma from "@calcom/prisma"; + +import { GiphyManager } from "../lib"; + +const searchSchema = z.object({ + keyword: z.string(), + offset: z.number().min(0), +}); + +/** + * This is an example endpoint for an app, these will run under `/api/integrations/[...args]` + * @param req + * @param res + */ +async function handler(req: NextApiRequest, res: NextApiResponse) { + const userId = req.session?.user?.id; + if (!userId) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + try { + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + select: { + id: true, + locale: true, + }, + }); + const locale = user?.locale || "en"; + const { keyword, offset } = req.body; + const gifImageUrl = await GiphyManager.searchGiphy(locale, keyword, offset); + return res.status(200).json({ image: gifImageUrl }); + } catch (error: unknown) { + if (error instanceof Error) { + return res.status(500).json({ message: error.message }); + } + return res.status(500); + } +} + +function validate(handler: (req: NextApiRequest, res: NextApiResponse) => Promise) { + return async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === "POST") { + try { + searchSchema.parse(req.body); + } catch (error) { + if (error instanceof ZodError && error?.name === "ZodError") { + return res.status(400).json(error?.issues); + } + return res.status(402); + } + } else { + return res.status(405); + } + await handler(req, res); + }; +} + +export default validate(handler); diff --git a/packages/app-store/giphyother/components/InstallAppButton.tsx b/packages/app-store/giphyother/components/InstallAppButton.tsx new file mode 100644 index 0000000000..2e78793b6a --- /dev/null +++ b/packages/app-store/giphyother/components/InstallAppButton.tsx @@ -0,0 +1,17 @@ +import useAddAppMutation from "../../_utils/useAddAppMutation"; +import { InstallAppButtonProps } from "../../types"; + +export default function InstallAppButton(props: InstallAppButtonProps) { + const mutation = useAddAppMutation("giphy_other"); + + return ( + <> + {props.render({ + onClick() { + mutation.mutate(""); + }, + loading: mutation.isLoading, + })} + + ); +} diff --git a/packages/app-store/giphyother/components/SearchDialog.tsx b/packages/app-store/giphyother/components/SearchDialog.tsx new file mode 100644 index 0000000000..b5e606c5c7 --- /dev/null +++ b/packages/app-store/giphyother/components/SearchDialog.tsx @@ -0,0 +1,151 @@ +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; +import { useState } from "react"; +import { Dispatch, SetStateAction } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Alert } from "@calcom/ui/Alert"; +import Button from "@calcom/ui/Button"; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog"; +import { TextField } from "@calcom/ui/form/fields"; +import Loader from "@calcom/web/components/Loader"; + +interface ISearchDialog { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + onSave: (url: string) => void; +} + +export const SearchDialog = (props: ISearchDialog) => { + const { t } = useLocale(); + const [gifImage, setGifImage] = useState(""); + const [offset, setOffset] = useState(0); + const [keyword, setKeyword] = useState(""); + const { isOpenDialog, setIsOpenDialog } = props; + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const searchGiphy = async (keyword: string, offset: number) => { + if (isLoading) { + return; + } + setIsLoading(true); + setErrorMessage(""); + const res = await fetch("/api/integrations/giphyother/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + keyword, + offset, + }), + }); + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || "Something went wrong"); + } else { + setGifImage(json.image || ""); + setOffset(offset); + if (!json.image) { + setErrorMessage("No Result found"); + } + } + setIsLoading(false); + return null; + }; + + return ( + + + + +
+ { + setKeyword(event.target.value); + }} + name="search" + type="text" + className="mt-2" + labelProps={{ style: { display: "none" } }} + placeholder="Search Giphy" + /> + +
+ {gifImage && ( +
+ {isLoading ? ( + + ) : ( + <> +
+ {`Gif +
+
+ +
+ + )} +
+ )} + {errorMessage && } + + { + props.setIsOpenDialog(false); + }} + asChild> + + + + + +
+
+ ); +}; diff --git a/packages/app-store/giphyother/components/SelectGifInput.tsx b/packages/app-store/giphyother/components/SelectGifInput.tsx new file mode 100644 index 0000000000..ffb3de11b3 --- /dev/null +++ b/packages/app-store/giphyother/components/SelectGifInput.tsx @@ -0,0 +1,52 @@ +import { SearchIcon, TrashIcon } from "@heroicons/react/solid"; +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import Button from "@calcom/ui/Button"; + +import { SearchDialog } from "./SearchDialog"; + +interface ISelectGifInput { + defaultValue?: string | null; + onChange: (url: string) => void; +} +export default function SelectGifInput(props: ISelectGifInput) { + const { t } = useLocale(); + const [selectedGif, setSelectedGif] = useState(props.defaultValue); + const [showDialog, setShowDialog] = useState(false); + + return ( +
+ {selectedGif && ( +
+ {"Selected +
+ )} +
+ + {selectedGif && ( + + )} +
+ { + setSelectedGif(url); + props.onChange(url); + }} + /> +
+ ); +} diff --git a/packages/app-store/giphyother/components/index.ts b/packages/app-store/giphyother/components/index.ts new file mode 100644 index 0000000000..f9a5586a67 --- /dev/null +++ b/packages/app-store/giphyother/components/index.ts @@ -0,0 +1,2 @@ +export { default as InstallAppButton } from "./InstallAppButton"; +export { default as SelectGifInput } from "./SelectGifInput"; diff --git a/packages/app-store/giphyother/index.ts b/packages/app-store/giphyother/index.ts new file mode 100644 index 0000000000..19f36d184d --- /dev/null +++ b/packages/app-store/giphyother/index.ts @@ -0,0 +1,4 @@ +export * as api from "./api"; +export * as lib from "./lib"; +export { metadata } from "./_metadata"; +export * as components from "./components"; diff --git a/packages/app-store/giphyother/lib/giphyManager.ts b/packages/app-store/giphyother/lib/giphyManager.ts new file mode 100644 index 0000000000..4262ffead1 --- /dev/null +++ b/packages/app-store/giphyother/lib/giphyManager.ts @@ -0,0 +1,20 @@ +export const searchGiphy = async (locale: string, keyword: string, offset: number = 0) => { + const queryParams = new URLSearchParams({ + api_key: String(process.env.GIPHY_API_KEY), + q: keyword, + limit: "1", + offset: String(offset), + // Contains images that are broadly accepted as appropriate and commonly witnessed by people in a public environment. + rating: "g", + lang: locale, + }); + const response = await fetch(`https://api.giphy.com/v1/gifs/search?${queryParams.toString()}`, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + const responseBody = await response.json(); + const gifs = responseBody.data; + return gifs?.[0]?.images?.fixed_height_downsampled?.url || null; +}; diff --git a/packages/app-store/giphyother/lib/index.ts b/packages/app-store/giphyother/lib/index.ts new file mode 100644 index 0000000000..ac2be0d1b2 --- /dev/null +++ b/packages/app-store/giphyother/lib/index.ts @@ -0,0 +1 @@ +export * as GiphyManager from "./giphyManager"; diff --git a/packages/app-store/giphyother/package.json b/packages/app-store/giphyother/package.json new file mode 100644 index 0000000000..716e394c67 --- /dev/null +++ b/packages/app-store/giphyother/package.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/giphy", + "version": "0.0.0", + "main": "./index.ts", + "description": "GIPHY is your top source for the best & newest GIFs & Animated Stickers online. Find everything from funny GIFs, reaction GIFs, unique GIFs and more.", + "dependencies": { + "@calcom/prisma": "*" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/giphyother/static/icon.svg b/packages/app-store/giphyother/static/icon.svg new file mode 100644 index 0000000000..29b795e1d5 --- /dev/null +++ b/packages/app-store/giphyother/static/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 27594ef364..839f3d1adf 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -2,6 +2,7 @@ import * as applecalendar from "./applecalendar"; import * as caldavcalendar from "./caldavcalendar"; import * as dailyvideo from "./dailyvideo"; +import * as giphyother from "./giphyother"; import * as googlecalendar from "./googlecalendar"; import * as googlevideo from "./googlevideo"; import * as hubspotothercalendar from "./hubspotothercalendar"; @@ -32,6 +33,7 @@ const appStore = { tandemvideo, zoomvideo, wipemycalother, + giphyother, }; export default appStore; diff --git a/packages/app-store/metadata.ts b/packages/app-store/metadata.ts index 053bf8bef0..d969e1eb66 100644 --- a/packages/app-store/metadata.ts +++ b/packages/app-store/metadata.ts @@ -1,6 +1,7 @@ import { metadata as applecalendar } from "./applecalendar/_metadata"; import { metadata as caldavcalendar } from "./caldavcalendar/_metadata"; import { metadata as dailyvideo } from "./dailyvideo/_metadata"; +import { metadata as giphy } from "./giphyother/_metadata"; import { metadata as googlecalendar } from "./googlecalendar/_metadata"; import { metadata as googlevideo } from "./googlevideo/_metadata"; import { metadata as hubspotothercalendar } from "./hubspotothercalendar/_metadata"; @@ -30,6 +31,7 @@ export const appStoreMetadata = { tandemvideo, zoomvideo, wipemycalother, + giphy, }; export default appStoreMetadata;