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:
co-authored by
Omar López
kodiakhq[bot]
Joe Au-Yeung
parent
b22c562719
commit
f7cec8d71f
@@ -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",
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as search } from "./search";
|
||||
export { default as get } from "./get";
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user