feat(app-store): Add Giphy app (#2580)

This commit is contained in:
Shrey Gupta 2022-05-02 02:12:35 +05:30 committed by GitHub
parent 276821e0b5
commit 21867c9cd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 484 additions and 8 deletions

View File

@ -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=
# *********************************************************************************************************

View File

@ -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<typeof getServerSideProps>) => {
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<typeof getServerSideProps>) => {
externalId: string;
};
successRedirectUrl: string;
giphyThankYouPage: string;
}>({
defaultValues: {
locations: eventType.locations || [],
@ -914,6 +924,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
periodDates,
periodCountCalendarDays,
smartContractAddress,
giphyThankYouPage,
beforeBufferTime,
afterBufferTime,
locations,
@ -931,11 +942,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
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<typeof getServerSideProps>) => {
</div>
</>
)}
{hasGiphyIntegration && (
<>
<hr className="border-neutral-200" />
<div className="block sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="gif"
className="mt-2 flex text-sm font-medium text-neutral-700">
{t("confirmation_page_gif")}
</label>
</div>
<div className="flex flex-col">
<div className="w-full">
<div className="block items-center sm:flex">
<div className="w-full">
<div className="relative flex items-start">
<div className="flex items-center">
<SelectGifInput
defaultValue={eventType?.metadata?.giphyThankYouPage as string}
onChange={(url) => {
formMethods.setValue("giphyThankYouPage", url);
}}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)}
</CollapsibleContent>
</>
{/* )} */}
@ -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,
},
};

View File

@ -159,6 +159,8 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
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<typeof getServerSideProps>)
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
{!needsConfirmation && <CheckIcon className="h-8 w-8 text-green-600" />}
<div
className={classNames(
"mx-auto flex items-center justify-center",
!giphyImage ? "h-12 w-12 rounded-full bg-green-100" : ""
)}>
{giphyImage && !needsConfirmation && <img src={giphyImage} alt={"Gif from Giphy"} />}
{!giphyImage && !needsConfirmation && <CheckIcon className="h-8 w-8 text-green-600" />}
{needsConfirmation && <ClockIcon className="h-8 w-8 text-green-600" />}
</div>
<div className="mt-3 text-center sm:mt-5">
@ -468,6 +475,7 @@ const getEventTypesFromDB = async (typeId: number) => {
hideBranding: true,
},
},
metadata: true,
},
});
};

View File

@ -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}}\".",

View File

@ -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 = (

View File

@ -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;

View File

@ -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" });
}

View File

@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as search } from "./search";

View File

@ -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<NextApiResponse | void>) {
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);

View File

@ -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,
})}
</>
);
}

View File

@ -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<SetStateAction<boolean>>;
onSave: (url: string) => void;
}
export const SearchDialog = (props: ISearchDialog) => {
const { t } = useLocale();
const [gifImage, setGifImage] = useState<string>("");
const [offset, setOffset] = useState<number>(0);
const [keyword, setKeyword] = useState<string>("");
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 (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<DialogHeader title="Search a gif" />
<div className="flex justify-center space-x-2 space-y-2">
<TextField
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
}}
name="search"
type="text"
className="mt-2"
labelProps={{ style: { display: "none" } }}
placeholder="Search Giphy"
/>
<Button
type="button"
tabIndex={-1}
onClick={(event) => {
searchGiphy(keyword, 0);
return false;
}}
loading={isLoading}>
{t("search")}
</Button>
</div>
{gifImage && (
<div className="flex flex-col items-center space-x-2 space-y-2 pt-3">
{isLoading ? (
<Loader />
) : (
<>
<div>
<img src={gifImage} alt={`Gif from Giphy for ${keyword}`} />
</div>
<div>
<nav>
<ul className="inline-flex space-x-2">
<li style={{ visibility: offset <= 0 ? "hidden" : "visible" }}>
<button
onClick={() => {
searchGiphy(keyword, offset - 1);
}}
className="focus:shadow-outline flex h-10 w-10 items-center justify-center rounded-full text-indigo-600 transition-colors duration-150 hover:bg-indigo-100">
<ChevronLeftIcon />
</button>
</li>
<li>
<button
onClick={() => {
searchGiphy(keyword, offset + 1);
}}
className="focus:shadow-outline flex h-10 w-10 items-center justify-center rounded-full bg-white text-indigo-600 transition-colors duration-150 hover:bg-indigo-100">
<ChevronRightIcon />
</button>
</li>
</ul>
</nav>
</div>
</>
)}
</div>
)}
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
<DialogFooter>
<DialogClose
onClick={() => {
props.setIsOpenDialog(false);
}}
asChild>
<Button type="button" color="minimal" tabIndex={-1}>
{t("cancel")}
</Button>
</DialogClose>
<Button
type="button"
loading={isLoading}
onClick={() => {
props.setIsOpenDialog(false);
props.onSave(gifImage);
setOffset(0);
setGifImage("");
setKeyword("");
return false;
}}>
{t("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -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 (
<div className="flex flex-col items-start space-x-2 space-y-2">
{selectedGif && (
<div>
<img alt={"Selected Gif Image"} src={selectedGif} />
</div>
)}
<div className="flex">
<Button color="secondary" type="button" StartIcon={SearchIcon} onClick={() => setShowDialog(true)}>
Search on Giphy
</Button>
{selectedGif && (
<Button
color="warn"
type="button"
StartIcon={TrashIcon}
onClick={() => {
setSelectedGif("");
props.onChange("");
}}>
{t("remove")}
</Button>
)}
</div>
<SearchDialog
isOpenDialog={showDialog}
setIsOpenDialog={setShowDialog}
onSave={(url) => {
setSelectedGif(url);
props.onChange(url);
}}
/>
</div>
);
}

View File

@ -0,0 +1,2 @@
export { default as InstallAppButton } from "./InstallAppButton";
export { default as SelectGifInput } from "./SelectGifInput";

View File

@ -0,0 +1,4 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";
export * as components from "./components";

View File

@ -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;
};

View File

@ -0,0 +1 @@
export * as GiphyManager from "./giphyManager";

View File

@ -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": "*"
}
}

View File

@ -0,0 +1,14 @@
<svg height="2500" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="4 2 16.32 20">
<g fill="none" fill-rule="evenodd">
<path d="M6.331 4.286H17.99v15.428H6.33z" fill="#000" />
<g fill-rule="nonzero">
<path d="M4 3.714h2.331v16.572H4z" fill="#04ff8e" />
<path d="M17.989 8.286h2.331v12h-2.331z" fill="#8e2eff" />
<path d="M4 19.714h16.32V22H4z" fill="#00c5ff" />
<path d="M4 2h9.326v2.286H4z" fill="#fff152" />
<path d="M17.989 6.571V4.286h-2.332V2h-2.331v6.857h6.994V6.571" fill="#ff5b5b" />
<path d="M17.989 11.143V8.857h2.331" fill="#551c99" />
</g>
<path d="M13.326 2v2.286h-2.332" fill="#999131" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@ -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;

View File

@ -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;