diff --git a/package.json b/package.json
index 6b8ab3ce6c..f341cf448c 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"build-storybook": "storybook build"
},
"dependencies": {
+ "@guildxyz/types": "^3.0.8",
"@hookform/resolvers": "^3.9.1",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-avatar": "^1.1.1",
@@ -75,6 +76,7 @@
"@storybook/react": "^8.4.4",
"@storybook/test": "^8.4.4",
"@svgr/webpack": "^8.1.0",
+ "@total-typescript/ts-reset": "^0.6.1",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
diff --git a/public/images/banner-light.svg b/public/images/banner-light.svg
new file mode 100644
index 0000000000..42bd11018a
--- /dev/null
+++ b/public/images/banner-light.svg
@@ -0,0 +1,650 @@
+
diff --git a/public/images/banner.svg b/public/images/banner.svg
new file mode 100644
index 0000000000..83f1b55b3c
--- /dev/null
+++ b/public/images/banner.svg
@@ -0,0 +1,650 @@
+
diff --git a/query.d.ts b/query.d.ts
new file mode 100644
index 0000000000..a600ecfe04
--- /dev/null
+++ b/query.d.ts
@@ -0,0 +1,9 @@
+import '@tanstack/react-query'
+import { ErrorLike } from './src/lib/types'
+
+declare module '@tanstack/react-query' {
+ interface Register {
+ defaultError: ErrorLike
+ }
+}
+
diff --git a/reset.d.ts b/reset.d.ts
new file mode 100644
index 0000000000..e4d600ccb0
--- /dev/null
+++ b/reset.d.ts
@@ -0,0 +1,2 @@
+// Do not add any other lines of code to this file!
+import "@total-typescript/ts-reset";
diff --git a/src/actions/auth.ts b/src/actions/auth.ts
index 80bc7158ef..88f35a49a2 100644
--- a/src/actions/auth.ts
+++ b/src/actions/auth.ts
@@ -2,22 +2,11 @@
import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants";
import { env } from "@/lib/env";
+import { fetcher } from "@/lib/fetcher";
+import { authSchema, tokenSchema } from "@/lib/schemas/user";
import { jwtDecode } from "jwt-decode";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
-import { z } from "zod";
-
-const authSchema = z.object({
- message: z.string(),
- token: z.string(),
- userId: z.string().uuid(),
-});
-
-const tokenSchema = z.object({
- userId: z.string().uuid(),
- exp: z.number().positive().int(),
- iat: z.number().positive().int(),
-});
export const signIn = async ({
message,
@@ -70,8 +59,24 @@ export const signOut = async (redirectTo?: string) => {
redirect(redirectTo ?? "/explorer");
};
-export const getAuthCookie = async () => {
- const cookieStore = await cookies();
- const authCookie = cookieStore.get(GUILD_AUTH_COOKIE_NAME);
- return authCookie && tokenSchema.parse(jwtDecode(authCookie.value));
+export const getToken = async () => {
+ return (await cookies()).get(GUILD_AUTH_COOKIE_NAME)?.value;
+};
+
+export const getParsedToken = async () => {
+ const token = await getToken();
+ return token ? tokenSchema.parse(jwtDecode(token)) : undefined;
+};
+
+export const fetcherWithAuth = async (
+ ...[resource, requestInit]: Parameters
+) => {
+ const token = await getToken();
+ if (!token) {
+ throw new Error("failed to retrieve jwt token");
+ }
+ return fetcher(resource, {
+ ...requestInit,
+ headers: { ...requestInit?.headers, "X-Auth-Token": token },
+ });
};
diff --git a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx
new file mode 100644
index 0000000000..16644c66f6
--- /dev/null
+++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx
@@ -0,0 +1,110 @@
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { ScrollArea } from "@/components/ui/ScrollArea";
+import { env } from "@/lib/env";
+import { fetcher } from "@/lib/fetcher";
+import type { Guild } from "@/lib/schemas/guild";
+import type { Role } from "@/lib/schemas/role";
+import type { RoleGroup } from "@/lib/schemas/roleGroup";
+import type { DynamicRoute, PaginatedResponse } from "@/lib/types";
+import type { Schemas } from "@guildxyz/types";
+import { Lock } from "@phosphor-icons/react/dist/ssr";
+
+const GuildPage = async ({
+ params,
+}: DynamicRoute<{ pageUrlName: string; guildUrlName: string }>) => {
+ const { pageUrlName, guildUrlName } = await params;
+ const guild = (await (
+ await fetch(`${env.NEXT_PUBLIC_API}/guild/urlName/${guildUrlName}`)
+ ).json()) as Guild;
+ const paginatedRoleGroup = (await (
+ await fetch(
+ `${env.NEXT_PUBLIC_API}/page/search?customQuery=@guildId:{${guild.id}}&pageSize=${Number.MAX_SAFE_INTEGER}`,
+ )
+ ).json()) as PaginatedResponse;
+ const roleGroups = paginatedRoleGroup.items;
+ const roleGroup = roleGroups.find(
+ // @ts-expect-error
+ (rg) => rg.urlName === pageUrlName || rg.id === guild.homeRoleGroupId,
+ )!;
+ const paginatedRole = await fetcher>(
+ `${env.NEXT_PUBLIC_API}/role/search?customQuery=@guildId:{${guild.id}}&pageSize=${Number.MAX_SAFE_INTEGER}`,
+ );
+ const roles = paginatedRole.items.filter((r) => r.groupId === roleGroup.id);
+
+ return (
+
+ {roles.map((role) => (
+
+ ))}
+
+ );
+};
+
+const RoleCard = async ({ role }: { role: Role }) => {
+ const rewards = (await Promise.all(
+ // @ts-ignore
+ role.rewards?.map(({ rewardId }) => {
+ const req = `${env.NEXT_PUBLIC_API}/reward/id/${rewardId}`;
+ try {
+ return fetcher(req);
+ } catch {
+ console.error({ rewardId, req });
+ }
+ }) ?? [],
+ )) as Schemas["RewardFull"][];
+
+ return (
+
+
+
+ {role.imageUrl && (
+
+ )}
+
{role.name}
+
+
+ {role.description}
+
+ {!!rewards.length && (
+
+
+ {rewards.map((reward) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+ REQUIREMENTS
+
+
+
+
+
+ );
+};
+
+const Reward = ({ reward }: { reward: Schemas["RewardFull"] }) => {
+ return (
+
+
{reward.name}
+
{reward.description}
+
+ {JSON.stringify(reward.permissions, null, 2)}
+
+
+ );
+};
+
+export default GuildPage;
diff --git a/src/app/(dashboard)/[guildUrlName]/actions.ts b/src/app/(dashboard)/[guildUrlName]/actions.ts
new file mode 100644
index 0000000000..910c3c1b0f
--- /dev/null
+++ b/src/app/(dashboard)/[guildUrlName]/actions.ts
@@ -0,0 +1,20 @@
+"use server";
+import { fetcherWithAuth } from "@/actions/auth";
+import { env } from "@/lib/env";
+import { revalidateTag } from "next/cache";
+
+export const revalidateRoleGroups = async (guildId: string) => {
+ revalidateTag(`page-${guildId}`);
+};
+
+export const joinGuild = async ({ guildId }: { guildId: string }) => {
+ return fetcherWithAuth(`${env.NEXT_PUBLIC_API}/guild/${guildId}/join`, {
+ method: "POST",
+ });
+};
+
+export const leaveGuild = async ({ guildId }: { guildId: string }) => {
+ return fetcherWithAuth(`${env.NEXT_PUBLIC_API}/guild/${guildId}/leave`, {
+ method: "POST",
+ });
+};
diff --git a/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx
new file mode 100644
index 0000000000..24164590aa
--- /dev/null
+++ b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx
@@ -0,0 +1,63 @@
+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 type { Guild } from "@/lib/schemas/guild";
+import { getPages } from "../fetchers";
+import { PageNavLink } from "./RoleGroupNavLink";
+
+type Props = {
+ guild: Guild;
+};
+
+const roleGroupOrder = ["Home", "Admin"].reverse();
+
+export const GuildTabs = async ({ guild }: Props) => {
+ const roleGroups = await getPages(guild.id);
+
+ return (
+
+
+ {roleGroups
+ .sort((a, b) => {
+ const [aIndex, bIndex] = [a, b].map((val) =>
+ roleGroupOrder.findIndex((pred) => pred === val.name),
+ );
+ return bIndex - aIndex;
+ })
+ .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/JoinButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx
new file mode 100644
index 0000000000..cf309fd9bc
--- /dev/null
+++ b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { Button } from "@/components/ui/Button";
+import type { Guild } from "@/lib/schemas/guild";
+import type { Schemas } from "@guildxyz/types";
+import { joinGuild, leaveGuild } from "../actions";
+
+export const JoinButton = ({
+ guild,
+}: { guild: Guild; user: Schemas["UserFull"] }) => {
+ // @ts-ignore
+ const isJoined = !!user.data.guilds?.some(
+ // @ts-ignore
+ ({ guildId }) => guildId === guild.id,
+ );
+
+ return isJoined ? (
+
+ ) : (
+
+ );
+};
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]/fetchers.ts b/src/app/(dashboard)/[guildUrlName]/fetchers.ts
new file mode 100644
index 0000000000..ad86f41cb0
--- /dev/null
+++ b/src/app/(dashboard)/[guildUrlName]/fetchers.ts
@@ -0,0 +1,29 @@
+import { env } from "@/lib/env";
+import { fetcher } from "@/lib/fetcher";
+import type { PaginatedResponse } from "@/lib/types";
+import type { Schemas } from "@guildxyz/types";
+
+export const getGuild = async (urlName: string) => {
+ return await fetcher(
+ `${env.NEXT_PUBLIC_API}/guild/urlName/${urlName}`,
+ {
+ next: {
+ tags: [`guild-${urlName}`],
+ },
+ },
+ );
+};
+
+export const getPages = async (guildId: string) => {
+ return (
+ await fetcher>(
+ `${env.NEXT_PUBLIC_API}/page/search?customQuery=@guildId:{${guildId}}&pageSize=${Number.MAX_SAFE_INTEGER}`,
+ {
+ next: {
+ tags: [`page-${guildId}`],
+ revalidate: 3600,
+ },
+ },
+ )
+ ).items;
+};
diff --git a/src/app/(dashboard)/[guildUrlName]/layout.tsx b/src/app/(dashboard)/[guildUrlName]/layout.tsx
new file mode 100644
index 0000000000..e50e749c1d
--- /dev/null
+++ b/src/app/(dashboard)/[guildUrlName]/layout.tsx
@@ -0,0 +1,63 @@
+import { getParsedToken } from "@/actions/auth";
+import { AuthBoundary } from "@/components/AuthBoundary";
+import { GuildImage } from "@/components/GuildImage";
+import { SignInButton } from "@/components/SignInButton";
+import { env } from "@/lib/env";
+import { fetcher } from "@/lib/fetcher";
+import type { Guild } from "@/lib/schemas/guild";
+import type { DynamicRoute } from "@/lib/types";
+import type { Schemas } from "@guildxyz/types";
+import { type PropsWithChildren, Suspense } from "react";
+import { GuildTabs, GuildTabsSkeleton } from "./components/GuildTabs";
+import { JoinButton } from "./components/JoinButton";
+
+const GuildPage = async ({
+ params,
+ children,
+}: PropsWithChildren>) => {
+ const { guildUrlName } = await params;
+ const guild = await fetcher(
+ `${env.NEXT_PUBLIC_API}/guild/urlName/${guildUrlName}`,
+ );
+ const token = await getParsedToken();
+ const user =
+ token &&
+ (await fetcher(
+ `${env.NEXT_PUBLIC_API}/user/id/${token.userId}`,
+ ));
+
+ return (
+
+
+
+
+
+
+
+ {guild.name}
+
+
+
}>
+ {user &&
}
+
+
+
+ {guild.description}
+
+
+
+
+ }>
+
+
+
+ {children}
+
+ );
+};
+
+export default GuildPage;
diff --git a/src/app/(dashboard)/[guildUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/page.tsx
new file mode 100644
index 0000000000..7687289661
--- /dev/null
+++ b/src/app/(dashboard)/[guildUrlName]/page.tsx
@@ -0,0 +1,10 @@
+import type { DynamicRoute } from "@/lib/types";
+import GuildPage from "./[pageUrlName]/page";
+
+const DefaultGuildPage = async ({
+ params,
+}: DynamicRoute<{ guildUrlName: string }>) => {
+ 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)/explorer/components/CreateGuildLink.tsx b/src/app/(dashboard)/explorer/components/CreateGuildLink.tsx
index f9d456ad00..d716acded9 100644
--- a/src/app/(dashboard)/explorer/components/CreateGuildLink.tsx
+++ b/src/app/(dashboard)/explorer/components/CreateGuildLink.tsx
@@ -2,7 +2,7 @@ import { buttonVariants } from "@/components/ui/Button";
import { Plus } from "@phosphor-icons/react/dist/ssr";
import Link from "next/link";
-export const CreateGuildLink = () => (
+export const CreateGuildLink = ({ className }: { className?: string }) => (
(
className={buttonVariants({
variant: "ghost",
size: "sm",
- className: "min-h-11 w-11 gap-1.5 px-0 sm:min-h-0 sm:w-auto sm:px-3",
+ className: [
+ "min-h-11 w-11 gap-1.5 px-0 sm:min-h-0 sm:w-auto sm:px-3",
+ className,
+ ],
})}
>
diff --git a/src/app/(dashboard)/explorer/fetchers.ts b/src/app/(dashboard)/explorer/fetchers.ts
index 7bfc490ea4..b783b5281b 100644
--- a/src/app/(dashboard)/explorer/fetchers.ts
+++ b/src/app/(dashboard)/explorer/fetchers.ts
@@ -1,17 +1,13 @@
import { env } from "@/lib/env";
import type { Guild } from "@/lib/schemas/guild";
import type { PaginatedResponse } from "@/lib/types";
+import { fetcher } from "../../../lib/fetcher";
import { PAGE_SIZE } from "./constants";
export const getGuildSearch =
(search = "") =>
async ({ pageParam }: { pageParam: number }) => {
- const res = await fetch(
+ return fetcher>(
`${env.NEXT_PUBLIC_API}/guild/search?page=${pageParam}&pageSize=${PAGE_SIZE}&search=${search}`,
);
- const json = await res.json();
-
- if (json.error) throw new Error(json.error);
-
- return json as PaginatedResponse;
};
diff --git a/src/app/(dashboard)/explorer/page.tsx b/src/app/(dashboard)/explorer/page.tsx
index 94c5b1c95c..0e4a4e07ef 100644
--- a/src/app/(dashboard)/explorer/page.tsx
+++ b/src/app/(dashboard)/explorer/page.tsx
@@ -1,4 +1,4 @@
-import { getAuthCookie as getTokenFromCookie } from "@/actions/auth";
+import { getToken } from "@/actions/auth";
import { AuthBoundary } from "@/components/AuthBoundary";
import { SignInButton } from "@/components/SignInButton";
import { env } from "@/lib/env";
@@ -22,7 +22,6 @@ import { getGuildSearch } from "./fetchers";
const getAssociatedGuilds = async ({ userId }: { userId: string }) => {
const request = `${env.NEXT_PUBLIC_API}/guild/search?page=1&pageSize=${Number.MAX_SAFE_INTEGER}&sortBy=name&reverse=false&customQuery=@owner:{${userId}}`;
- console.log(request);
return fetcher>(request);
};
@@ -36,7 +35,15 @@ export default async function Explorer() {
return (
<>
-
+
+
+
0 ? (
+ return associatedGuilds.length > 0 ? (
- {myGuilds.map((guild) => (
+ {associatedGuilds.map((guild) => (
))}
@@ -124,7 +131,7 @@ async function YourGuilds() {
or create your own!
-
+
);
}
diff --git a/src/app/playground/page.tsx b/src/app/playground/page.tsx
deleted file mode 100644
index 9bf4132b81..0000000000
--- a/src/app/playground/page.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-const Playground = () => {
- return (
-
-
- Hello, Guild!, page for testing frameworks, configs
-
- Hello, Guild!
- Test, one, two, three...
-
- Test, one, two, three...
-
-
- In dark mode I appear violet, in light we go rose
-
-
- );
-};
-
-export default Playground;
diff --git a/src/components/rewards/RewardCard.tsx b/src/components/rewards/RewardCard.tsx
new file mode 100644
index 0000000000..402ba807ac
--- /dev/null
+++ b/src/components/rewards/RewardCard.tsx
@@ -0,0 +1,54 @@
+import { cn } from "@/lib/cssUtils";
+import { QuestionMark } from "@phosphor-icons/react/dist/ssr";
+import type { PropsWithChildren, ReactNode } from "react";
+import { Button, type ButtonProps } from "../ui/Button";
+import { Card } from "../ui/Card";
+
+export const RewardCard = ({
+ image,
+ title,
+ description,
+ className,
+ children,
+}: PropsWithChildren<{
+ image?: ReactNode | string;
+ title: string;
+ description?: string;
+ className?: string;
+}>) => (
+
+
+
+ {!image ? (
+
+ ) : typeof image === "string" ? (
+
+ ) : (
+ image
+ )}
+
+
+
+ {title}
+
+
+ {description && (
+
{description}
+ )}
+
+ {children}
+
+);
+
+export const RewardCardButton = ({
+ className,
+ ...props
+}: Omit) => (
+
+);
diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts
index 425892f08d..64393cc420 100644
--- a/src/lib/fetcher.ts
+++ b/src/lib/fetcher.ts
@@ -1,4 +1,5 @@
import { env } from "./env";
+import type { ErrorLike } from "./types";
export const fetcher = async (
resource: string,
@@ -10,11 +11,18 @@ export const fetcher = async (
? await response.json()
: await response.text();
+ if (process.env.NODE_ENV === "development") {
+ console.info(
+ `[${new Date().toLocaleTimeString()} - fetcher]: ${resource}`,
+ res,
+ );
+ }
+
if (!response.ok) {
if (resource.includes(env.NEXT_PUBLIC_API)) {
return Promise.reject({
- error: res.error,
- } as { error: string });
+ error: (res as ErrorLike).error || (res as ErrorLike).message,
+ });
}
return Promise.reject(res as Error);
diff --git a/src/lib/schemas/user.ts b/src/lib/schemas/user.ts
new file mode 100644
index 0000000000..a8cb2c4eff
--- /dev/null
+++ b/src/lib/schemas/user.ts
@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+export const authSchema = z.object({
+ message: z.string(),
+ token: z.string(),
+ userId: z.string().uuid(),
+});
+
+export const tokenSchema = z.object({
+ userId: z.string().uuid(),
+ exp: z.number().positive().int(),
+ iat: z.number().positive().int(),
+});
diff --git a/src/lib/types.ts b/src/lib/types.ts
index c289f700f7..57b5ecc82c 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -12,3 +12,16 @@ export type PaginatedResponse- = {
export type DynamicRoute> = {
params: T;
};
+
+/**
+ * Unstable error structure coming from v3 backend
+ *
+ * @property message most common error response
+ * @property error on some endpoints this field is given instead of message
+ */
+// TODO: align this to backend when error handling gets consistent
+export type ErrorLike = {
+ message: string;
+ status?: string;
+ error?: string;
+};
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/tsconfig.json b/tsconfig.json
index 70fb7eb54f..381fb4f92b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -39,7 +39,8 @@
"next-env.d.ts",
"tailwind.config.ts",
".next/types/**/*.ts",
- "guild.d.ts"
+ "reset.d.ts",
+ "query.d.ts"
],
"exclude": [
"node_modules",