Skip to content

Commit

Permalink
feat: rewards (#1579)
Browse files Browse the repository at this point in the history
* feat(RewardCard): change layout with container queries

* chore: update schemas

* feat: custom reward cards for permissions and points

* feat: fetch real rewards data and add a simple reward leaderboard

* fix: remove points name for now

* feat: separate join and leave guild components

* feat: useGuild hook

* fix: add missing use client directives

* feat: auth rework (#1581)

* feat: use `https` in development

* feat: use httpOnly cookie for authenticated requests

* feat: third party auth (#1582)

* feat: 3rd party auth flow

* feat(ConnectResultToast): add toast icons

* chore: update readme

* cleanup: remove unnecessary components

* fix: invalidate queries when signing in/out

* feat: join modal

* feat: DiscordRewardCard

* cleanup(RoleCard): remove unnecessary TODO comments

* feat: better identity-related types

* fix(DiscordRewardCard): disabled state

* fix(ConnectResultToast): show toast only after platform connection

* fix(PointsRewardCard): don't use pathname for navigation

* fix(rewardCards): add proper types

* feat(leaderboard): display points' name

* feat(leaderboard): display user addresses & "Your position" section

* feat(DiscordRewardCard): more detailed tooltip message
  • Loading branch information
BrickheadJohnny authored Dec 12, 2024
1 parent 0d6a0df commit 8f10e63
Show file tree
Hide file tree
Showing 46 changed files with 1,160 additions and 439 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ bun.lockb
/playwright/.cache/
/playwright/.auth/
/playwright/results

certificates
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -31,6 +31,7 @@
"@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.62.2",
"@tanstack/react-query-devtools": "^5.62.2",
Expand All @@ -40,7 +41,6 @@
"event-source-plus": "^0.1.8",
"foxact": "^0.2.43",
"jotai": "^2.10.3",
"jwt-decode": "^4.0.0",
"mini-svg-data-uri": "^1.4.4",
"next": "15.0.3",
"next-themes": "^0.4.4",
Expand Down
52 changes: 0 additions & 52 deletions src/actions/auth.ts

This file was deleted.

158 changes: 93 additions & 65 deletions src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
"use client";

import { RequirementDisplayComponent } from "@/components/requirements/RequirementDisplayComponent";
import { rewardCards } from "@/components/rewards/rewardCards";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { ScrollArea } from "@/components/ui/ScrollArea";
import { Skeleton } from "@/components/ui/Skeleton";
import { rewardBatchOptions, roleBatchOptions } from "@/lib/options";
import type { Schemas } from "@guildxyz/types";
import { Lock } from "@phosphor-icons/react/dist/ssr";
import { fetchGuildApiData } from "@/lib/fetchGuildApi";
import { roleBatchOptions } from "@/lib/options";
import type { GuildReward, GuildRewardType } from "@/lib/schemas/guildReward";
import type { Role } from "@/lib/schemas/role";
import { ImageSquare, Lock } from "@phosphor-icons/react/dist/ssr";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { Suspense } from "react";
import { useGuild } from "../hooks/useGuild";

const GuildPage = () => {
const { pageUrlName, guildUrlName } = useParams<{
Expand Down Expand Up @@ -38,73 +41,98 @@ const GuildPage = () => {
);
};

const RoleCard = ({ role }: { role: Schemas["Role"] }) => {
const { data: rewards } = useSuspenseQuery(
rewardBatchOptions({ roleId: role.id }),
);

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>
const RoleCard = ({ role }: { role: Role }) => (
<Card className="flex flex-col md:flex-row" key={role.id}>
<div className="@container flex flex-col border-r p-5 md:w-1/2">
<div className="mb-2 flex items-center gap-3">
{role.imageUrl ? (
<img
className="size-14 rounded-full border"
src={role.imageUrl}
alt="role avatar"
/>
) : (
<div className="flex size-14 items-center justify-center rounded-full bg-image">
<ImageSquare weight="duotone" className="size-6" />
</div>
)}
<h3 className="font-extrabold text-xl">{role.name}</h3>
</div>
<div className="bg-card-secondary md:w-1/2">
<div className="flex items-center justify-between p-5">
<span className="font-bold text-foreground-secondary text-xs">
REQUIREMENTS
</span>
<Button size="sm">
<Lock />
Join Guild to collect rewards
</Button>
</div>
<p className="mb-4 text-foreground-dimmed leading-relaxed">
{role.description}
</p>

<Suspense fallback={<p>Loading rewards...</p>}>
<RoleRewards roleId={role.id} roleRewards={role.rewards} />
</Suspense>
</div>

{/* TODO group rules by access groups */}
<div className="grid px-5 pb-5">
{role.accessGroups[0].rules?.map((rule) => (
<RequirementDisplayComponent
key={rule.accessRuleId}
// @ts-expect-error: incomplete type
requirement={rule}
/>
))}
</div>
<div className="bg-card-secondary md:w-1/2">
<div className="flex items-center justify-between p-5">
<span className="font-bold text-foreground-secondary text-xs">
REQUIREMENTS
</span>
<Button size="sm">
<Lock />
Join Guild to collect rewards
</Button>
</div>
</Card>
);
};

const Reward = ({ reward }: { reward: Schemas["Reward"] }) => {
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>
{/* TODO group rules by access groups */}
<div className="grid px-5 pb-5">
{role.accessGroups[0].rules?.map((rule) => (
<RequirementDisplayComponent
key={rule.accessRuleId}
requirement={rule}
/>
))}
</div>
</div>
);
</Card>
);

const RoleRewards = ({
roleId,
roleRewards,
}: { roleId: string; roleRewards: Role["rewards"] }) => {
const { data: guild } = useGuild();
const { data: rewards } = useSuspenseQuery<GuildReward[]>({
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 ? (
<div className="mt-auto grid @[26rem]:grid-cols-2 gap-2">
{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 (
<RewardCard
key={roleReward.rewardId}
roleId={roleId}
reward={{
guildReward,
roleReward,
}}
/>
);
})}
</div>
) : null;
};

export default GuildPage;
29 changes: 29 additions & 0 deletions src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client";

import { Button } from "@/components/ui/Button";
import { userOptions } from "@/lib/options";
import { useQuery } from "@tanstack/react-query";
import { useGuild } from "../hooks/useGuild";
import { JoinGuild } from "./JoinGuild";
import { LeaveGuild } from "./LeaveGuild";

export const ActionButton = () => {
const user = useQuery(userOptions());
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 ? <LeaveGuild /> : <JoinGuild />;
};

export const ActionButtonSkeleton = () => (
<Button isLoading loadingText="Loading">
Join guild
</Button>
);
11 changes: 5 additions & 6 deletions src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ 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 { guildOptions, pageBatchOptions } from "@/lib/options";
import { pageBatchOptions } from "@/lib/options";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useGuild } from "../hooks/useGuild";
import { useGuildUrlName } from "../hooks/useGuildUrlName";
import { PageNavLink } from "./RoleGroupNavLink";

export const GuildTabs = () => {
const { guildUrlName } = useParams<{ guildUrlName: string }>();
const { data: guild } = useSuspenseQuery(
guildOptions({ guildIdLike: guildUrlName }),
);
const guildUrlName = useGuildUrlName();
const { data: guild } = useGuild();
const { data: pages } = useSuspenseQuery(
pageBatchOptions({ guildIdLike: guildUrlName }),
);
Expand Down
Loading

0 comments on commit 8f10e63

Please sign in to comment.