diff --git a/.gitignore b/.gitignore index 1fffb8e724..d1a75623fe 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ bun.lockb /playwright/.cache/ /playwright/.auth/ /playwright/results + +certificates \ No newline at end of file diff --git a/README.md b/README.md index 1997db2a30..da0c30ee85 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,11 @@ Open source interface for Guild.xyz -- a tool for platformless membership manage ### Running the interface locally 1. `bun i` -2. `bun run dev` -3. If you don't have the secret environment variables, copy the `.env.example` as `.env.local`. +2. Append `127.0.0.1 local.openguild.xyz` to `/etc/hosts` +3. If you don't have the secret environment variables, copy the `.env.example` as `.env.local` +4. Run `bun dev`, create certificate if prompted +5. Open `https://local.openguild.xyz:3000` and dismiss the unsecure site warning -Open [http://localhost:3000](http://localhost:3000) in your browser to see the result. ### Getting secret environment variables (for core team members): diff --git a/package.json b/package.json index 9b2c388d12..883536d381 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "prepare": "husky", - "dev": "next dev --turbo", + "dev": "next dev --turbo --experimental-https", "build": "next build", "start": "next start", "type-check": "tsc --pretty --noEmit --incremental false", @@ -31,6 +31,7 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@t3-oss/env-nextjs": "^0.11.1", + "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.15", "@tanstack/react-query": "^5.62.2", "@tanstack/react-query-devtools": "^5.62.2", @@ -40,7 +41,6 @@ "event-source-plus": "^0.1.8", "foxact": "^0.2.43", "jotai": "^2.10.3", - "jwt-decode": "^4.0.0", "mini-svg-data-uri": "^1.4.4", "next": "15.0.3", "next-themes": "^0.4.4", diff --git a/src/actions/auth.ts b/src/actions/auth.ts deleted file mode 100644 index 8ef8f78b97..0000000000 --- a/src/actions/auth.ts +++ /dev/null @@ -1,52 +0,0 @@ -"use server"; - -import { associatedGuildsOption } from "@/app/(dashboard)/explorer/options"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; -import { fetchGuildApi } from "@/lib/fetchGuildApi"; -import { getQueryClient } from "@/lib/getQueryClient"; -import { userOptions } from "@/lib/options"; -import { authSchema, tokenSchema } from "@/lib/schemas/user"; -import { jwtDecode } from "jwt-decode"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; - -export const signIn = async ({ - message, - signature, -}: { - message: string; - signature: string; -}) => { - const cookieStore = await cookies(); - - const requestInit = { - method: "POST", - body: JSON.stringify({ - message, - signature, - }), - } satisfies RequestInit; - - const signInRes = await fetchGuildApi("auth/siwe/login", requestInit); - let json = signInRes.data; - if (signInRes.response.status === 401) { - const registerRes = await fetchGuildApi("auth/siwe/register", requestInit); - json = registerRes.data; - } - const authData = authSchema.parse(json); - const { exp } = tokenSchema.parse(jwtDecode(authData.token)); - - cookieStore.set(GUILD_AUTH_COOKIE_NAME, authData.token, { - expires: new Date(exp * 1000), - }); - return authData; -}; - -export const signOut = async (redirectTo?: string) => { - const cookieStore = await cookies(); - cookieStore.delete(GUILD_AUTH_COOKIE_NAME); - const queryClient = getQueryClient(); - queryClient.removeQueries(associatedGuildsOption()); - queryClient.removeQueries(userOptions()); - redirect(redirectTo ?? "/explorer"); -}; diff --git a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx index e11b50a273..9e0cf3b3a0 100644 --- a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx +++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx @@ -1,16 +1,19 @@ "use client"; import { RequirementDisplayComponent } from "@/components/requirements/RequirementDisplayComponent"; +import { rewardCards } from "@/components/rewards/rewardCards"; import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; -import { ScrollArea } from "@/components/ui/ScrollArea"; import { Skeleton } from "@/components/ui/Skeleton"; -import { rewardBatchOptions, roleBatchOptions } from "@/lib/options"; -import type { Schemas } from "@guildxyz/types"; -import { Lock } from "@phosphor-icons/react/dist/ssr"; +import { fetchGuildApiData } from "@/lib/fetchGuildApi"; +import { roleBatchOptions } from "@/lib/options"; +import type { GuildReward, GuildRewardType } from "@/lib/schemas/guildReward"; +import type { Role } from "@/lib/schemas/role"; +import { ImageSquare, Lock } from "@phosphor-icons/react/dist/ssr"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { Suspense } from "react"; +import { useGuild } from "../hooks/useGuild"; const GuildPage = () => { const { pageUrlName, guildUrlName } = useParams<{ @@ -38,73 +41,98 @@ const GuildPage = () => { ); }; -const RoleCard = ({ role }: { role: Schemas["Role"] }) => { - const { data: rewards } = useSuspenseQuery( - rewardBatchOptions({ roleId: role.id }), - ); - - return ( - -
-
- {role.imageUrl && ( - role avatar - )} -

