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 e55151a33d..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,19 +31,19 @@ "@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.60.2", - "@tanstack/react-query-devtools": "^5.61.0", + "@tanstack/react-query": "^5.62.2", + "@tanstack/react-query-devtools": "^5.62.2", "autoprefixer": "^10.4.20", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "event-source-plus": "^0.1.8", - "foxact": "^0.2.41", - "jotai": "^2.10.2", - "jwt-decode": "^4.0.0", + "foxact": "^0.2.43", + "jotai": "^2.10.3", "mini-svg-data-uri": "^1.4.4", "next": "15.0.3", - "next-themes": "^0.4.3", + "next-themes": "^0.4.4", "pinata-web3": "^0.5.2", "react": "19.0.0-rc-66855b96-20241106", "react-canvas-confetti": "^2.0.7", @@ -56,38 +56,38 @@ "remark-textr": "^6.1.0", "server-only": "^0.0.1", "sonner": "^1.7.0", - "tailwind-merge": "^2.5.4", + "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "typographic-base": "^1.0.4", "vaul": "^1.1.1", - "viem": "^2.21.45", - "wagmi": "^2.12.32", + "viem": "^2.21.54", + "wagmi": "^2.13.3", "zod": "^3.23.8" }, "devDependencies": { "@biomejs/biome": "1.9.0", "@chromatic-com/storybook": "^3.2.2", - "@storybook/addon-essentials": "^8.4.4", - "@storybook/addon-interactions": "^8.4.4", - "@storybook/addon-onboarding": "^8.4.4", + "@storybook/addon-essentials": "^8.4.7", + "@storybook/addon-interactions": "^8.4.7", + "@storybook/addon-onboarding": "^8.4.7", "@storybook/addon-styling-webpack": "^1.0.1", - "@storybook/addon-themes": "^8.4.4", - "@storybook/blocks": "^8.4.4", - "@storybook/nextjs": "^8.4.4", - "@storybook/react": "^8.4.4", - "@storybook/test": "^8.4.4", + "@storybook/addon-themes": "^8.4.7", + "@storybook/blocks": "^8.4.7", + "@storybook/nextjs": "^8.4.7", + "@storybook/react": "^8.4.7", + "@storybook/test": "^8.4.7", "@svgr/webpack": "^8.1.0", "@total-typescript/ts-reset": "^0.6.1", - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "husky": "^9.1.6", + "@types/node": "^20.17.9", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.1", + "husky": "^9.1.7", "lint-staged": "^15.2.10", - "postcss": "^8", - "storybook": "^8.4.4", - "tailwindcss": "^3.4.1", - "type-fest": "^4.29.1", - "typescript": "^5" + "postcss": "^8.4.49", + "storybook": "^8.4.7", + "tailwindcss": "^3.4.16", + "type-fest": "^4.30.0", + "typescript": "^5.7.2" }, "overrides": { "react": "19.0.0-rc-66855b96-20241106", 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/actions/me.ts b/src/actions/me.ts deleted file mode 100644 index 21cec9c513..0000000000 --- a/src/actions/me.ts +++ /dev/null @@ -1,14 +0,0 @@ -"use server"; - -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; -import { env } from "@/lib/env"; -import { cookies } from "next/headers"; - -export async function me() { - const token = (await cookies()).get(GUILD_AUTH_COOKIE_NAME); - const response = await fetch(`${env.NEXT_PUBLIC_API}/auth/me`, { - headers: { "X-Auth-Token": token?.value || "" }, - }); - - return response.json(); -} diff --git a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx new file mode 100644 index 0000000000..d222fa8996 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { RequirementDisplayComponent } from "@/components/requirements/RequirementDisplayComponent"; +import { rewardCards } from "@/components/rewards/rewardCards"; +import { Badge } from "@/components/ui/Badge"; +import { Button, buttonVariants } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { useUser } from "@/hooks/useUser"; +import { cn } from "@/lib/cssUtils"; +import { fetchGuildApiData } from "@/lib/fetchGuildApi"; +import type { GuildReward, GuildRewardType } from "@/lib/schemas/guildReward"; +import type { Role } from "@/lib/schemas/role"; +import { Check, ImageSquare, LockSimple } from "@phosphor-icons/react/dist/ssr"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useSetAtom } from "jotai"; +import { Suspense } from "react"; +import { joinModalAtom } from "../atoms"; +import { useGuild } from "../hooks/useGuild"; +import { useSuspenseRoles } from "../hooks/useSuspenseRoles"; + +const GuildPage = () => { + const { data: roles } = useSuspenseRoles(); + + return ( +
+ {roles.map((role) => ( + } + key={role.id} + > + + + ))} +
+ ); +}; + +const RoleCard = ({ role }: { role: Role }) => ( + +
+
+ {role.imageUrl ? ( + role avatar + ) : ( +
+ +
+ )} +

{role.name}

+
+

+ {role.description} +

+ + Loading rewards...

}> + +
+
+ +
+
+ + REQUIREMENTS + + + +
+ + {/* 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; +}; + +// TODO: handle state during join & error/no access states too +const ACCESS_INDICATOR_CLASS = + "rounded-b-2xl rounded-t-none min-w-full justify-between sm:rounded-b-xl sm:rounded-t-xl sm:min-w-max"; +const AccessIndicator = ({ + roleId, + className, +}: { roleId: Role["id"]; className?: string }) => { + const { data: guild } = useGuild(); + + const { data: user } = useUser(); + const isGuildMember = user?.guilds?.find((g) => g.guildId === guild.id); + const isRoleMember = !!user?.guilds + ?.flatMap((g) => g.roles) + ?.find((r) => r?.roleId === roleId); + + const onJoinModalOpenChange = useSetAtom(joinModalAtom); + + if (!isGuildMember) + return ( + + ); + + if (!isRoleMember) + return ( + + ); + + return ( + + + You have access + + ); +}; + +export default GuildPage; diff --git a/src/app/(dashboard)/[guildUrlName]/atoms.ts b/src/app/(dashboard)/[guildUrlName]/atoms.ts new file mode 100644 index 0000000000..f1b445154f --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/atoms.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const joinModalAtom = atom(false); diff --git a/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx new file mode 100644 index 0000000000..f90848a398 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Button } from "@/components/ui/Button"; +import { useUser } from "@/hooks/useUser"; +import { useGuild } from "../hooks/useGuild"; +import { JoinGuild } from "./JoinGuild"; +import { LeaveGuild } from "./LeaveGuild"; + +export const ActionButton = () => { + const user = useUser(); + 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 new file mode 100644 index 0000000000..2c0fe00936 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx @@ -0,0 +1,53 @@ +"use client"; + +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 { useGuild } from "../hooks/useGuild"; +import { useSuspensePages } from "../hooks/useSuspensePages"; +import { PageNavLink } from "./RoleGroupNavLink"; + +export const GuildTabs = () => { + const { data: guild } = useGuild(); + const { data: pages } = useSuspensePages(); + + return ( + +
+ {pages.map((rg) => ( + `/${s}`) + .join("")} + > + {rg.name} + + ))} +
+ +
+ ); +}; + +const SKELETON_SIZES = ["w-20", "w-36", "w-28"]; +export const GuildTabsSkeleton = () => ( +
+ {[...Array(3)].map((_, i) => ( + + + + ))} +
+); diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx new file mode 100644 index 0000000000..1256275ede --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx @@ -0,0 +1,257 @@ +"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 { useUser } from "@/hooks/useUser"; +import { cn } from "@/lib/cssUtils"; +import { env } from "@/lib/env"; +import { 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, useQueryClient } from "@tanstack/react-query"; +import { EventSourcePlus } from "event-source-plus"; +import { useAtom, useSetAtom } from "jotai"; +import { useRouter, useSearchParams } from "next/navigation"; +import { type ReactNode, useEffect } from "react"; +import { toast } from "sonner"; +import { joinModalAtom } from "../atoms"; +import { useGuild } from "../hooks/useGuild"; +import { useSuspenseRoles } from "../hooks/useSuspenseRoles"; + +const JOIN_MODAL_SEARCH_PARAM = "join"; + +export const JoinGuild = () => { + const searchParams = useSearchParams(); + const shouldOpen = searchParams.has(JOIN_MODAL_SEARCH_PARAM); + + const [open, onOpenChange] = useAtom(joinModalAtom); + + useEffect(() => { + if (!shouldOpen) return; + onOpenChange(true); + }, [shouldOpen, onOpenChange]); + + const { data: user } = useUser(); + + 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 } = useUser(); + + 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 guild = useGuild(); + + const { data: user } = useUser(); + + const queryClient = useQueryClient(); + + if (!guild.data) { + throw new Error("Failed to fetch guild"); + } + + const { data: roles } = useSuspenseRoles(); + + const onJoinModalOpenChange = useSetAtom(joinModalAtom); + + 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 sse = JSON.parse( + sseMessage.data, + // biome-ignore lint/suspicious/noExplicitAny: TODO: fill missing types + ) as any; + const { status, message, data } = sse; + if (status === "Completed") { + if (data === undefined) { + throw new Error( + "Server responded with success, but returned no user", + ); + } + resolve(data); + } else if (status === "Acquired roles:") { + // TODO: temp, just for the demo + if ("roles" in sse) { + console.log("roles", sse); + const roleNames = sse.roles.map( + ({ roleId }: { roleId: string }) => { + const role = roles.find((r) => r.id === roleId); + return role?.name; + }, + ); + toast("Acquired roles", { + description: roleNames.filter(Boolean).join(", "), + }); + return; + } + } 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) => { + onJoinModalOpenChange(false); + 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..8bf78a108b --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { IconButton } from "@/components/ui/IconButton"; +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 ( + + + mutate()} + isLoading={isPending} + icon={} + /> + + + +

Leave guild

+
+
+ ); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/components/RoleGroupNavLink.tsx b/src/app/(dashboard)/[guildUrlName]/components/RoleGroupNavLink.tsx new file mode 100644 index 0000000000..32b327ea29 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/RoleGroupNavLink.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { buttonVariants } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { PropsWithChildren } from "react"; + +export const PageNavLink = ({ + href, + children, +}: PropsWithChildren<{ href: string }>) => { + const pathname = usePathname(); + const isActive = pathname === href; + + return ( + + + {children} + + + ); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts b/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts new file mode 100644 index 0000000000..8e0f0892f5 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts @@ -0,0 +1,15 @@ +"use client"; + +import { guildOptions } from "@/lib/options"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +export const useGuild = (urlName?: string) => { + const { guildUrlName: urlNameFromHook } = useParams<{ + guildUrlName: string; + }>(); + + const guildIdLike = urlName ?? urlNameFromHook; + + return useSuspenseQuery(guildOptions({ guildIdLike })); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/hooks/useGuildUrlName.ts b/src/app/(dashboard)/[guildUrlName]/hooks/useGuildUrlName.ts new file mode 100644 index 0000000000..4ff14458ce --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/hooks/useGuildUrlName.ts @@ -0,0 +1,7 @@ +import { useParams } from "next/navigation"; + +export const useGuildUrlName = () => { + const { guildUrlName } = useParams<{ guildUrlName: string }>(); + + return guildUrlName; +}; diff --git a/src/app/(dashboard)/[guildUrlName]/hooks/usePageUrlName.ts b/src/app/(dashboard)/[guildUrlName]/hooks/usePageUrlName.ts new file mode 100644 index 0000000000..53effb43b8 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/hooks/usePageUrlName.ts @@ -0,0 +1,7 @@ +import { useParams } from "next/navigation"; + +export const usePageUrlName = () => + useParams<{ + pageUrlName: string; + guildUrlName: string; + }>(); diff --git a/src/app/(dashboard)/[guildUrlName]/hooks/useSuspensePages.ts b/src/app/(dashboard)/[guildUrlName]/hooks/useSuspensePages.ts new file mode 100644 index 0000000000..d67aa182c5 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/hooks/useSuspensePages.ts @@ -0,0 +1,9 @@ +import { pageBatchOptions } from "@/lib/options"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useGuildUrlName } from "./useGuildUrlName"; + +export const useSuspensePages = () => { + const guildUrlName = useGuildUrlName(); + + return useSuspenseQuery(pageBatchOptions({ guildIdLike: guildUrlName })); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/hooks/useSuspenseRoles.ts b/src/app/(dashboard)/[guildUrlName]/hooks/useSuspenseRoles.ts new file mode 100644 index 0000000000..5558535321 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/hooks/useSuspenseRoles.ts @@ -0,0 +1,14 @@ +import { roleBatchOptions } from "@/lib/options"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { usePageUrlName } from "./usePageUrlName"; + +export const useSuspenseRoles = () => { + const { guildUrlName, pageUrlName } = usePageUrlName(); + + return useSuspenseQuery( + roleBatchOptions({ + guildIdLike: guildUrlName, + pageIdLike: pageUrlName, + }), + ); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/layout.tsx b/src/app/(dashboard)/[guildUrlName]/layout.tsx new file mode 100644 index 0000000000..8cac86dc61 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/layout.tsx @@ -0,0 +1,96 @@ +import { GuildImage } from "@/components/GuildImage"; +import { getQueryClient } from "@/lib/getQueryClient"; +import { + guildOptions, + pageBatchOptions, + roleBatchOptions, + userOptions, +} from "@/lib/options"; +import type { DynamicRoute } from "@/lib/types"; +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import { type PropsWithChildren, Suspense } from "react"; +import { ActionButton, ActionButtonSkeleton } from "./components/ActionButton"; +import { GuildTabs, GuildTabsSkeleton } from "./components/GuildTabs"; + +const GuildLayout = async ({ + params, + children, +}: PropsWithChildren>) => { + const { guildUrlName } = await params; + const queryClient = getQueryClient(); + + // TODO: handle possible request failures + await Promise.all([ + queryClient.prefetchQuery(userOptions()), + queryClient.prefetchQuery(pageBatchOptions({ guildIdLike: guildUrlName })), + queryClient.prefetchQuery( + guildOptions({ + guildIdLike: guildUrlName, + }), + ), + ]); + + const pageBatch = queryClient.getQueryData( + pageBatchOptions({ guildIdLike: guildUrlName }).queryKey, + ); + const roleBatchOptionsCollection = pageBatch?.map((page) => { + return roleBatchOptions({ + pageIdLike: page.urlName!, + guildIdLike: guildUrlName, + }); + }); + + if (roleBatchOptionsCollection) { + await Promise.all( + roleBatchOptionsCollection.map((c) => queryClient.prefetchQuery(c)), + ); + } + + const guild = queryClient.getQueryState( + guildOptions({ + guildIdLike: guildUrlName, + }).queryKey, + ); + + if (guild?.error || !guild?.data) { + throw new Error(`Failed to fetch guild ${guild?.error?.status || ""}`); + } + + return ( + +
+
+
+
+
+ +

+ {guild.data.name} +

+
+ + }> + + +
+

+ {guild.data.description} +

+
+
+ + }> + + + + {children} +
+
+ ); +}; + +export default GuildLayout; 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..64271e3957 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useUser } from "@/hooks/useUser"; +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { useSuspensePointReward } from "../hooks/useSuspensePointReward"; +import { leaderboardOptions } from "../options"; +import { LeaderboardUserCard } from "./LeaderboardUserCard"; + +export const Leaderboard = () => { + const { data: user } = useUser(); + + const { rewardId } = useParams<{ rewardId: string }>(); + const { data: rawData } = useSuspenseInfiniteQuery( + leaderboardOptions({ rewardId, userId: user?.id }), + ); + + const { data: pointReward } = useSuspensePointReward(); + + 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..9082a2dd43 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/LeaderboardUserCard.tsx @@ -0,0 +1,33 @@ +import { Card } from "@/components/ui/Card"; +import type { Leaderboard } from "@/lib/schemas/leaderboard"; +import { User } from "@phosphor-icons/react/dist/ssr"; +import { useSuspensePointReward } from "../hooks/useSuspensePointReward"; + +export const LeaderboardUserCard = ({ + user, +}: { user: NonNullable }) => { + const { data: pointReward } = useSuspensePointReward(); + + return ( + +
+ {`#${user.rank}`} +
+ +
+
+
+ +
+ + {user.primaryIdentity.foreignId} + +
+ + {`${user.amount} `} + {pointReward.data.name} + +
+
+ ); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/hooks/useSuspensePointReward.ts b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/hooks/useSuspensePointReward.ts new file mode 100644 index 0000000000..b2fe3afb2c --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/hooks/useSuspensePointReward.ts @@ -0,0 +1,8 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { pointsRewardOptions } from "../options"; + +export const useSuspensePointReward = () => { + const { rewardId } = useParams<{ rewardId: string }>(); + return useSuspenseQuery(pointsRewardOptions({ rewardId })); +}; 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..f519018f64 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/page.tsx @@ -0,0 +1,30 @@ +import { getQueryClient } from "@/lib/getQueryClient"; +import { userOptions } from "@/lib/options"; +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(); + const user = await queryClient.fetchQuery(userOptions()); + await queryClient.prefetchInfiniteQuery( + leaderboardOptions({ rewardId, userId: user?.id }), + ); + await queryClient.prefetchQuery(pointsRewardOptions({ rewardId })); + + return ( + + + + + + ); +}; + +export default LeaderboardPage; diff --git a/src/app/(dashboard)/[guildUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/page.tsx new file mode 100644 index 0000000000..dd1db1e2bd --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/page.tsx @@ -0,0 +1,7 @@ +import GuildPage from "./[pageUrlName]/page"; + +const DefaultGuildPage = async () => { + return ; +}; + +export default DefaultGuildPage; diff --git a/src/app/(dashboard)/[guild]/page.tsx b/src/app/(dashboard)/[guild]/page.tsx deleted file mode 100644 index b31d944b7e..0000000000 --- a/src/app/(dashboard)/[guild]/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Metadata } from "next"; - -type Props = { - params: Promise<{ guild: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}; - -export async function generateMetadata({ params }: Props): Promise { - const urlName = (await params).guild; - - return { - title: urlName, - }; -} - -const GuildPage = async ({ params }: Props) => { - const urlName = (await params).guild; - - return ( -
-

Guild page

-

{`URL name: ${urlName}`}

-
- ); -}; - -export default GuildPage; 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)/create-guild/page.tsx b/src/app/(dashboard)/create-guild/page.tsx index fcb9b6c6c5..054d28a663 100644 --- a/src/app/(dashboard)/create-guild/page.tsx +++ b/src/app/(dashboard)/create-guild/page.tsx @@ -1,5 +1,4 @@ import { AuthBoundary } from "@/components/AuthBoundary"; -import { ConfettiProvider } from "@/components/ConfettiProvider"; import { SignInButton } from "@/components/SignInButton"; import { Card } from "@/components/ui/Card"; import svgToTinyDataUri from "mini-svg-data-uri"; @@ -23,23 +22,21 @@ const CreateGuild = () => ( /> - - -

- Begin your guild -

+ +

+ Begin your guild +

-
- -
+
+ +
- } - > - - -
-
+ } + > + + +
); diff --git a/src/app/(dashboard)/explorer/fetchers.ts b/src/app/(dashboard)/explorer/fetchers.ts index f9e275d2f1..0a842927cf 100644 --- a/src/app/(dashboard)/explorer/fetchers.ts +++ b/src/app/(dashboard)/explorer/fetchers.ts @@ -1,13 +1,14 @@ +import { useUser } from "@/hooks/useUser"; import { fetchGuildApiData } from "@/lib/fetchGuildApi"; -import { tryGetParsedToken } from "@/lib/token"; +import type { Leaderboard } from "@/lib/schemas/leaderboard"; import type { PaginatedResponse } from "@/lib/types"; import type { Schemas } from "@guildxyz/types"; import { PAGE_SIZE } from "./constants"; export const fetchAssociatedGuilds = async () => { - const { userId } = await tryGetParsedToken(); + const { data: user } = useUser(); 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 +20,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 ac55280d95..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(); - void queryClient.prefetchInfiniteQuery(guildSearchOptions({})); - //void queryClient.prefetchQuery(associatedGuildsOption()); + await queryClient.prefetchInfiniteQuery(guildSearchOptions({})); + await queryClient.prefetchQuery(associatedGuildsOption()); return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 94c5da2364..835f1467f8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,36 +1,42 @@ 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 { type ReactNode, 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 = ({ children, }: Readonly<{ - children: React.ReactNode; + children: ReactNode; }>) => { return ( - {children} + {children} - + + + + diff --git a/src/components/AuthBoundary.tsx b/src/components/AuthBoundary.tsx index d90ec5af4e..584643026e 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 { useUser } from "@/hooks/useUser"; +import type { ReactNode } from "react"; + +export const AuthBoundary = ({ fallback, children, }: Readonly<{ - fallback: React.ReactNode; - children: React.ReactNode; + fallback: ReactNode; + children: ReactNode; }>) => { - try { - await tryGetToken(); - return <>{children}; - } catch { - return <>{fallback}; - } + const { data: user } = useUser(); + + 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/Providers.tsx b/src/components/Providers.tsx index 51c80a8193..dc25cb332a 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -8,6 +8,7 @@ import { Provider as JotaiProvider } from "jotai"; import { ThemeProvider } from "next-themes"; import type { FunctionComponent, PropsWithChildren } from "react"; import { WagmiProvider } from "wagmi"; +import { ConfettiProvider } from "./ConfettiProvider"; import { TooltipProvider } from "./ui/Tooltip"; export const Providers: FunctionComponent = ({ @@ -26,7 +27,7 @@ export const Providers: FunctionComponent = ({ - {children} + {children} diff --git a/src/components/SignInButton.tsx b/src/components/SignInButton.tsx index 9715059b4b..a84c172458 100644 --- a/src/components/SignInButton.tsx +++ b/src/components/SignInButton.tsx @@ -1,18 +1,21 @@ "use client"; import { signInDialogOpenAtom } from "@/config/atoms"; -import { SignIn } from "@phosphor-icons/react/dist/ssr"; +import { useUser } from "@/hooks/useUser"; +import { Check, SignIn } from "@phosphor-icons/react/dist/ssr"; 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 } = useUser(); return ( diff --git a/src/components/requirements/DataBlock.tsx b/src/components/requirements/DataBlock.tsx index cb9f1ad7c0..75959f51ef 100644 --- a/src/components/requirements/DataBlock.tsx +++ b/src/components/requirements/DataBlock.tsx @@ -1,6 +1,6 @@ -import type { PropsWithChildren } from "react"; +import type { PropsWithChildren, ReactElement } from "react"; -export const DataBlock = ({ children }: PropsWithChildren): JSX.Element => { +export const DataBlock = ({ children }: PropsWithChildren): ReactElement => { return ( {children} diff --git a/src/components/requirements/DataBlockWithCopy.tsx b/src/components/requirements/DataBlockWithCopy.tsx index cee1806e0b..fc71f7a37d 100644 --- a/src/components/requirements/DataBlockWithCopy.tsx +++ b/src/components/requirements/DataBlockWithCopy.tsx @@ -9,7 +9,7 @@ import { import { Check } from "@phosphor-icons/react/dist/ssr"; import { useClipboard } from "foxact/use-clipboard"; import { useDebouncedState } from "foxact/use-debounced-state"; -import { type PropsWithChildren, useEffect } from "react"; +import { type PropsWithChildren, type ReactElement, useEffect } from "react"; import { DataBlock } from "./DataBlock"; type Props = { @@ -19,7 +19,7 @@ type Props = { export const DataBlockWithCopy = ({ text, children, -}: PropsWithChildren): JSX.Element => { +}: PropsWithChildren): ReactElement => { const { copied, copy } = useClipboard({ timeout: 1500, }); diff --git a/src/components/requirements/RequirementDisplayComponent.tsx b/src/components/requirements/RequirementDisplayComponent.tsx new file mode 100644 index 0000000000..b22d2ddf06 --- /dev/null +++ b/src/components/requirements/RequirementDisplayComponent.tsx @@ -0,0 +1,151 @@ +import { ChainIndicator } from "@/components/requirements/ChainIndicator"; +import { RequirementLink } from "@/components/requirements/RequirementLink"; +import { CHAINS, type SupportedChainID } from "@/config/chains"; +import { GearSix, Warning } from "@phosphor-icons/react/dist/ssr"; +import { + Requirement, + RequirementContent, + RequirementFooter, + RequirementImage, +} from "./Requirement"; + +import type { Rule } from "@/lib/schemas/rule"; +import { shortenHex } from "@/lib/shortenHex"; +import { Fragment } from "react"; +import { Badge } from "../ui/Badge"; +import { DataBlockWithCopy } from "./DataBlockWithCopy"; +import { ADDRESS_REGEX, PLACEHOLDER_REGEX } from "./constants"; + +const convertTemplateText = (templateText: string, requirement: Rule) => + templateText.replace(PLACEHOLDER_REGEX, (_match, rawKey) => { + const key = rawKey.trim() as keyof typeof requirement.config & + keyof typeof requirement.integration; + + const usableKeyValues = { + ...requirement.config, + ...requirement.integration, + }; + + return key in usableKeyValues ? usableKeyValues[key] : key; + }); + +const isSupportedChain = (chainId?: number): chainId is SupportedChainID => + chainId ? !!CHAINS[chainId as SupportedChainID] : false; + +const RequirementNode = ({ + node, + requirement, +}: { + node: Rule["ui"]["nodes"][number]; + requirement: Rule; +}) => { + switch (node.type) { + case "TEXT": + return ( + + {convertTemplateText(node.value, requirement)} + + ); + case "CHAIN_INDICATOR": + // @ts-expect-error - TODO: create a map for chainId - chainName (uppercase) pairs + return isSupportedChain(node.value) ? ( + + ) : null; + case "EXTERNAL_LINK": + return ( + + {node.value} + + ); + default: + return ( + + + Unsupported node + + ); + } +}; + +export const RequirementDisplayComponent = ({ + requirement, +}: { requirement: Rule }) => { + const headerNodes = requirement.ui.nodes.filter( + (node) => node.position === "HEADER", + ); + const footerNodes = requirement.ui.nodes.filter( + (node) => node.position === "FOOTER", + ); + + if (requirement.ui.nodes?.length > 0) { + return ( + + + +

+ {headerNodes.map((node) => ( + + ))} +

+ {footerNodes?.length > 0 && ( + + {footerNodes.map((node) => ( + + ))} + + )} +
+
+ ); + } + + const integrationConfigArray = Object.entries(requirement.config); + + return ( + + + + + +

+ {`${requirement.integration.displayName} (`} + {integrationConfigArray.map(([key, value], index) => ( + + {`${key}: `} + {typeof value === "string" && ADDRESS_REGEX.test(value) ? ( + + {shortenHex(value)} + + ) : ( + + {typeof value === "object" ? JSON.stringify(value) : value} + + )} + {index < integrationConfigArray.length - 1 && {", "}} + + ))} + ) +

+ {typeof requirement.config.data !== "string" && + "chain" in requirement.config.data && + typeof requirement.config.data.chain === "number" && + isSupportedChain(requirement.config.data.chain) && ( + + + + )} +
+
+ ); +}; diff --git a/src/components/requirements/constants.ts b/src/components/requirements/constants.ts new file mode 100644 index 0000000000..c279b1feb5 --- /dev/null +++ b/src/components/requirements/constants.ts @@ -0,0 +1,2 @@ +export const PLACEHOLDER_REGEX = /\{\{([^{}]+)\}\}/g; +export const ADDRESS_REGEX = /^0x[a-f0-9]{40}$/i; diff --git a/src/components/rewards/DiscordRewardCard.tsx b/src/components/rewards/DiscordRewardCard.tsx new file mode 100644 index 0000000000..185b916a93 --- /dev/null +++ b/src/components/rewards/DiscordRewardCard.tsx @@ -0,0 +1,105 @@ +import { useGuild } from "@/app/(dashboard)/[guildUrlName]/hooks/useGuild"; +import { IDENTITY_STYLES } from "@/config/constants"; +import { useUser } from "@/hooks/useUser"; +import { cn } from "@/lib/cssUtils"; +import { env } from "@/lib/env"; +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 { 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 } = useUser(); + 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) => ( -