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 avatar + )} +

{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" ? ( + Reward icon + ) : ( + image + )} +
+ + + {title} + + + {description && ( + {description} + )} +
+ {children} +
+); + +export const RewardCardButton = ({ + className, + ...props +}: Omit) => ( +