From bbe0179044b98d13f01b354b1460ba5071ace591 Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Wed, 11 Dec 2024 13:21:25 +0100 Subject: [PATCH] feat: join modal --- .../[guildUrlName]/components/JoinButton.tsx | 139 ---------------- .../[guildUrlName]/components/JoinGuild.tsx | 152 +++++++++++++++++- src/app/(dashboard)/[guildUrlName]/layout.tsx | 11 +- src/components/SignInButton.tsx | 8 +- src/config/constants.ts | 20 ++- src/lib/options.ts | 1 + src/lib/schemas/user.ts | 4 + 7 files changed, 182 insertions(+), 153 deletions(-) delete mode 100644 src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx deleted file mode 100644 index 0b0554aef2..0000000000 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/Button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/Tooltip"; -import { env } from "@/lib/env"; -import { fetchGuildLeave } from "@/lib/fetchers"; -import { guildOptions, userOptions } from "@/lib/options"; -import type { Schemas } from "@guildxyz/types"; -import { CheckCircle, SignOut } from "@phosphor-icons/react/dist/ssr"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { EventSourcePlus } from "event-source-plus"; -import { useParams } from "next/navigation"; -import { toast } from "sonner"; - -export const JoinButton = () => { - const { guildUrlName } = useParams<{ guildUrlName: string }>(); - const user = useQuery(userOptions()); - const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); - const queryClient = useQueryClient(); - - if (!guild.data) { - throw new Error("Failed to fetch guild"); - } - - const isJoined = !!user.data?.guilds?.some( - ({ guildId }) => guildId === guild.data.id, - ); - - const joinMutation = useMutation({ - mutationFn: async () => { - const url = new URL( - `api/guild/${guild.data.id}/join`, - env.NEXT_PUBLIC_API, - ); - const eventSource = new EventSourcePlus(url.toString(), { - retryStrategy: "on-error", - method: "post", - maxRetryCount: 0, - headers: { - "content-type": "application/json", - }, - }); - - const { resolve, reject, promise } = - Promise.withResolvers(); - eventSource.listen({ - onMessage: (sseMessage) => { - try { - const { status, message, data } = JSON.parse( - sseMessage.data, - // biome-ignore lint/suspicious/noExplicitAny: TODO: fill missing types - ) as any; - if (status === "Completed") { - if (data === undefined) { - throw new Error( - "Server responded with success, but returned no user", - ); - } - resolve(data); - } else if (status === "error") { - reject(); - } - - toast(status, { - description: message, - icon: - status === "Completed" ? ( - - ) : undefined, - }); - } catch (e) { - console.warn("JSON parsing failed on join event stream", e); - } - }, - }); - - return promise; - }, - onSuccess: async (user) => { - await queryClient.cancelQueries(userOptions()); - queryClient.setQueryData(userOptions().queryKey, user); - }, - onSettled: () => { - queryClient.invalidateQueries(userOptions()); - }, - }); - - const leaveMutation = useMutation({ - mutationFn: () => fetchGuildLeave({ guildId: guild.data.id }), - onSuccess: async () => { - await queryClient.cancelQueries(userOptions()); - const prev = queryClient.getQueryData(userOptions().queryKey); - if (prev) { - queryClient.setQueryData(userOptions().queryKey, { - ...prev, - guilds: prev?.guilds?.filter( - ({ guildId }) => guildId !== guild.data.id, - ), - }); - } - }, - }); - - return isJoined ? ( - - - {/* TODO: IconButton component */} - - ); -}; diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx index b4737cf9f6..9719f5f3ad 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx @@ -1,18 +1,150 @@ "use client"; +import { SignInButton } from "@/components/SignInButton"; import { Button } from "@/components/ui/Button"; +import { + ResponsiveDialog, + ResponsiveDialogBody, + ResponsiveDialogContent, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/components/ui/ResponsiveDialog"; +import { IDENTITY_STYLES } from "@/config/constants"; +import { cn } from "@/lib/cssUtils"; import { env } from "@/lib/env"; import { guildOptions, userOptions } from "@/lib/options"; +import type { IdentityType } from "@/lib/schemas/user"; import type { Schemas } from "@guildxyz/types"; -import { CheckCircle } from "@phosphor-icons/react/dist/ssr"; +import { Check, CheckCircle, XCircle } from "@phosphor-icons/react/dist/ssr"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { EventSourcePlus } from "event-source-plus"; -import { useParams } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { type ReactNode, useEffect, useState } from "react"; import { toast } from "sonner"; +const JOIN_MODAL_SEARCH_PARAM = "join"; + export const JoinGuild = () => { + const searchParams = useSearchParams(); + const shouldOpen = searchParams.has(JOIN_MODAL_SEARCH_PARAM); + + const [open, onOpenChange] = useState(false); + + useEffect(() => { + if (!shouldOpen) return; + onOpenChange(true); + }, [shouldOpen]); + + const { data: user } = useQuery(userOptions()); + + return ( + + + + + + + Join guild + + + + } + /> + {/* TODO: add `requiredIdentities` prop to the Guild entity & list only the necessary identity connect buttons here */} + + + + + + + + + ); +}; + +const JoinStep = ({ + complete, + label, + button, +}: { complete: boolean; label: string; button: ReactNode }) => ( +
+
+ {complete && } +
+
+ {label} +
{button}
+
+
+); + +const getReturnToURLWithSearchParams = () => { + if (typeof window === "undefined") return ""; + + const url = new URL(window.location.href); + const searchParams = new URLSearchParams(url.searchParams); + + if (searchParams.get(JOIN_MODAL_SEARCH_PARAM)) { + return url.toString(); + } + + searchParams.set(JOIN_MODAL_SEARCH_PARAM, "true"); + + return `${window.location.href.split("?")[0]}?${searchParams.toString()}`; +}; + +const ConnectIdentityJoinStep = ({ identity }: { identity: IdentityType }) => { + const router = useRouter(); + const { data: user } = useQuery(userOptions()); + + const connected = !!user?.identities?.find((i) => i.platform === identity); + + const Icon = IDENTITY_STYLES[identity].icon; + + return ( + + router.push( + `${env.NEXT_PUBLIC_API}/connect/${identity}?returnTo=${getReturnToURLWithSearchParams()}`, + ) + } + leftIcon={ + connected ? : + } + disabled={!user || connected} // TODO: once we allow users to log in with 3rd party accounts, we can remove the "!user" part of this + className={IDENTITY_STYLES[identity].buttonColorsClassName} + > + Connect + + } + /> + ); +}; + +const JoinGuildButton = () => { const { guildUrlName } = useParams<{ guildUrlName: string }>(); const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); + + const { data: user } = useQuery(userOptions()); + const queryClient = useQueryClient(); if (!guild.data) { @@ -32,6 +164,7 @@ export const JoinGuild = () => { headers: { "content-type": "application/json", }, + credentials: "include", }); const { resolve, reject, promise } = @@ -65,6 +198,9 @@ export const JoinGuild = () => { console.warn("JSON parsing failed on join event stream", e); } }, + onResponseError: (ctx) => { + return reject(ctx.error); + }, }); return promise; @@ -72,17 +208,25 @@ export const JoinGuild = () => { onSuccess: async (user) => { queryClient.setQueryData(userOptions().queryKey, user); }, + onError: (error: Error) => { + toast("Join error", { + description: error.message, + icon: , + }); + }, }); return ( ); }; diff --git a/src/app/(dashboard)/[guildUrlName]/layout.tsx b/src/app/(dashboard)/[guildUrlName]/layout.tsx index 6c19e28fa1..8cac86dc61 100644 --- a/src/app/(dashboard)/[guildUrlName]/layout.tsx +++ b/src/app/(dashboard)/[guildUrlName]/layout.tsx @@ -1,4 +1,3 @@ -import { AuthBoundary } from "@/components/AuthBoundary"; import { GuildImage } from "@/components/GuildImage"; import { getQueryClient } from "@/lib/getQueryClient"; import { @@ -73,12 +72,10 @@ const GuildLayout = async ({ {guild.data.name} - {/* TODO: JoinButton should open a modal where the user can sign in and also connect the required platforms. So we won't need an AuthBoundary here. */} - - }> - - - + + }> + +

{guild.data.description} diff --git a/src/components/SignInButton.tsx b/src/components/SignInButton.tsx index 9715059b4b..e4d82f5dc2 100644 --- a/src/components/SignInButton.tsx +++ b/src/components/SignInButton.tsx @@ -1,18 +1,22 @@ "use client"; import { signInDialogOpenAtom } from "@/config/atoms"; -import { SignIn } from "@phosphor-icons/react/dist/ssr"; +import { userOptions } from "@/lib/options"; +import { Check, SignIn } from "@phosphor-icons/react/dist/ssr"; +import { useQuery } from "@tanstack/react-query"; import { useSetAtom } from "jotai"; import type { ComponentProps } from "react"; import { Button } from "./ui/Button"; export const SignInButton = (props: ComponentProps) => { const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom); + const { data: user } = useQuery(userOptions()); return (