{role.name}

-
-

- {role.description} -

- {!!rewards.length && ( - -
- {rewards.map((reward) => ( - - ))} -
-
+const RoleCard = ({ role }: { role: Role }) => ( + +
+
+ {role.imageUrl ? ( + role avatar + ) : ( +
+ +
)} +

{role.name}

-
-
- - REQUIREMENTS - - -
+

+ {role.description} +

+ + Loading rewards...

}> + +
+
- {/* TODO group rules by access groups */} -
- {role.accessGroups[0].rules?.map((rule) => ( - - ))} -
+
+
+ + REQUIREMENTS + +
- - ); -}; -const Reward = ({ reward }: { reward: Schemas["Reward"] }) => { - return ( -
-
{reward.name}
-
{reward.description}
-
-        {JSON.stringify(reward.permissions, null, 2)}
-      
+ {/* TODO group rules by access groups */} +
+ {role.accessGroups[0].rules?.map((rule) => ( + + ))} +
- ); + +); + +const RoleRewards = ({ + roleId, + roleRewards, +}: { roleId: string; roleRewards: Role["rewards"] }) => { + const { data: guild } = useGuild(); + const { data: rewards } = useSuspenseQuery({ + queryKey: ["reward", "search", guild.id], + queryFn: () => + fetchGuildApiData<{ items: GuildReward[] }>( + `reward/search?customQuery=@guildId:{${guild.id}}`, + ).then((data) => data.items), // TODO: we shouldn't do this, we should just get back an array on this endpoint in my opinion + }); + + return roleRewards?.length > 0 && rewards?.length > 0 ? ( +
+ {roleRewards.map((roleReward) => { + const guildReward = rewards.find((gr) => gr.id === roleReward.rewardId); + if (!guildReward) return null; + + const hasRewardCard = ( + rewardType: GuildRewardType, + ): rewardType is keyof typeof rewardCards => rewardType in rewardCards; + + const RewardCard = hasRewardCard(guildReward.type) + ? rewardCards[guildReward.type] + : null; + + if (!RewardCard) return null; + + return ( + + ); + })} +
+ ) : null; }; export default GuildPage; diff --git a/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx new file mode 100644 index 0000000000..306f14c6ba --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Button } from "@/components/ui/Button"; +import { userOptions } from "@/lib/options"; +import { useQuery } from "@tanstack/react-query"; +import { useGuild } from "../hooks/useGuild"; +import { JoinGuild } from "./JoinGuild"; +import { LeaveGuild } from "./LeaveGuild"; + +export const ActionButton = () => { + const user = useQuery(userOptions()); + const guild = useGuild(); + + if (!guild.data) { + throw new Error("Failed to fetch guild"); + } + + const isJoined = !!user.data?.guilds?.some( + ({ guildId }) => guildId === guild.data.id, + ); + + return isJoined ? : ; +}; + +export const ActionButtonSkeleton = () => ( + +); diff --git a/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx index f6c0cae90a..bf2cb70921 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx @@ -4,16 +4,15 @@ import { Card } from "@/components/ui/Card"; import { ScrollArea, ScrollBar } from "@/components/ui/ScrollArea"; import { Skeleton } from "@/components/ui/Skeleton"; import { cn } from "@/lib/cssUtils"; -import { guildOptions, pageBatchOptions } from "@/lib/options"; +import { pageBatchOptions } from "@/lib/options"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { useParams } from "next/navigation"; +import { useGuild } from "../hooks/useGuild"; +import { useGuildUrlName } from "../hooks/useGuildUrlName"; import { PageNavLink } from "./RoleGroupNavLink"; export const GuildTabs = () => { - const { guildUrlName } = useParams<{ guildUrlName: string }>(); - const { data: guild } = useSuspenseQuery( - guildOptions({ guildIdLike: guildUrlName }), - ); + const guildUrlName = useGuildUrlName(); + const { data: guild } = useGuild(); const { data: pages } = useSuspenseQuery( pageBatchOptions({ guildIdLike: guildUrlName }), ); diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx deleted file mode 100644 index 2468544274..0000000000 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/Button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/Tooltip"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; -import { env } from "@/lib/env"; -import { fetchGuildLeave } from "@/lib/fetchers"; -import { getCookieClientSide } from "@/lib/getCookieClientSide"; -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 () => { - //TODO: Handle error here, throw error in funciton if needed - const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME)!; - 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: { - "x-auth-token": token, - "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 new file mode 100644 index 0000000000..2805434c10 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx @@ -0,0 +1,232 @@ +"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 { IDENTITY_NAME, type IdentityType } from "@/lib/schemas/identity"; +import type { Schemas } from "@guildxyz/types"; +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, 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) { + throw new Error("Failed to fetch guild"); + } + + const { mutate, isPending } = 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", + }, + credentials: "include", + }); + + 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); + } + }, + onResponseError: (ctx) => { + return reject(ctx.error); + }, + }); + + return promise; + }, + 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]/components/LeaveGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx new file mode 100644 index 0000000000..8e1b98018c --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Button } from "@/components/ui/Button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/Tooltip"; +import { fetchGuildLeave } from "@/lib/fetchers"; +import { userOptions } from "@/lib/options"; +import { SignOut } from "@phosphor-icons/react/dist/ssr"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useGuild } from "../hooks/useGuild"; + +export const LeaveGuild = () => { + const guild = useGuild(); + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation({ + mutationFn: () => fetchGuildLeave({ guildId: guild.data.id }), + onSuccess: async () => { + const prev = queryClient.getQueryData(userOptions().queryKey); + if (prev) { + queryClient.setQueryData(userOptions().queryKey, { + ...prev, + guilds: prev?.guilds?.filter( + ({ guildId }) => guildId !== guild.data.id, + ), + }); + } + }, + }); + + return ( + + + {/* TODO: IconButton component */} +
- {/* 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/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx new file mode 100644 index 0000000000..e25fa1c19f --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { userOptions } from "@/lib/options"; +import { + useQuery, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { leaderboardOptions, pointsRewardOptions } from "../options"; +import { LeaderboardUserCard } from "./LeaderboardUserCard"; + +export const Leaderboard = ({ rewardId }: { rewardId: string }) => { + const { data: user } = useQuery(userOptions()); + const { data: rawData } = useSuspenseInfiniteQuery( + leaderboardOptions({ rewardId, userId: user?.id }), + ); + + const { data: pointReward } = useSuspenseQuery( + pointsRewardOptions({ rewardId }), + ); + + const data = rawData?.pages[0]; + + return ( +

+ {!!data.user && ( +
+

Your position

+ +
+ )} + +
+

{`${pointReward.data.name} leaderboard`}

+ {data.leaderboard.map((user, index) => ( + + ))} +
+
+ ); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/LeaderboardUserCard.tsx b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/LeaderboardUserCard.tsx new file mode 100644 index 0000000000..4f2944aace --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/LeaderboardUserCard.tsx @@ -0,0 +1,38 @@ +import { Card } from "@/components/ui/Card"; +import type { Leaderboard } from "@/lib/schemas/leaderboard"; +import { User } from "@phosphor-icons/react/dist/ssr"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { pointsRewardOptions } from "../options"; + +export const LeaderboardUserCard = ({ + user, +}: { user: NonNullable }) => { + const { rewardId } = useParams<{ rewardId: string }>(); + const { data: pointReward } = useSuspenseQuery( + pointsRewardOptions({ rewardId }), + ); + + return ( + +
+ {`#${user.rank}`} +
+ +
+
+
+ +
+ + {user.primaryIdentity.foreignId} + +
+ + {`${user.amount} `} + {pointReward.data.name} + +
+
+ ); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/options.ts b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/options.ts new file mode 100644 index 0000000000..6461427b3e --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/options.ts @@ -0,0 +1,32 @@ +import { fetchLeaderboard } from "@/app/(dashboard)/explorer/fetchers"; +import { fetchGuildApiData } from "@/lib/fetchGuildApi"; +import type { GuildReward } from "@/lib/schemas/guildReward"; +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; + +export const leaderboardOptions = ({ + rewardId, + userId, +}: { rewardId: string; userId?: string }) => { + return infiniteQueryOptions({ + queryKey: ["leaderboard", rewardId], + queryFn: ({ pageParam }) => + fetchLeaderboard({ rewardId, userId, offset: pageParam }), + initialPageParam: 1, + enabled: rewardId !== undefined, + getNextPageParam: (lastPage) => + lastPage.total / lastPage.limit <= lastPage.offset + ? undefined + : lastPage.offset + 1, + }); +}; + +export const pointsRewardOptions = ({ rewardId }: { rewardId: string }) => { + return queryOptions({ + queryKey: ["reward", "id", rewardId], + queryFn: () => + fetchGuildApiData>( + `reward/id/${rewardId}`, + ), + enabled: !!rewardId, + }); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/page.tsx b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/page.tsx new file mode 100644 index 0000000000..63335ba50b --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/page.tsx @@ -0,0 +1,26 @@ +import { getQueryClient } from "@/lib/getQueryClient"; +import type { DynamicRoute } from "@/lib/types"; +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import { Suspense } from "react"; +import { Leaderboard } from "./components/Leaderboard"; +import { leaderboardOptions, pointsRewardOptions } from "./options"; + +const LeaderboardPage = async ({ + params, +}: DynamicRoute<{ guildUrlName: string; rewardId: string }>) => { + const { rewardId } = await params; + + const queryClient = getQueryClient(); + await queryClient.prefetchInfiniteQuery(leaderboardOptions({ rewardId })); + await queryClient.prefetchQuery(pointsRewardOptions({ rewardId })); + + return ( + + + + + + ); +}; + +export default LeaderboardPage; diff --git a/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx b/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx index 441b0c21c9..9c6d757eb3 100644 --- a/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx +++ b/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx @@ -2,9 +2,7 @@ import { useConfetti } from "@/components/ConfettiProvider"; import { Button } from "@/components/ui/Button"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; import { fetchGuildApiData } from "@/lib/fetchGuildApi"; -import { getCookieClientSide } from "@/lib/getCookieClientSide"; import type { CreateGuildForm, Guild } from "@/lib/schemas/guild"; import { CheckCircle, XCircle } from "@phosphor-icons/react/dist/ssr"; import { useMutation } from "@tanstack/react-query"; @@ -21,10 +19,6 @@ const CreateGuildButton = () => { const { mutate: onSubmit, isPending } = useMutation({ mutationFn: async (data: CreateGuildForm) => { - const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME); - - if (!token) throw new Error("Unauthorized"); // TODO: custom errors? - const guild = { ...data, contact: undefined, diff --git a/src/app/(dashboard)/explorer/fetchers.ts b/src/app/(dashboard)/explorer/fetchers.ts index f9e275d2f1..af0956bd31 100644 --- a/src/app/(dashboard)/explorer/fetchers.ts +++ b/src/app/(dashboard)/explorer/fetchers.ts @@ -1,13 +1,15 @@ import { fetchGuildApiData } from "@/lib/fetchGuildApi"; -import { tryGetParsedToken } from "@/lib/token"; +import { userOptions } from "@/lib/options"; +import type { Leaderboard } from "@/lib/schemas/leaderboard"; import type { PaginatedResponse } from "@/lib/types"; import type { Schemas } from "@guildxyz/types"; +import { useQuery } from "@tanstack/react-query"; import { PAGE_SIZE } from "./constants"; export const fetchAssociatedGuilds = async () => { - const { userId } = await tryGetParsedToken(); + const { data: user } = useQuery(userOptions()); return fetchGuildApiData>( - `guild/search?page=1&pageSize=${Number.MAX_SAFE_INTEGER}&sortBy=name&reverse=false&customQuery=@owner:{${userId}}`, + `guild/search?page=1&pageSize=${Number.MAX_SAFE_INTEGER}&sortBy=name&reverse=false&customQuery=@owner:{${user?.id}}`, ); }; @@ -19,3 +21,14 @@ export const fetchGuildSearch = async ({ `guild/search?page=${pageParam}&pageSize=${PAGE_SIZE}&search=${search}`, ); }; + +export const fetchLeaderboard = async ({ + rewardId, + userId, + offset = 0, +}: { rewardId: string; userId?: string; offset?: number }) => { + return fetchGuildApiData< + Leaderboard & { total: number; offset: number; limit: number } + >(`reward/${rewardId}/leaderboard?userId=${userId} +`); // TODO: use the offset param +}; diff --git a/src/app/(dashboard)/explorer/layout.tsx b/src/app/(dashboard)/explorer/layout.tsx index 2ba50a5015..70965bcbe9 100644 --- a/src/app/(dashboard)/explorer/layout.tsx +++ b/src/app/(dashboard)/explorer/layout.tsx @@ -1,12 +1,12 @@ import { getQueryClient } from "@/lib/getQueryClient"; import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; import type { PropsWithChildren } from "react"; -import { guildSearchOptions } from "./options"; +import { associatedGuildsOption, guildSearchOptions } from "./options"; const ExplorerLayout = async ({ children }: PropsWithChildren) => { const queryClient = getQueryClient(); await queryClient.prefetchInfiniteQuery(guildSearchOptions({})); - // await queryClient.prefetchQuery(associatedGuildsOption()); + await queryClient.prefetchQuery(associatedGuildsOption()); return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 94c5da2364..9853c91e72 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,20 +1,23 @@ import type { Metadata } from "next"; import "@/styles/globals.css"; +import { ConnectResultToast } from "@/components/ConnectResultToast"; +import { PrefetchUserBoundary } from "@/components/PrefetchUserBoundary"; import { PreloadResources } from "@/components/PreloadResources"; import { Providers } from "@/components/Providers"; import { SignInDialog } from "@/components/SignInDialog"; import { Toaster } from "@/components/ui/Toaster"; import { dystopian } from "@/lib/fonts"; import { cn } from "lib/cssUtils"; +import { Suspense } from "react"; export const metadata: Metadata = { title: "Guildhall", applicationName: "Guildhall", description: "Automated membership management for the platforms your community already uses.", - // icons: { - // icon: "/guild-icon.png", - // }, + icons: { + icon: "/guild-icon.png", + }, }; const RootLayout = ({ @@ -27,10 +30,13 @@ const RootLayout = ({ - {children} + {children} - + + + + diff --git a/src/components/AuthBoundary.tsx b/src/components/AuthBoundary.tsx index d90ec5af4e..a9103dfca1 100644 --- a/src/components/AuthBoundary.tsx +++ b/src/components/AuthBoundary.tsx @@ -1,16 +1,18 @@ -import { tryGetToken } from "@/lib/token"; +"use client"; -export const AuthBoundary = async ({ +import { userOptions } from "@/lib/options"; +import { useQuery } from "@tanstack/react-query"; + +export const AuthBoundary = ({ fallback, children, }: Readonly<{ fallback: React.ReactNode; children: React.ReactNode; }>) => { - try { - await tryGetToken(); - return <>{children}; - } catch { - return <>{fallback}; - } + const { data: user } = useQuery(userOptions()); + + if (user?.id) return children; + + return fallback; }; diff --git a/src/components/ConnectResultToast.tsx b/src/components/ConnectResultToast.tsx new file mode 100644 index 0000000000..0f53c127ba --- /dev/null +++ b/src/components/ConnectResultToast.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { IDENTITY_NAME, IdentityTypeSchema } from "@/lib/schemas/identity"; +import { CheckCircle, XCircle } from "@phosphor-icons/react/dist/ssr"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect } from "react"; +import { toast } from "sonner"; + +const SUCCESS_PARAM = "connectSuccess"; +const ERROR_MSG_PARAM = "connectErrorMessage"; + +export const ConnectResultToast = () => { + const { push } = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const connectSuccessPlatformSearchParam = searchParams.get(SUCCESS_PARAM); + + const connectErrorMessage = searchParams.get(ERROR_MSG_PARAM); + + const removeSearchParam = useCallback( + (param: string) => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.delete(param); + push(`${pathname}?${newSearchParams.toString()}`); + }, + [searchParams, pathname, push], + ); + + useEffect(() => { + if (!connectSuccessPlatformSearchParam) return; + + const connectSuccessPlatform = IdentityTypeSchema.safeParse( + connectSuccessPlatformSearchParam, + ); + + const platformName = connectSuccessPlatform.error + ? "an unknown platform" + : IDENTITY_NAME[connectSuccessPlatform.data]; + + toast(`Successfully connected ${platformName}!`, { + icon: , + }); + removeSearchParam(SUCCESS_PARAM); + }, [connectSuccessPlatformSearchParam, removeSearchParam]); + + useEffect(() => { + if (!connectErrorMessage) return; + toast("Error", { + description: connectErrorMessage, + icon: , + }); + removeSearchParam(ERROR_MSG_PARAM); + }, [connectErrorMessage, removeSearchParam]); + + return null; +}; diff --git a/src/components/PrefetchUserBoundary.tsx b/src/components/PrefetchUserBoundary.tsx new file mode 100644 index 0000000000..2a360fe7aa --- /dev/null +++ b/src/components/PrefetchUserBoundary.tsx @@ -0,0 +1,15 @@ +import { getQueryClient } from "@/lib/getQueryClient"; +import { userOptions } from "@/lib/options"; +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; + +export const PrefetchUserBoundary = async ({ children }: PropsWithChildren) => { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(userOptions()); + + return ( + + {children} + + ); +}; 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 ( diff --git a/src/components/rewards/DiscordRewardCard.tsx b/src/components/rewards/DiscordRewardCard.tsx new file mode 100644 index 0000000000..08c0d754e6 --- /dev/null +++ b/src/components/rewards/DiscordRewardCard.tsx @@ -0,0 +1,106 @@ +import { useGuild } from "@/app/(dashboard)/[guildUrlName]/hooks/useGuild"; +import { IDENTITY_STYLES } from "@/config/constants"; +import { cn } from "@/lib/cssUtils"; +import { env } from "@/lib/env"; +import { userOptions } from "@/lib/options"; +import type { GuildReward } from "@/lib/schemas/guildReward"; +import { IDENTITY_NAME } from "@/lib/schemas/identity"; +import { DiscordRoleRewardDataSchema } from "@/lib/schemas/roleReward"; +import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import type { FunctionComponent } from "react"; +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipTrigger, +} from "../ui/Tooltip"; +import { RewardCard, RewardCardButton } from "./RewardCard"; +import type { RewardCardProps } from "./types"; + +export const DiscordRewardCard: FunctionComponent = ({ + roleId, + reward, +}) => { + const router = useRouter(); + const { + data: { imageUrl, invite }, + } = reward.guildReward as Extract; + const roleRewardData = DiscordRoleRewardDataSchema.parse( + reward.roleReward.data, + ); + + const Icon = IDENTITY_STYLES.DISCORD.icon; + + const { data: user } = useQuery(userOptions()); + const connected = !!user?.identities?.find((i) => i.platform === "DISCORD"); + + const { + data: { id: guildId }, + } = useGuild(); + + const isGuildMember = !!user?.guilds?.find((g) => g.guildId === guildId); + + const hasRoleAccess = !!user?.guilds + ?.flatMap((g) => g.roles) + ?.find((r) => r?.roleId === roleId); + return ( + } + className={IDENTITY_STYLES.DISCORD.borderColorClassName} + > + {!user || !isGuildMember || !hasRoleAccess ? ( + + + } + className={cn( + IDENTITY_STYLES.DISCORD.buttonColorsClassName, + "![--button-bg-hover:var(--button-bg)] ![--button-bg-active:var(--button-bg)] cursor-not-allowed opacity-50", + )} + > + Go to server + + + + + +

+ {!user + ? "Sign in to proceed" + : !isGuildMember + ? "Join guild to check access" + : "Check access to get reward"} +

+
+
+
+ ) : connected ? ( + } + className={IDENTITY_STYLES.DISCORD.buttonColorsClassName} + onClick={() => router.push(`https://discord.gg/${invite}`)} + > + Go to server + + ) : ( + + router.push( + `${env.NEXT_PUBLIC_API}/connect/DISCORD?returnTo=${window.location.href}`, + ) + : undefined + } + > + {`Connect ${IDENTITY_NAME.DISCORD}`} + + )} +
+ ); +}; diff --git a/src/components/rewards/GuildPermissionRewardCard.tsx b/src/components/rewards/GuildPermissionRewardCard.tsx new file mode 100644 index 0000000000..025edc4fac --- /dev/null +++ b/src/components/rewards/GuildPermissionRewardCard.tsx @@ -0,0 +1,15 @@ +import { Wrench } from "@phosphor-icons/react/dist/ssr"; +import type { FunctionComponent } from "react"; +import { RewardCard } from "./RewardCard"; +import type { RewardCardProps } from "./types"; + +export const GuildPermissionRewardCard: FunctionComponent = ({ + reward, +}) => ( + } + /> +); diff --git a/src/components/rewards/PointsRewardCard.tsx b/src/components/rewards/PointsRewardCard.tsx new file mode 100644 index 0000000000..2c297be8ed --- /dev/null +++ b/src/components/rewards/PointsRewardCard.tsx @@ -0,0 +1,38 @@ +import { useGuildUrlName } from "@/app/(dashboard)/[guildUrlName]/hooks/useGuildUrlName"; +import type { GuildReward } from "@/lib/schemas/guildReward"; +import { PointsRoleRewardDataSchema } from "@/lib/schemas/roleReward"; +import { ArrowRight, Star } from "@phosphor-icons/react/dist/ssr"; +import { useRouter } from "next/navigation"; +import type { FunctionComponent } from "react"; +import { RewardCard, RewardCardButton } from "./RewardCard"; +import type { RewardCardProps } from "./types"; + +export const PointsRewardCard: FunctionComponent = ({ + reward, +}) => { + const { + id, + data: { name }, + } = reward.guildReward as Extract; + const roleRewardData = PointsRoleRewardDataSchema.parse( + reward.roleReward.data, + ); + + const router = useRouter(); + const guildUrlName = useGuildUrlName(); + + return ( + } + > + } + onClick={() => router.push(`/${guildUrlName}/leaderboard/${id}`)} + > + View leaderboard + + + ); +}; diff --git a/src/components/rewards/RewardCard.tsx b/src/components/rewards/RewardCard.tsx index 402ba807ac..3015724513 100644 --- a/src/components/rewards/RewardCard.tsx +++ b/src/components/rewards/RewardCard.tsx @@ -16,33 +16,35 @@ export const RewardCard = ({ description?: string; className?: string; }>) => ( - -
-
- {!image ? ( - - ) : typeof image === "string" ? ( - Reward icon - ) : ( - image - )} -
+ +
+
+
+ {!image ? ( + + ) : typeof image === "string" ? ( + Reward icon + ) : ( + image + )} +
- - {title} - + + {title} + - {description && ( - {description} - )} + {description && ( + + {description} + + )} +
+ {children}
- {children}
); @@ -50,5 +52,9 @@ export const RewardCardButton = ({ className, ...props }: Omit) => ( -