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.name}
+
+
+ {role.description}
+
+
+
Loading rewards...}>
+
+
+
+
+
+
+
+ {/* 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 (
+ }
+ className={cn(ACCESS_INDICATOR_CLASS, className)}
+ onClick={() => onJoinModalOpenChange(true)}
+ >
+ Join guild to collect rewards
+
+ );
+
+ if (!isRoleMember)
+ return (
+ }
+ className={cn(ACCESS_INDICATOR_CLASS, className)}
+ onClick={() => onJoinModalOpenChange(true)}
+ >
+ Check access to collect rewards
+
+ );
+
+ 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 }) => (
+
+);
+
+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 && (
+
+ )}
+
+
+ {`${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 (
}
+ leftIcon={user ? : }
+ disabled={!!user}
onClick={() => setSignInDialogOpen(true)}
>
Sign in
diff --git a/src/components/SignInDialog.tsx b/src/components/SignInDialog.tsx
index 740745b7ad..f1ccf1c155 100644
--- a/src/components/SignInDialog.tsx
+++ b/src/components/SignInDialog.tsx
@@ -1,11 +1,12 @@
"use client";
-import { signIn } from "@/actions/auth";
import { signInDialogOpenAtom } from "@/config/atoms";
import { fetchGuildApi } from "@/lib/fetchGuildApi";
+import { userOptions } from "@/lib/options";
+import { authSchema } from "@/lib/schemas/user";
import { SignIn, User, Wallet, XCircle } from "@phosphor-icons/react/dist/ssr";
import { DialogDescription } from "@radix-ui/react-dialog";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtom, useSetAtom } from "jotai";
import { shortenHex } from "lib/shortenHex";
import { toast } from "sonner";
@@ -21,7 +22,6 @@ import {
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "./ui/ResponsiveDialog";
-
const CUSTOM_CONNECTOR_ICONS = {
"com.brave.wallet": "/walletLogos/brave.svg",
walletConnect: "/walletLogos/walletconnect.svg",
@@ -133,6 +133,8 @@ const SignInWithEthereum = () => {
const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom);
+ const queryClient = useQueryClient();
+
const { mutate: signInWithEthereum, isPending } = useMutation({
mutationKey: ["SIWE"],
mutationFn: async () => {
@@ -155,9 +157,33 @@ const SignInWithEthereum = () => {
const signature = await signMessageAsync({ message });
- return signIn({ message, signature });
+ 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);
+
+ return authData;
+ },
+ onSuccess: () => {
+ setSignInDialogOpen(false);
+ queryClient.invalidateQueries({
+ queryKey: userOptions().queryKey,
+ });
},
- onSuccess: () => setSignInDialogOpen(false),
onError: (error) => {
toast("Sign in error", {
description: error.message,
diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx
index 38540a6e67..33b94a0778 100644
--- a/src/components/SignOutButton.tsx
+++ b/src/components/SignOutButton.tsx
@@ -1,17 +1,34 @@
"use client";
-import { signOut } from "@/actions/auth";
+import { associatedGuildsOption } from "@/app/(dashboard)/explorer/options";
+import { fetchGuildApi } from "@/lib/fetchGuildApi";
+import { userOptions } from "@/lib/options";
import { SignOut } from "@phosphor-icons/react/dist/ssr";
-import { usePathname } from "next/navigation";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "./ui/Button";
export const SignOutButton = () => {
- const pathname = usePathname();
+ const queryClient = useQueryClient();
+
+ const { mutate: signOut, isPending } = useMutation({
+ mutationFn: () =>
+ fetchGuildApi("auth/logout", {
+ method: "POST",
+ }),
+ onSuccess: () => {
+ queryClient.resetQueries({ queryKey: userOptions().queryKey });
+ queryClient.resetQueries({
+ queryKey: associatedGuildsOption().queryKey,
+ });
+ },
+ });
+
return (
}
- onClick={() => signOut(pathname)}
+ onClick={() => signOut()}
+ isLoading={isPending}
>
Sign out
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" ? (
-
- ) : (
- image
- )}
-
+
+
+
+
+ {!image ? (
+
+ ) : typeof image === "string" ? (
+
+ ) : (
+ image
+ )}
+
-
- {title}
-
+
+ {title}
+
- {description && (
-
{description}
- )}
+ {description && (
+
+ {description}
+
+ )}
+
+ {children}
- {children}
);
@@ -50,5 +52,9 @@ export const RewardCardButton = ({
className,
...props
}: Omit
) => (
-
+
);
diff --git a/src/components/rewards/rewardCards.ts b/src/components/rewards/rewardCards.ts
new file mode 100644
index 0000000000..8104a0c2d4
--- /dev/null
+++ b/src/components/rewards/rewardCards.ts
@@ -0,0 +1,15 @@
+import type { GuildRewardType } from "@/lib/schemas/guildReward";
+import type { FunctionComponent } from "react";
+import { DiscordRewardCard } from "./DiscordRewardCard";
+import { GuildPermissionRewardCard } from "./GuildPermissionRewardCard";
+import { PointsRewardCard } from "./PointsRewardCard";
+import type { RewardCardProps } from "./types";
+
+export const rewardCards = {
+ GUILD: GuildPermissionRewardCard,
+ POINTS: PointsRewardCard,
+ DISCORD: DiscordRewardCard,
+} as const satisfies Record<
+ Exclude,
+ FunctionComponent
+>;
diff --git a/src/components/rewards/types.ts b/src/components/rewards/types.ts
new file mode 100644
index 0000000000..30928a87ad
--- /dev/null
+++ b/src/components/rewards/types.ts
@@ -0,0 +1,10 @@
+import type { GuildReward } from "@/lib/schemas/guildReward";
+import type { RoleReward } from "@/lib/schemas/roleReward";
+
+export type RewardCardProps = {
+ roleId: string;
+ reward: {
+ guildReward: GuildReward;
+ roleReward: RoleReward;
+ };
+};
diff --git a/src/config/constants.ts b/src/config/constants.ts
index 698f9f9e61..d1ea5a3519 100644
--- a/src/config/constants.ts
+++ b/src/config/constants.ts
@@ -1 +1,21 @@
-export const GUILD_AUTH_COOKIE_NAME = "guild-auth";
+import type { IdentityType } from "@/lib/schemas/identity";
+import type { Icon } from "@phosphor-icons/react/dist/lib/types";
+import { DiscordLogo } from "@phosphor-icons/react/dist/ssr";
+
+export const IDENTITY_STYLES = {
+ DISCORD: {
+ bgColorClassName: "bg-indigo-500",
+ borderColorClassName: "border-indigo-500",
+ buttonColorsClassName:
+ "[--button-bg:theme(colors.indigo.500)] [--button-bg-hover:theme(colors.indigo.600)] [--button-bg-active:theme(colors.indigo.700)] dark:[--button-bg-hover:theme(colors.indigo.400)] dark:[--button-bg-active:theme(colors.indigo.300)] [--button-foreground:theme(colors.white)]",
+ icon: DiscordLogo,
+ },
+} satisfies Record<
+ IdentityType,
+ {
+ bgColorClassName: string;
+ borderColorClassName: string;
+ buttonColorsClassName: string;
+ icon: Icon;
+ }
+>;
diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts
new file mode 100644
index 0000000000..8d1c1a365c
--- /dev/null
+++ b/src/hooks/useUser.ts
@@ -0,0 +1,4 @@
+import { userOptions } from "@/lib/options";
+import { useQuery } from "@tanstack/react-query";
+
+export const useUser = () => useQuery(userOptions());
diff --git a/src/lib/fetchGuildApi.ts b/src/lib/fetchGuildApi.ts
index 43852f6a63..d1e0d7cfad 100644
--- a/src/lib/fetchGuildApi.ts
+++ b/src/lib/fetchGuildApi.ts
@@ -1,5 +1,4 @@
-import { signOut } from "@/actions/auth";
-import { tryGetToken } from "@/lib/token";
+import { isServer } from "@tanstack/react-query";
import { env } from "./env";
import type { ErrorLike } from "./types";
@@ -9,8 +8,15 @@ type FetchResult =
// TODO: include a dedicated logger with severity channels
const logger = {
- info: (...args: Parameters) => {
- if (process.env.NODE_ENV === "development" && env.LOGGING > 0) {
+ info: (
+ { response }: { response: Response },
+ ...args: Parameters
+ ) => {
+ if (
+ process.env.NODE_ENV === "development" &&
+ env.LOGGING > 0 &&
+ !response.ok
+ ) {
console.info(
`[${new Date().toLocaleTimeString()} - fetchGuildApi]:`,
...args,
@@ -57,45 +63,40 @@ export const fetchGuildApi = async (
requestInit?: RequestInit,
): Promise> => {
if (pathname.startsWith("/")) {
- throw new Error("`pathname` must not start with slash");
+ throw new Error(`"pathname" must not start with slash: ${pathname}`);
}
if (pathname.endsWith("/")) {
- throw new Error("`pathname` must not end with slash");
+ throw new Error(`"pathname" must not end with slash: ${pathname}`);
}
const url = new URL(`api/${pathname}`, env.NEXT_PUBLIC_API);
- let token: string | undefined;
- try {
- token = await tryGetToken();
- } catch (_) {
- //logger.info(e);
- }
-
const headers = new Headers(requestInit?.headers);
- if (token) {
- headers.set("X-Auth-Token", token);
- }
+
if (requestInit?.body instanceof FormData) {
headers.set("Content-Type", "multipart/form-data");
} else if (requestInit?.body) {
headers.set("Content-Type", "application/json");
}
+ // Next.js won't include cookies automatically, so we include them manually on the server
+ if (isServer) {
+ const { cookies } = await import("next/headers");
+ const cookiesHeader = (await cookies()).toString();
+ headers.set("cookie", cookiesHeader);
+ }
+
const response = await fetch(url, {
...requestInit,
headers,
+ credentials: "include",
});
- if (response.status === 401) {
- signOut();
- }
-
const contentType = response.headers.get("content-type");
if (!contentType?.includes("application/json")) {
throw new Error("Guild API failed to respond with json");
}
- logger.info("\n", url.toString(), response.status);
+ logger.info({ response }, "\n", url.toString(), response.status);
let json: unknown;
try {
@@ -104,7 +105,7 @@ export const fetchGuildApi = async (
throw new Error("Failed to parse json from response");
}
- logger.info(json, "\n");
+ logger.info({ response }, json, "\n");
if (!response.ok) {
return {
diff --git a/src/lib/fetchers.ts b/src/lib/fetchers.ts
index 6f15e7399e..0471d87c30 100644
--- a/src/lib/fetchers.ts
+++ b/src/lib/fetchers.ts
@@ -1,46 +1,78 @@
import { fetchGuildApiData } from "@/lib/fetchGuildApi";
import { resolveIdLikeRequest } from "@/lib/resolveIdLikeRequest";
-import { tryGetParsedToken } from "@/lib/token";
-import type { ErrorLike, WithIdLike } from "@/lib/types";
+import type {
+ Entity,
+ EntitySchema,
+ ErrorLike,
+ WithId,
+ WithIdLike,
+} from "@/lib/types";
import type { Schemas } from "@guildxyz/types";
+import { z } from "zod";
+import type { Role } from "./schemas/role";
-export const fetchGuildLeave = async ({ guildId }: { guildId: string }) => {
- return fetchGuildApiData(`guild/${guildId}/leave`, {
- method: "POST",
- });
-};
-
-export const fetchGuild = async ({ idLike }: WithIdLike) => {
- return fetchGuildApiData(
- `guild/${resolveIdLikeRequest(idLike)}`,
- );
-};
-
-export const fetchEntity = async ({
+export const fetchEntity = async ({
idLike,
entity,
responseInit,
}: {
- entity: string;
+ entity: T;
idLike: string;
responseInit?: Parameters[1];
}) => {
const pathname = `${entity}/${resolveIdLikeRequest(idLike)}`;
- return fetchGuildApiData(pathname, responseInit);
+ return fetchGuildApiData, Error>(pathname, responseInit);
};
export const fetchUser = async () => {
- const { userId } = await tryGetParsedToken();
- return fetchEntity({
- entity: "user",
- idLike: userId,
+ return fetchGuildApiData("auth/me");
+};
+
+export const fetchGuildLeave = async ({ guildId }: { guildId: string }) => {
+ return fetchGuildApiData(`guild/${guildId}/leave`, {
+ method: "POST",
});
};
-export const fetchPages = async ({ guildId }: { guildId: string }) => {
- const guild = await fetchGuild({ idLike: guildId });
+export const fetchPageBatch = async ({ guildIdLike }: WithIdLike<"guild">) => {
+ const guild = await fetchEntity({ entity: "guild", idLike: guildIdLike });
return fetchGuildApiData("page/batch", {
method: "POST",
body: JSON.stringify({ ids: guild.pages?.map((p) => p.pageId!) ?? [] }),
});
};
+
+export const fetchRoleBatch = async ({
+ pageIdLike,
+ guildIdLike,
+}: Partial> & WithIdLike<"guild">) => {
+ const isHomePageUrl = !pageIdLike;
+ let pageIdLikeWithHome = pageIdLike;
+ if (isHomePageUrl) {
+ const { homePageId } = await fetchEntity({
+ entity: "guild",
+ idLike: guildIdLike,
+ });
+ pageIdLikeWithHome = homePageId!;
+ }
+ const page = await fetchEntity({
+ entity: "page",
+ idLike: pageIdLikeWithHome!,
+ });
+
+ return fetchGuildApiData("role/batch", {
+ method: "POST",
+ body: JSON.stringify({ ids: page.roles?.map((p) => p.roleId!) ?? [] }),
+ });
+};
+
+export const fetchRewardBatch = async ({ roleId }: WithId<"role">) => {
+ z.string().uuid().parse(roleId);
+ const role = await fetchEntity({ entity: "role", idLike: roleId });
+ return fetchGuildApiData("reward/batch", {
+ method: "POST",
+ body: JSON.stringify({
+ ids: role.rewards?.map((r) => r.rewardId!) ?? [],
+ }),
+ });
+};
diff --git a/src/lib/getCookie.ts b/src/lib/getCookie.ts
deleted file mode 100644
index 84b6dea857..0000000000
--- a/src/lib/getCookie.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export const getCookie = (name: string) => {
- const decodedCookie = decodeURIComponent(document.cookie);
- const cookiesArray = decodedCookie.split(";");
-
- for (let i = 0; i < cookiesArray.length; i++) {
- const [cookieName, cookieValue] = cookiesArray[i]
- .split("=")
- .map((v) => v.trim());
- if (cookieName === name) return cookieValue;
- }
-
- return undefined;
-};
diff --git a/src/lib/getCookieClientSide.ts b/src/lib/getCookieClientSide.ts
deleted file mode 100644
index 40ca44e1eb..0000000000
--- a/src/lib/getCookieClientSide.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-"use client";
-
-import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants";
-
-export const getCookieClientSide = (name: string) => {
- const decodedCookie = decodeURIComponent(document.cookie);
- const cookiesArray = decodedCookie.split(";");
-
- for (let i = 0; i < cookiesArray.length; i++) {
- const [cookieName, cookieValue] = cookiesArray[i]
- .split("=")
- .map((v) => v.trim());
- if (cookieName === name) return cookieValue;
- }
-
- return undefined;
-};
-
-export const getTokenClientSide = () => {
- return getCookieClientSide(GUILD_AUTH_COOKIE_NAME);
-};
diff --git a/src/lib/options.ts b/src/lib/options.ts
index 4ac8c44e38..1d9556c09d 100644
--- a/src/lib/options.ts
+++ b/src/lib/options.ts
@@ -1,23 +1,45 @@
-import { fetchEntity, fetchUser } from "@/lib/fetchers";
-import type { ErrorLike } from "@/lib/types";
+import {
+ fetchEntity,
+ fetchPageBatch,
+ fetchRoleBatch,
+ fetchUser,
+} from "@/lib/fetchers";
+import type { WithIdLike } from "@/lib/types";
import type { Schemas } from "@guildxyz/types";
import { queryOptions } from "@tanstack/react-query";
-export const entityOptions = ({
+export const entityOptions = ({
entity,
idLike,
...rest
}: Parameters[0]) => {
- return queryOptions({
+ return queryOptions({
queryKey: [entity, idLike],
queryFn: () => fetchEntity({ entity, idLike, ...rest }),
});
};
-export const guildOptions = ({ idLike }: { idLike: string }) => {
- return entityOptions({
+export const guildOptions = ({ guildIdLike }: WithIdLike<"guild">) => {
+ return entityOptions({
entity: "guild",
- idLike,
+ idLike: guildIdLike,
+ });
+};
+
+export const pageBatchOptions = ({ guildIdLike }: WithIdLike<"guild">) => {
+ return queryOptions({
+ queryKey: ["page", "batch", guildIdLike],
+ queryFn: () => fetchPageBatch({ guildIdLike }),
+ });
+};
+
+export const roleBatchOptions = ({
+ pageIdLike,
+ guildIdLike,
+}: Partial> & WithIdLike<"guild">) => {
+ return queryOptions({
+ queryKey: ["role", "batch", pageIdLike || "home", guildIdLike],
+ queryFn: () => fetchRoleBatch({ pageIdLike, guildIdLike }),
});
};
@@ -25,5 +47,6 @@ export const userOptions = () => {
return queryOptions({
queryKey: ["user"],
queryFn: () => fetchUser(),
+ retry: false,
});
};
diff --git a/src/lib/schemas/common.ts b/src/lib/schemas/common.ts
index 43f6779f29..6e966e343f 100644
--- a/src/lib/schemas/common.ts
+++ b/src/lib/schemas/common.ts
@@ -6,6 +6,4 @@ export const NameSchema = z
export const ImageUrlSchema = z.literal("").or(z.string().url().max(255));
-export const LogicSchema = z.enum(["AND", "OR", "ANY_OF"]);
-
export const DateLike = z.date().or(z.string().datetime());
diff --git a/src/lib/schemas/guildReward.ts b/src/lib/schemas/guildReward.ts
new file mode 100644
index 0000000000..9847c7299c
--- /dev/null
+++ b/src/lib/schemas/guildReward.ts
@@ -0,0 +1,83 @@
+import { z } from "zod";
+import { DateLike } from "./common";
+
+const GUILD_REWARD_TYPES = [
+ "GUILD",
+ "DISCORD",
+ "TELEGRAM",
+ "POINTS",
+ "FORM",
+] as const;
+export const GuildRewardTypeSchema = z.enum(GUILD_REWARD_TYPES);
+export type GuildRewardType = z.infer;
+
+const NameAndImageSchema = z.object({
+ name: z.string().optional(),
+ imageUrl: z.literal("").or(z.string().url()).optional(),
+});
+
+const GuildPermissionRewardSchema = z.object({
+ type: z.literal(GuildRewardTypeSchema.enum.GUILD),
+ data: NameAndImageSchema.extend({
+ permissionId: z.string().uuid(),
+ permissionLevel: z.enum(["read", "write", "delete"]),
+ permissionEntity: z.string(), // TODO: should this be an enum instead?
+ }),
+});
+
+const DiscordRewardSchema = z.object({
+ type: z.literal(GuildRewardTypeSchema.enum.DISCORD),
+ data: NameAndImageSchema.extend({
+ inviteChannel: z.string().optional(),
+ invite: z.string().optional(), // Custom invite link, can be modified on our frontend
+ joinButton: z.boolean().optional(),
+ needCaptcha: z.boolean().optional(),
+
+ requiredIdentityPlatform: z.literal("DISCORD"), // TODO: extract this to another schema?
+ }),
+});
+
+const TelegramRewardSchema = z.object({
+ type: z.literal(GuildRewardTypeSchema.enum.TELEGRAM),
+ data: NameAndImageSchema.extend({
+ groupId: z.string(),
+ }),
+});
+
+const PointsRewardSchema = z.object({
+ type: z.literal(GuildRewardTypeSchema.enum.POINTS),
+ data: NameAndImageSchema.extend({
+ pointId: z.string().uuid(),
+ }),
+});
+
+const FormRewardSchema = z.object({
+ type: z.literal(GuildRewardTypeSchema.enum.FORM),
+ data: NameAndImageSchema.extend({
+ formId: z.string().uuid(),
+ }),
+});
+
+const CreateGuildRewardSchema = z
+ .object({
+ guildId: z.string().uuid(),
+ })
+ .and(
+ z.discriminatedUnion("type", [
+ GuildPermissionRewardSchema,
+ DiscordRewardSchema,
+ TelegramRewardSchema,
+ PointsRewardSchema,
+ FormRewardSchema,
+ ]),
+ );
+
+const GuildRewardSchema = CreateGuildRewardSchema.and(
+ z.object({
+ id: z.string().uuid(),
+ createdAt: DateLike,
+ updatedAt: DateLike,
+ }),
+);
+
+export type GuildReward = z.infer;
diff --git a/src/lib/schemas/identity.ts b/src/lib/schemas/identity.ts
new file mode 100644
index 0000000000..6288ca82c0
--- /dev/null
+++ b/src/lib/schemas/identity.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+const IDENTITY_TYPES = ["DISCORD"] as const;
+export const IdentityTypeSchema = z.enum(IDENTITY_TYPES);
+export type IdentityType = z.infer;
+
+export const IDENTITY_NAME = {
+ DISCORD: "Discord",
+} satisfies Record;
diff --git a/src/lib/schemas/leaderboard.ts b/src/lib/schemas/leaderboard.ts
new file mode 100644
index 0000000000..8fd35b00d1
--- /dev/null
+++ b/src/lib/schemas/leaderboard.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+const LeaderboardUserSchema = z.object({
+ userId: z.string().uuid(),
+ amount: z.number(),
+ rank: z.number(),
+ // TODO: use the user identity schema here
+ primaryIdentity: z.object({
+ identityId: z.string().uuid(),
+ foreignId: z.string(),
+ }),
+});
+
+const LeaderboardSchema = z.object({
+ leaderboard: z.array(LeaderboardUserSchema.omit({ rank: true })),
+ user: LeaderboardUserSchema.optional(),
+});
+
+export type Leaderboard = z.infer;
diff --git a/src/lib/schemas/role.ts b/src/lib/schemas/role.ts
index a2ccb12209..b4e7b519ff 100644
--- a/src/lib/schemas/role.ts
+++ b/src/lib/schemas/role.ts
@@ -1,20 +1,12 @@
import { z } from "zod";
-import { DateLike, ImageUrlSchema, LogicSchema, NameSchema } from "./common";
+import { DateLike, ImageUrlSchema, NameSchema } from "./common";
+import { RoleRewardSchema } from "./roleReward";
+import { RuleSchema } from "./rule";
export const CreateRoleSchema = z.object({
name: NameSchema.min(1, "You must specify a name for the role"),
description: z.string().nullish(),
imageUrl: ImageUrlSchema.nullish(),
- settings: z
- .object({
- logic: LogicSchema,
- position: z.number().positive().nullish(),
- anyOfNum: z.number().positive().optional(),
- })
- .default({
- logic: "AND",
- anyOfNum: 1,
- }),
groupId: z.string().uuid(),
});
@@ -25,6 +17,14 @@ const RoleSchema = CreateRoleSchema.extend({
createdAt: DateLike,
updatedAt: DateLike,
memberCount: z.number().nonnegative(),
+ topLevelAccessGroupId: z.string().uuid(),
+ accessGroups: z.array(
+ z.object({
+ gate: z.enum(["AND", "OR", "ANY_OF"]),
+ rules: z.array(RuleSchema),
+ }),
+ ),
+ rewards: z.array(RoleRewardSchema),
});
export type Role = z.infer;
diff --git a/src/lib/schemas/roleReward.ts b/src/lib/schemas/roleReward.ts
new file mode 100644
index 0000000000..4e764d4fc7
--- /dev/null
+++ b/src/lib/schemas/roleReward.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+import { DateLike } from "./common";
+
+const CapacityTimeSchema = z.object({
+ capacity: z.number().nonnegative().nullish(),
+ startTime: z.string().datetime().nullish(),
+ endTime: z.string().datetime().nullish(),
+});
+
+export const DiscordRoleRewardDataSchema = z
+ .object({
+ roleId: z.string(),
+ name: z.string(),
+ })
+ .strict();
+
+export const PointsRoleRewardDataSchema = z
+ .object({
+ amount: z.number().positive(),
+ })
+ .strict();
+
+// TODO: we might move the `CapacityTimeSchema` inside `data` if it'll be allowed for specific reward types only
+const CreateRoleRewardSchema = CapacityTimeSchema.extend({
+ rewardId: z.string().uuid(),
+ data: z.union([DiscordRoleRewardDataSchema, PointsRoleRewardDataSchema]),
+});
+
+export const RoleRewardSchema = CreateRoleRewardSchema.extend({
+ id: z.string().uuid(),
+ createdAt: DateLike,
+ updatedAt: DateLike,
+});
+
+export type RoleReward = z.infer;
diff --git a/src/lib/schemas/rule.ts b/src/lib/schemas/rule.ts
new file mode 100644
index 0000000000..653fddce1a
--- /dev/null
+++ b/src/lib/schemas/rule.ts
@@ -0,0 +1,64 @@
+import { z } from "zod";
+import { IdentityTypeSchema } from "./identity";
+
+const CreateRuleSchema = z.object({
+ /**
+ * TODO
+ * - I don't know how will we create rules
+ */
+});
+
+const TextNodeSchema = z.object({
+ id: z.string().uuid(),
+ type: z.literal("TEXT"),
+ position: z.literal("HEADER"),
+ value: z.string().min(1),
+});
+
+const ChainIndicatorNodeSchema = z.object({
+ id: z.string().uuid(),
+ type: z.literal("CHAIN_INDICATOR"),
+ position: z.literal("FOOTER"),
+ value: z.string(), // TODO: maybe allow supported chains only?
+});
+
+const ExternalLinkNodeSchema = z.object({
+ id: z.string().uuid(),
+ type: z.literal("EXTERNAL_LINK"),
+ position: z.literal("FOOTER"),
+ value: z.string().min(1),
+ href: z.string().url(),
+});
+
+const RequirementNodeSchema = z.discriminatedUnion("type", [
+ TextNodeSchema,
+ ChainIndicatorNodeSchema,
+ ExternalLinkNodeSchema,
+]);
+
+export const RuleSchema = CreateRuleSchema.extend({
+ accessRuleId: z.string().uuid(),
+ integration: z.object({
+ id: z.string(),
+ displayName: z.string(),
+ identityType: IdentityTypeSchema,
+ }),
+ config: z
+ .object({
+ platform: z.string(), // TODO: platform schema
+ type: z.string(), // TODO: maybe this isn't a fixed property?
+ id: z.string(),
+ })
+ .and(z.record(z.string().or(z.record(z.string())))),
+ params: z.object({
+ op: z.enum(["greater", "less", "equal"]),
+ field: z.string(), // TODO: I'm not sure if field & value are fixed props
+ value: z.string(),
+ }),
+ ui: z.object({
+ imageUrl: z.literal("").or(z.string().url().optional()),
+ nodes: z.array(RequirementNodeSchema),
+ }),
+});
+
+export type Rule = z.infer;
diff --git a/src/lib/token.ts b/src/lib/token.ts
deleted file mode 100644
index a9bc0ff87d..0000000000
--- a/src/lib/token.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants";
-import { getTokenClientSide } from "@/lib/getCookieClientSide";
-import { tokenSchema } from "@/lib/schemas/user";
-import { isServer } from "@tanstack/react-query";
-import { jwtDecode } from "jwt-decode";
-
-export const tryGetToken = async () => {
- let token: string | undefined;
- if (isServer) {
- const { cookies } = await import("next/headers");
- token = (await cookies()).get(GUILD_AUTH_COOKIE_NAME)?.value;
- } else {
- token = getTokenClientSide();
- }
-
- if (!token) {
- throw new Error(
- "Failed to retrieve JWT token on auth request initialization.",
- );
- }
- return token;
-};
-
-export const tryGetParsedToken = async () => {
- const token = await tryGetToken();
- return tokenSchema.parse(jwtDecode(token));
-};
diff --git a/src/lib/types.ts b/src/lib/types.ts
index f8864ff6c4..2faafb886c 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1,3 +1,5 @@
+import type { Schemas } from "@guildxyz/types";
+
export type PaginatedResponse- = {
page: number;
pageSize: number;
@@ -31,4 +33,15 @@ export type ErrorLike = {
* - `urlName`: uri safe identifier alias
* - `id`: uuid v4 identifier
*/
-export type WithIdLike = T & { idLike: string };
+export type WithIdLike = {
+ [key in `${E}IdLike`]: string;
+};
+
+export type WithId = {
+ [key in `${E}Id`]: string;
+};
+
+// TODO: move to @guildxyz/types
+export type Entity = "guild" | "role" | "page" | "user" | "reward";
+
+export type EntitySchema = Schemas[Capitalize];
diff --git a/src/stories/rewards/RewardCard.stories.tsx b/src/stories/rewards/RewardCard.stories.tsx
new file mode 100644
index 0000000000..1cec2709db
--- /dev/null
+++ b/src/stories/rewards/RewardCard.stories.tsx
@@ -0,0 +1,31 @@
+import { RewardCard, RewardCardButton } from "@/components/rewards/RewardCard";
+import { TooltipProvider } from "@/components/ui/Tooltip";
+import type { Meta, StoryObj } from "@storybook/react";
+
+const RewardCardExample = () => (
+
+
+ Reward card button
+
+
+);
+
+const meta: Meta = {
+ title: "Rewards/RewardCard",
+ component: RewardCardExample,
+ decorators: (Story) => (
+
+
+
+ ),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/styles/globals.css b/src/styles/globals.css
index e74452b9e7..bfb4d84a4e 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -214,8 +214,8 @@ html {
--button-success-hover: var(--green-400);
--button-success-active: var(--green-300);
--button-success-foreground: var(--white);
- --button-success-subtle: var(--green-300);
- --button-success-subtle-foreground: var(--green-500);
+ --button-success-subtle: var(--green-200);
+ --button-success-subtle-foreground: var(--green-300);
--badge-background: var(--whiteAlpha);
--badge-foreground: var(--foreground);
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 85b633f1da..73998060d8 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,6 +1,7 @@
import type { Config } from "tailwindcss"
import animatePlugin from "tailwindcss-animate";
-import typography from "@tailwindcss/typography"
+import typographyPlugin from "@tailwindcss/typography"
+import containerQueriesPlugin from "@tailwindcss/container-queries"
const config = {
darkMode: ["class"],
@@ -101,7 +102,7 @@ const config = {
},
},
},
- plugins: [animatePlugin, typography],
+ plugins: [animatePlugin, typographyPlugin, containerQueriesPlugin],
} satisfies Config
export default config