fix(app-store): Giphy Ux fixes (#2746)

* fix(app-store): Giphy Ux fixes

* Add search placeholder

* Min height image on event type page

* Use regex in zod validation and fix design issues for figma row 1

* Add paddings as per discussion with Ciarán

* Fix getGiphyApiKey nomenclature

* Update apps/web/public/static/locales/en/common.json

* Update regex to be more readable and allow enter to search

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Shrey Gupta
2022-05-30 10:23:23 -06:00
committed by GitHub
co-authored by Omar López kodiakhq[bot] Joe Au-Yeung
parent b22c562719
commit f7cec8d71f
7 changed files with 244 additions and 74 deletions
@@ -845,6 +845,11 @@
"calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions",
"set_your_phone_number": "Set a phone number for the meeting",
"calendar_no_busy_slots": "There are no busy slots",
"add_gif": "Add GIF",
"search_giphy": "Search Giphy",
"add_link_from_giphy": "Add link from Giphy",
"add_gif_to_confirmation": "Adding a GIF to confirmation page",
"find_gif_spice_confirmation": "Find GIF to spice up your confirmation page",
"display_location_label":"Display on booking page",
"display_location_info_badge":"Location will be visible before the booking is confirmed",
"share_feedback": "Share feedback",
+64
View File
@@ -0,0 +1,64 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z, ZodError } from "zod";
import prisma from "@calcom/prisma";
import { GiphyManager } from "../lib";
const giphyUrlRegexp = new RegExp("^https://(.*).giphy.com/media/(.*)/giphy.gif(.*)");
const getSchema = z.object({
url: z.string().regex(giphyUrlRegexp, "Giphy URL is invalid"),
});
/**
* 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 { url } = req.body;
const parsedUrl = new URL(url.replace(/ /g, ""));
// remove query strings if any that could cause trouble in parsing ID from url
const sanitisedUrl = parsedUrl.origin + parsedUrl.pathname;
// Extract Giphy ID from embed url
const matches = giphyUrlRegexp.exec(sanitisedUrl);
if (!matches || matches.length < 3) {
return res.status(400).json({ message: "Giphy URL is invalid" });
}
const giphyId = matches[2];
const gifImageUrl = await GiphyManager.getGiphyById(giphyId);
return res.status(200).json({ image: gifImageUrl });
} catch (error: unknown) {
console.error({ error });
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 {
getSchema.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);
+1
View File
@@ -1,2 +1,3 @@
export { default as add } from "./add";
export { default as search } from "./search";
export { default as get } from "./get";
+6 -2
View File
@@ -32,8 +32,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
});
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 });
const { gifImageUrl, total } = await GiphyManager.searchGiphy(locale, keyword, offset);
return res.status(200).json({
image: gifImageUrl,
// rotate results to 0 offset when no more gifs
nextOffset: total === offset + 1 ? 0 : offset + 1,
});
} catch (error: unknown) {
if (error instanceof Error) {
return res.status(500).json({ message: error.message });
@@ -1,13 +1,12 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
import { SearchIcon, LinkIcon } from "@heroicons/react/outline";
import { useState } from "react";
import { Dispatch, SetStateAction } from "react";
import classNames from "@calcom/lib/classNames";
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";
import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
interface ISearchDialog {
isOpenDialog: boolean;
@@ -15,14 +14,19 @@ interface ISearchDialog {
onSave: (url: string) => void;
}
const MODE_SEARCH = "search" as const;
const MODE_URL = "url" as const;
type Mode = typeof MODE_SEARCH | typeof MODE_URL;
export const SearchDialog = (props: ISearchDialog) => {
const { t } = useLocale();
const [gifImage, setGifImage] = useState<string>("");
const [offset, setOffset] = useState<number>(0);
const [nextOffset, setNextOffset] = useState<number>(0);
const [keyword, setKeyword] = useState<string>("");
const { isOpenDialog, setIsOpenDialog } = props;
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [selectedMode, setSelectedMode] = useState<Mode>(MODE_SEARCH);
const searchGiphy = async (keyword: string, offset: number) => {
if (isLoading) {
@@ -45,7 +49,7 @@ export const SearchDialog = (props: ISearchDialog) => {
setErrorMessage(json?.message || "Something went wrong");
} else {
setGifImage(json.image || "");
setOffset(offset);
setNextOffset(json.nextOffset);
if (!json.image) {
setErrorMessage("No Result found");
}
@@ -54,72 +58,134 @@ export const SearchDialog = (props: ISearchDialog) => {
return null;
};
const getGiphyByUrl = async (url: string) => {
if (isLoading) {
return;
}
setIsLoading(true);
setErrorMessage("");
const res = await fetch("/api/integrations/giphy/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
}),
});
const json = await res.json();
if (!res.ok) {
setErrorMessage(json?.message || json?.[0]?.message || "Something went wrong");
} else {
setGifImage(json.image || "");
if (!json.image) {
setErrorMessage("No Result found");
}
}
setIsLoading(false);
return null;
};
const renderTab = (Icon: any, text: string, mode: Mode) => (
<div
className={classNames(
"flex cursor-pointer items-center border-b-2 p-2 text-sm",
selectedMode === mode ? "border-black" : "border-transparent text-gray-500"
)}
onClick={() => {
setKeyword("");
setGifImage("");
setSelectedMode(mode);
}}>
<Icon color={selectedMode === mode ? "black" : "grey"} className="mr-2 h-4 w-4" />
{text}
</div>
);
const handleFormSubmit = async (event: React.SyntheticEvent) => {
event.stopPropagation();
event.preventDefault();
if (selectedMode === MODE_SEARCH) {
searchGiphy(keyword, 0);
} else if (selectedMode === MODE_URL) {
getGiphyByUrl(keyword);
}
};
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}>
<h3 className="leading-16 font-cal text-xl text-gray-900" id="modal-title">
{t("add_gif_to_confirmation")}
</h3>
<p className="mb-3 text-sm font-light text-neutral-500">{t("find_gif_spice_confirmation")}</p>
<div className="flex items-center border-b border-solid">
{renderTab(SearchIcon, t("search_giphy"), MODE_SEARCH)}
{renderTab(LinkIcon, t("add_link_from_giphy"), MODE_URL)}
</div>
<form className="flex w-full justify-center space-x-2 space-y-2" onSubmit={handleFormSubmit}>
<div className="relative block w-full pt-2">
<input
type="text"
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
placeholder={
selectedMode === MODE_SEARCH
? t("search_giphy")
: "https://media.giphy.com/media/some-id/giphy.gif"
}
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
}}
/>
</div>
<Button type="submit" tabIndex={-1} color="secondary" loading={isLoading}>
{t("search")}
</Button>
</div>
</form>
{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 className="flex w-full items-center justify-center bg-gray-100">
{isLoading ? (
<div className="flex h-[200px] w-full items-center justify-center bg-gray-400 pt-3 pb-3">
<svg
className={classNames("mx-4 h-5 w-5 animate-spin", "text-white dark:text-black")}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</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>
</>
)}
) : (
<img className="h-[200px] pt-3 pb-3" src={gifImage} alt={`Gif from Giphy for ${keyword}`} />
)}
</div>
</div>
)}
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
{gifImage && selectedMode === MODE_SEARCH && (
<div className="mt-4 flex items-center justify-between space-x-2">
<div className="text-sm font-light text-neutral-500">Not the perfect GIF?</div>
<Button
size="sm"
color="secondary"
type="button"
loading={isLoading}
onClick={() => searchGiphy(keyword, nextOffset)}>
Shuffle
</Button>
</div>
)}
<DialogFooter>
<DialogClose
onClick={() => {
@@ -133,16 +199,16 @@ export const SearchDialog = (props: ISearchDialog) => {
<Button
type="button"
loading={isLoading}
disabled={!gifImage}
onClick={() => {
props.setIsOpenDialog(false);
props.onSave(gifImage);
setOffset(0);
setNextOffset(0);
setGifImage("");
setKeyword("");
return false;
}}>
{t("save")}
{t("add_gif")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,4 +1,4 @@
import { SearchIcon, TrashIcon } from "@heroicons/react/solid";
import { PlusIcon, PencilAltIcon, XIcon } from "@heroicons/react/solid";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -18,19 +18,26 @@ export default function SelectGifInput(props: ISelectGifInput) {
return (
<div className="flex flex-col items-start space-x-2 space-y-2">
{selectedGif && (
<div>
<div className="min-h-[200px]">
<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="minimal" type="button" StartIcon={PencilAltIcon} onClick={() => setShowDialog(true)}>
Change
</Button>
) : (
<Button color="minimal" type="button" StartIcon={PlusIcon} onClick={() => setShowDialog(true)}>
Add from Giphy
</Button>
)}
{selectedGif && (
<Button
color="warn"
type="button"
StartIcon={TrashIcon}
StartIcon={XIcon}
onClick={() => {
setSelectedGif("");
props.onChange("");
+25 -2
View File
@@ -4,10 +4,14 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
let api_key = "";
export const searchGiphy = async (locale: string, keyword: string, offset: number = 0) => {
const checkGiphyApiKey = async () => {
const appKeys = await getAppKeysFromSlug("giphy");
if (typeof appKeys.api_key === "string") api_key = appKeys.api_key;
if (!api_key) throw new HttpError({ statusCode: 400, message: "Missing Giphy api_key" });
};
export const searchGiphy = async (locale: string, keyword: string, offset = 0) => {
await checkGiphyApiKey();
const queryParams = new URLSearchParams({
api_key,
q: keyword,
@@ -25,5 +29,24 @@ export const searchGiphy = async (locale: string, keyword: string, offset: numbe
});
const responseBody = await response.json();
const gifs = responseBody.data;
return gifs?.[0]?.images?.fixed_height_downsampled?.url || null;
return {
gifImageUrl: gifs?.[0]?.images?.fixed_height_downsampled?.url || null,
total: responseBody.pagination.total_count,
};
};
export const getGiphyById = async (giphyId: string) => {
await checkGiphyApiKey();
const queryParams = new URLSearchParams({
api_key,
});
const response = await fetch(`https://api.giphy.com/v1/gifs/${giphyId}?${queryParams.toString()}`, {
method: "GET",
headers: {
Accept: "application/json",
},
});
const responseBody = await response.json();
const gifs = responseBody.data;
return gifs?.images?.fixed_height_downsampled?.url || null;
};