Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: requirements #1572

Merged
merged 10 commits into from
Dec 4, 2024
20 changes: 16 additions & 4 deletions src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { RequirementDisplayComponent } from "@/components/requirements/RequirementDisplayComponent";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { ScrollArea } from "@/components/ui/ScrollArea";
import { fetchGuildApiData } from "@/lib/fetchGuildApi";
import type { Role } from "@/lib/schemas/role";
import type { DynamicRoute } from "@/lib/types";
import type { Schemas } from "@guildxyz/types";
import { Lock } from "@phosphor-icons/react/dist/ssr";
Expand All @@ -15,13 +17,13 @@
);
const pages = await fetchGuildApiData<Schemas["Page"][]>("page/batch", {
method: "POST",
body: JSON.stringify({ ids: guild.pages?.map((p) => p.pageId!) ?? [] }),

Check warning on line 20 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 page = pages.find((p) => p.urlName === pageUrlName)!;

Check warning on line 22 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 roles = await fetchGuildApiData<Schemas["Role"][]>("role/batch", {
dominik-stumpf marked this conversation as resolved.
Show resolved Hide resolved
const roles = await fetchGuildApiData<Role[]>("role/batch", {
method: "POST",
body: JSON.stringify({
ids: page.roles?.map((r) => r.roleId!) ?? [],

Check warning on line 26 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.
}),
});

Expand All @@ -34,11 +36,11 @@
);
};

const RoleCard = async ({ role }: { role: Schemas["Role"] }) => {
const RoleCard = async ({ role }: { role: Role }) => {
const rewards = await fetchGuildApiData<Schemas["Reward"][]>("reward/batch", {
method: "POST",
body: JSON.stringify({
ids: role.rewards?.map((r) => r.rewardId!) ?? [],

Check warning on line 43 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.
}),
headers: {
"Content-Type": "application/json",
Expand Down Expand Up @@ -71,8 +73,8 @@
</ScrollArea>
)}
</div>
<div className="bg-card-secondary p-6 md:w-1/2">
<div className="flex items-center justify-between">
<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>
Expand All @@ -81,6 +83,16 @@
Join Guild to collect rewards
</Button>
</div>

{/* 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>
);
Expand Down
147 changes: 147 additions & 0 deletions src/components/requirements/RequirementDisplayComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { ChainIndicator } from "@/components/requirements/ChainIndicator";
import { RequirementLink } from "@/components/requirements/RequirementLink";
import { CHAINS, type SupportedChainID } from "@/config/chains";
import { GearSix } 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 { DataBlockWithCopy } from "./DataBlockWithCopy";

const PLACEHOLDER_REGEX = /\{\{([^{}]+)\}\}/g;
const ADDRESS_REGEX = /^0x[a-f0-9]{40}$/i;
dominik-stumpf marked this conversation as resolved.
Show resolved Hide resolved

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 (
<span key={node.id}>
{convertTemplateText(node.value, requirement)}
</span>
);
case "CHAIN_INDICATOR":
// @ts-expect-error - TODO: create a map for chainId - chainName (uppercase) pairs
return isSupportedChain(node.value) ? (
<ChainIndicator key={node.id} chain={node.value} />
) : null;
case "EXTERNAL_LINK":
return (
<RequirementLink
key={node.id}
href={convertTemplateText(node.href, requirement)}
>
{node.value}
</RequirementLink>
);
default:
return null;
dominik-stumpf marked this conversation as resolved.
Show resolved Hide resolved
}
};

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 (
<Requirement>
<RequirementImage />
<RequirementContent>
<p>
{headerNodes.map((node) => (
<RequirementNode
key={node.id}
node={node}
requirement={requirement}
/>
))}
</p>
{footerNodes?.length > 0 && (
<RequirementFooter>
{footerNodes.map((node) => (
<RequirementNode
key={node.id}
node={node}
requirement={requirement}
/>
))}
</RequirementFooter>
)}
</RequirementContent>
</Requirement>
);
}

const integrationConfigArray = Object.entries(requirement.config);

return (
<Requirement className="rounded-2xl border border-border bg-card-secondary p-5">
<RequirementImage>
<GearSix className="size-6" />
</RequirementImage>
<RequirementContent>
<p>
<span>{`${requirement.integration.displayName} (`}</span>
{integrationConfigArray.map(([key, value], index) => (
<Fragment key={key}>
<span>{`${key}: `}</span>
{typeof value === "string" && ADDRESS_REGEX.test(value) ? (
<DataBlockWithCopy text={value}>
{shortenHex(value)}
</DataBlockWithCopy>
) : (
<span>
{typeof value === "object" ? JSON.stringify(value) : value}
</span>
)}
{index < integrationConfigArray.length - 1 && <span>{", "}</span>}
</Fragment>
))}
<span>)</span>
</p>
{typeof requirement.config.data !== "string" &&
"chain" in requirement.config.data &&
typeof requirement.config.data.chain === "number" &&
isSupportedChain(requirement.config.data.chain) && (
<RequirementFooter>
<ChainIndicator chain={requirement.config.data.chain} />
</RequirementFooter>
)}
</RequirementContent>
</Requirement>
);
};
2 changes: 0 additions & 2 deletions src/lib/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
25 changes: 14 additions & 11 deletions src/lib/schemas/role.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import { z } from "zod";
import { DateLike, ImageUrlSchema, LogicSchema, NameSchema } from "./common";
import { DateLike, ImageUrlSchema, NameSchema } from "./common";
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(),
});

Expand All @@ -25,6 +16,18 @@ 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(
z.object({
rewardId: z.string().uuid(),
}),
),
});

export type Role = z.infer<typeof RoleSchema>;
63 changes: 63 additions & 0 deletions src/lib/schemas/rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { z } from "zod";

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: z.string(), // TODO: identity schema
}),
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<typeof RuleSchema>;
Loading