Skip to content

Commit

Permalink
Merge branch 'add-guild-page' into requirement-builder
Browse files Browse the repository at this point in the history
  • Loading branch information
BrickheadJohnny committed Dec 2, 2024
2 parents fe1ff54 + 7f8800d commit 85a93af
Show file tree
Hide file tree
Showing 25 changed files with 1,846 additions and 83 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
650 changes: 650 additions & 0 deletions public/images/banner-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
650 changes: 650 additions & 0 deletions public/images/banner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions query.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import '@tanstack/react-query'
import { ErrorLike } from './src/lib/types'

declare module '@tanstack/react-query' {
interface Register {
defaultError: ErrorLike
}
}

2 changes: 2 additions & 0 deletions reset.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Do not add any other lines of code to this file!
import "@total-typescript/ts-reset";
39 changes: 22 additions & 17 deletions src/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <Data = unknown, Error = unknown>(
...[resource, requestInit]: Parameters<typeof fetcher>
) => {
const token = await getToken();
if (!token) {
throw new Error("failed to retrieve jwt token");
}
return fetcher<Data, Error>(resource, {
...requestInit,
headers: { ...requestInit?.headers, "X-Auth-Token": token },
});
};
110 changes: 110 additions & 0 deletions src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<RoleGroup>;
const roleGroups = paginatedRoleGroup.items;
const roleGroup = roleGroups.find(
// @ts-expect-error
(rg) => rg.urlName === pageUrlName || rg.id === guild.homeRoleGroupId,
)!;

Check warning on line 29 in src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx

View workflow job for this annotation

GitHub Actions / quality-assurance

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const paginatedRole = await fetcher<PaginatedResponse<Role>>(
`${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 (
<div className="my-4 space-y-4">
{roles.map((role) => (
<RoleCard role={role} key={role.id} />
))}
</div>
);
};

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<Schemas["RewardFull"]>(req);
} catch {
console.error({ rewardId, req });
}
}) ?? [],
)) as Schemas["RewardFull"][];

return (
<Card className="flex flex-col md:flex-row" key={role.id}>
<div className="border-r p-6 md:w-1/2">
<div className="flex items-center gap-3">
{role.imageUrl && (
<img
className="size-14 rounded-full border"
src={role.imageUrl} // TODO: fallback image
alt="role avatar"
/>
)}
<h3 className="font-bold text-xl tracking-tight">{role.name}</h3>
</div>
<p className="mt-4 text-foreground-dimmed leading-relaxed">
{role.description}
</p>
{!!rewards.length && (
<ScrollArea className="mt-8 h-64 rounded-lg border pr-3">
<div className="flex flex-col gap-4">
{rewards.map((reward) => (
<Reward reward={reward} key={reward.id} />
))}
</div>
</ScrollArea>
)}
</div>
<div className="bg-card-secondary p-6 md:w-1/2">
<div className="flex items-center justify-between">
<span className="font-bold text-foreground-secondary text-xs">
REQUIREMENTS
</span>
<Button size="sm">
<Lock />
Join Guild to collect rewards
</Button>
</div>
</div>
</Card>
);
};

const Reward = ({ reward }: { reward: Schemas["RewardFull"] }) => {
return (
<div className="border-b p-4 last:border-b-0">
<div className="mb-2 font-medium">{reward.name}</div>
<div className="text-foreground-dimmed text-sm">{reward.description}</div>
<pre className="mt-3 text-foreground-secondary text-xs">
<code>{JSON.stringify(reward.permissions, null, 2)}</code>
</pre>
</div>
);
};

export default GuildPage;
20 changes: 20 additions & 0 deletions src/app/(dashboard)/[guildUrlName]/actions.ts
Original file line number Diff line number Diff line change
@@ -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",
});
};
63 changes: 63 additions & 0 deletions src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ScrollArea
className="-ml-8 w-[calc(100%+theme(space.8))]"
style={{
maskImage:
"linear-gradient(to right, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%)",
}}
>
<div className="my-4 flex gap-3 px-8">
{roleGroups
.sort((a, b) => {
const [aIndex, bIndex] = [a, b].map((val) =>
roleGroupOrder.findIndex((pred) => pred === val.name),
);
return bIndex - aIndex;
})
.map((rg) => (
<PageNavLink
key={rg.id}
href={[guild.urlName, rg.urlName]
.filter(Boolean)
.map((s) => `/${s}`)
.join("")}
>
{rg.name}
</PageNavLink>
))}
</div>
<ScrollBar orientation="horizontal" className="hidden" />
</ScrollArea>
);
};

const SKELETON_SIZES = ["w-20", "w-36", "w-28"];
export const GuildTabsSkeleton = () => (
<div className="my-4 flex gap-3">
{[...Array(3)].map((_, i) => (
<Card
key={`${SKELETON_SIZES[i]}${i}`}
className={cn("flex h-11 items-center px-4", SKELETON_SIZES[i])}
>
<Skeleton className="h-4 w-full" />
</Card>
))}
</div>
);
38 changes: 38 additions & 0 deletions src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<Button
colorScheme="destructive"
className="rounded-2xl"
onClick={() => {
leaveGuild({ guildId: guild.id });
}}
>
Leave Guild
</Button>
) : (
<Button
colorScheme="success"
className="rounded-2xl"
onClick={() => {
joinGuild({ guildId: guild.id });
}}
>
Join Guild
</Button>
);
};
32 changes: 32 additions & 0 deletions src/app/(dashboard)/[guildUrlName]/components/RoleGroupNavLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="bg-card-secondary">
<Link
href={href}
className={buttonVariants({
variant: "ghost",
className: [
"focus-visible:bg-[var(--button-bg-hover)]",
{ "bg-[var(--button-bg-hover)]": isActive },
],
})}
>
{children}
</Link>
</Card>
);
};
29 changes: 29 additions & 0 deletions src/app/(dashboard)/[guildUrlName]/fetchers.ts
Original file line number Diff line number Diff line change
@@ -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<Schemas["GuildFull"]>(
`${env.NEXT_PUBLIC_API}/guild/urlName/${urlName}`,
{
next: {
tags: [`guild-${urlName}`],
},
},
);
};

export const getPages = async (guildId: string) => {
return (
await fetcher<PaginatedResponse<Schemas["PageFull"]>>(
`${env.NEXT_PUBLIC_API}/page/search?customQuery=@guildId:{${guildId}}&pageSize=${Number.MAX_SAFE_INTEGER}`,
{
next: {
tags: [`page-${guildId}`],
revalidate: 3600,
},
},
)
).items;
};
Loading

0 comments on commit 85a93af

Please sign in to comment.