diff --git a/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.tsx b/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.tsx
new file mode 100644
index 0000000000..16e79e3f59
--- /dev/null
+++ b/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import React from "react";
+import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
+import { DefaultWaveLeaderboardDrop } from "./DefaultWaveLeaderboardDrop";
+
+interface QuorumWaveLeaderboardDropProps {
+ readonly drop: ExtendedDrop;
+ readonly onDropClick: (drop: ExtendedDrop) => void;
+}
+
+export const QuorumWaveLeaderboardDrop: React.FC<
+ QuorumWaveLeaderboardDropProps
+> = ({ drop, onDropClick }) => {
+ return (
+
+ );
+};
diff --git a/components/waves/leaderboard/drops/WaveLeaderboardDrop.tsx b/components/waves/leaderboard/drops/WaveLeaderboardDrop.tsx
index 6945fc7384..3166b9f062 100644
--- a/components/waves/leaderboard/drops/WaveLeaderboardDrop.tsx
+++ b/components/waves/leaderboard/drops/WaveLeaderboardDrop.tsx
@@ -1,33 +1,20 @@
import React from "react";
import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
-import type { ApiWave } from "@/generated/models/ObjectSerializer";
-import { MemesLeaderboardDrop } from "@/components/memes/drops/MemesLeaderboardDrop";
-import { useWave } from "@/hooks/useWave";
-import { DefaultWaveLeaderboardDrop } from "./DefaultWaveLeaderboardDrop";
+import type { ApiWave } from "@/generated/models/ApiWave";
+import { useWaveLeaderboardRendererSet } from "../leaderboardRendererRegistry";
interface WaveLeaderboardDropProps {
readonly drop: ExtendedDrop;
readonly wave: ApiWave;
readonly onDropClick: (drop: ExtendedDrop) => void;
- readonly onSourceDropDeleted?: (() => void) | undefined;
}
export const WaveLeaderboardDrop: React.FC
= ({
drop,
wave,
onDropClick,
- onSourceDropDeleted,
}) => {
- const { isMemesWave } = useWave(wave);
- if (isMemesWave) {
- return (
-
- );
- }
- return ;
+ const { LeaderboardDrop } = useWaveLeaderboardRendererSet(wave.id);
+
+ return ;
};
diff --git a/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx b/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx
index 945e359206..02030dca18 100644
--- a/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx
+++ b/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx
@@ -5,7 +5,6 @@ import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
import type { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard";
import { useWaveDropsLeaderboard } from "@/hooks/useWaveDropsLeaderboard";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { WaveLeaderboardDrop } from "./WaveLeaderboardDrop";
import { WaveLeaderboardEmptyState } from "./WaveLeaderboardEmptyState";
@@ -15,6 +14,7 @@ import { WaveLeaderboardLoadingBar } from "./WaveLeaderboardLoadingBar";
interface WaveLeaderboardDropsProps {
readonly wave: ApiWave;
readonly sort: WaveDropsLeaderboardSort;
+ readonly onDropClick: (drop: ExtendedDrop) => void;
readonly onCreateDrop?: (() => void) | undefined;
readonly curatedByGroupId?: string | undefined;
readonly minPrice?: number | undefined;
@@ -25,30 +25,22 @@ interface WaveLeaderboardDropsProps {
export const WaveLeaderboardDrops: React.FC = ({
wave,
sort,
+ onDropClick,
onCreateDrop,
curatedByGroupId,
minPrice,
maxPrice,
priceCurrency,
}) => {
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const {
- drops,
- fetchNextPage,
- hasNextPage,
- isFetching,
- isFetchingNextPage,
- refetch,
- } = useWaveDropsLeaderboard({
- waveId: wave.id,
- sort,
- curatedByGroupId,
- minPrice,
- maxPrice,
- priceCurrency,
- });
+ const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
+ useWaveDropsLeaderboard({
+ waveId: wave.id,
+ sort,
+ curatedByGroupId,
+ minPrice,
+ maxPrice,
+ priceCurrency,
+ });
const intersectionElementRef = useIntersectionObserver(async () => {
if (hasNextPage && !isFetching && !isFetchingNextPage) {
@@ -56,16 +48,6 @@ export const WaveLeaderboardDrops: React.FC = ({
}
});
- const onDropClick = (drop: ExtendedDrop) => {
- const params = new URLSearchParams(searchParams.toString());
- params.set("drop", drop.id);
- router.push(`${pathname}?${params.toString()}`);
- };
-
- const handleSourceDropDeleted = React.useCallback(() => {
- void refetch();
- }, [refetch]);
-
if (isFetching && drops.length === 0) {
return ;
}
@@ -84,7 +66,6 @@ export const WaveLeaderboardDrops: React.FC = ({
drop={drop}
wave={wave}
onDropClick={onDropClick}
- onSourceDropDeleted={handleSourceDropDeleted}
/>
))}
{isFetchingNextPage && }
diff --git a/components/waves/leaderboard/leaderboardRendererRegistry.tsx b/components/waves/leaderboard/leaderboardRendererRegistry.tsx
new file mode 100644
index 0000000000..fd98c06b99
--- /dev/null
+++ b/components/waves/leaderboard/leaderboardRendererRegistry.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import React, { useMemo } from "react";
+import type { ComponentType } from "react";
+import { useSeizeSettings } from "@/contexts/SeizeSettingsContext";
+import type { ApiWave } from "@/generated/models/ApiWave";
+import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
+import {
+ resolveWaveParticipationVariant,
+ type WaveParticipationVariant,
+} from "@/helpers/waves/wave-participation-presentation.helpers";
+import { MemesLeaderboardDrop } from "@/components/memes/drops/MemesLeaderboardDrop";
+import { DefaultWaveSmallLeaderboardDrop } from "@/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop";
+import { MemesWaveSmallLeaderboardDrop } from "@/components/waves/small-leaderboard/MemesWaveSmallLeaderboardDrop";
+import { QuorumWaveSmallLeaderboardDrop } from "@/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop";
+import { DefaultWaveLeaderboardDrop } from "./drops/DefaultWaveLeaderboardDrop";
+import { QuorumWaveLeaderboardDrop } from "./drops/QuorumWaveLeaderboardDrop";
+
+interface WaveLeaderboardDropRendererProps {
+ readonly drop: ExtendedDrop;
+ readonly wave: ApiWave;
+ readonly onDropClick: (drop: ExtendedDrop) => void;
+}
+
+interface WaveSmallLeaderboardDropRendererProps {
+ readonly drop: ExtendedDrop;
+ readonly onDropClick: () => void;
+}
+
+interface WaveLeaderboardRendererSet {
+ readonly LeaderboardDrop: ComponentType;
+ readonly SmallLeaderboardDrop: ComponentType;
+}
+
+interface ResolvedWaveLeaderboardRendererSet extends WaveLeaderboardRendererSet {
+ readonly variant: WaveParticipationVariant;
+}
+
+const WAVE_LEADERBOARD_VARIANT_OVERRIDES: Readonly<
+ Partial>
+> = {};
+
+const DefaultLeaderboardDropRenderer: React.FC<
+ WaveLeaderboardDropRendererProps
+> = ({ drop, onDropClick }) => {
+ return ;
+};
+
+const QuorumLeaderboardDropRenderer: React.FC<
+ WaveLeaderboardDropRendererProps
+> = ({ drop, onDropClick }) => {
+ return ;
+};
+
+const MemesLeaderboardDropRenderer: React.FC<
+ WaveLeaderboardDropRendererProps
+> = ({ drop, wave, onDropClick }) => {
+ return (
+
+ );
+};
+
+const WAVE_LEADERBOARD_RENDERERS: Readonly<
+ Record
+> = {
+ default: {
+ LeaderboardDrop: DefaultLeaderboardDropRenderer,
+ SmallLeaderboardDrop: DefaultWaveSmallLeaderboardDrop,
+ },
+ memes: {
+ LeaderboardDrop: MemesLeaderboardDropRenderer,
+ SmallLeaderboardDrop: MemesWaveSmallLeaderboardDrop,
+ },
+ curation: {
+ LeaderboardDrop: DefaultLeaderboardDropRenderer,
+ SmallLeaderboardDrop: DefaultWaveSmallLeaderboardDrop,
+ },
+ quorum: {
+ LeaderboardDrop: QuorumLeaderboardDropRenderer,
+ SmallLeaderboardDrop: QuorumWaveSmallLeaderboardDrop,
+ },
+};
+
+export const useWaveLeaderboardRendererSet = (
+ waveId: string | null | undefined
+): ResolvedWaveLeaderboardRendererSet => {
+ const { isMemesWave, isCurationWave, isQuorumWave } = useSeizeSettings();
+
+ return useMemo(() => {
+ const variant = resolveWaveParticipationVariant({
+ waveId,
+ overrides: WAVE_LEADERBOARD_VARIANT_OVERRIDES,
+ isMemesWave,
+ isCurationWave,
+ isQuorumWave,
+ });
+
+ return {
+ variant,
+ ...WAVE_LEADERBOARD_RENDERERS[variant],
+ };
+ }, [isCurationWave, isMemesWave, isQuorumWave, waveId]);
+};
diff --git a/components/waves/memes/MemesArtSubmissionModal.tsx b/components/waves/memes/MemesArtSubmissionModal.tsx
index a928fdf41a..4f391d0c5a 100644
--- a/components/waves/memes/MemesArtSubmissionModal.tsx
+++ b/components/waves/memes/MemesArtSubmissionModal.tsx
@@ -1,9 +1,8 @@
"use client";
-import React, { useRef } from "react";
-import { motion, AnimatePresence } from "framer-motion";
+import React, { useEffect, useRef } from "react";
+import { AnimatePresence, LazyMotion, domAnimation, m } from "framer-motion";
import { createPortal } from "react-dom";
-import { useKeyPressEvent } from "react-use";
import type { ApiWave } from "@/generated/models/ApiWave";
import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
import MemesArtSubmissionContainer from "./submission/MemesArtSubmissionContainer";
@@ -25,14 +24,30 @@ const MemesArtSubmissionModal: React.FC = ({
}) => {
const modalRef = useRef(null);
- useKeyPressEvent("Escape", () => onClose());
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
-
- {isOpen && (
-
+
+ = ({
onClick={onClose}
>
- = ({
onSourceDropDeleted={onSourceDropDeleted}
/>
-
+
-
- )}
- ,
+
+
+ ,
document.body
);
};
diff --git a/components/waves/memes/submission/MemesArtResubmitAction.tsx b/components/waves/memes/submission/MemesArtResubmitAction.tsx
index d92b18df8d..a4608b8f54 100644
--- a/components/waves/memes/submission/MemesArtResubmitAction.tsx
+++ b/components/waves/memes/submission/MemesArtResubmitAction.tsx
@@ -142,7 +142,7 @@ function MemesArtResubmitActionWithWave({
const title = disabledReason ?? "Resubmit";
const tooltipId = `resubmit-${drop.id}-${variant}`;
- const modal = (
+ const modal = isModalOpen ? (
- );
+ ) : null;
if (variant === "menu") {
return (
diff --git a/components/waves/quorum/QuorumParticipationDrop.tsx b/components/waves/quorum/QuorumParticipationDrop.tsx
new file mode 100644
index 0000000000..4b4348f3a9
--- /dev/null
+++ b/components/waves/quorum/QuorumParticipationDrop.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import EndedParticipationDrop from "@/components/waves/drops/participation/EndedParticipationDrop";
+import OngoingParticipationDrop from "@/components/waves/drops/participation/OngoingParticipationDrop";
+import type { ParticipationDropProps } from "@/components/waves/drops/participation/participationRenderer.types";
+import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules";
+
+export default function QuorumParticipationDrop(props: ParticipationDropProps) {
+ const { isVotingEnded } = useDropInteractionRules(props.drop);
+
+ if (isVotingEnded) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/components/waves/quorum/QuorumProposalCompactContent.tsx b/components/waves/quorum/QuorumProposalCompactContent.tsx
new file mode 100644
index 0000000000..40a830c77e
--- /dev/null
+++ b/components/waves/quorum/QuorumProposalCompactContent.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import DropPartMarkdownWithPropLogger from "@/components/drops/view/part/DropPartMarkdownWithPropLogger";
+import type { DropPartMarkdownProps } from "@/components/drops/view/part/DropPartMarkdown";
+import { ChevronRightIcon } from "@heroicons/react/20/solid";
+import { useId, useState } from "react";
+import type {
+ ParsedQuorumProposalMarkdown,
+ ParsedQuorumProposalSection,
+} from "./quorumProposalMarkdown";
+
+type CompactMarkdownProps = Pick<
+ DropPartMarkdownProps,
+ | "mentionedUsers"
+ | "mentionedGroups"
+ | "mentionedWaves"
+ | "referencedNfts"
+ | "nftLinks"
+ | "onQuoteClick"
+ | "currentDropId"
+ | "hideLinkPreviews"
+ | "quotePath"
+ | "linkPreviewToggleControl"
+ | "onLinkCardActionsActiveChange"
+>;
+
+interface QuorumProposalCompactContentProps extends CompactMarkdownProps {
+ readonly proposal: ParsedQuorumProposalMarkdown;
+}
+
+function stopPropagation(event: { stopPropagation: () => void }): void {
+ event.stopPropagation();
+}
+
+function ProposalMarkdownBlock({
+ markdown,
+ markdownProps,
+}: Readonly<{
+ markdown: string;
+ markdownProps: CompactMarkdownProps;
+}>) {
+ return (
+
+ );
+}
+
+function ProposalSectionCard({
+ section,
+ markdownProps,
+}: Readonly<{
+ section: ParsedQuorumProposalSection;
+ markdownProps: CompactMarkdownProps;
+}>) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
setIsOpen(event.currentTarget.open)}
+ className="tw-rounded-xl tw-border tw-border-solid tw-border-iron-800 tw-bg-iron-950/70"
+ >
+
+
+ {section.heading}
+
+
+
+ {isOpen && (
+
+ )}
+
+ );
+}
+
+export default function QuorumProposalCompactContent({
+ proposal,
+ ...markdownProps
+}: QuorumProposalCompactContentProps) {
+ const [areDetailsVisible, setAreDetailsVisible] = useState(false);
+ const detailsContainerId = useId();
+ const sectionCount = proposal.sections.length;
+ const detailsToggleLabel = areDetailsVisible
+ ? "Hide details"
+ : `Show details (${sectionCount})`;
+
+ return (
+
+
+
+ Proposal
+
+
+ {proposal.title}
+
+
+ {sectionCount > 0 && (
+
+
+
+ )}
+
+
+ {areDetailsVisible && (
+
+ {proposal.sections.map((section) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/waves/quorum/quorumProposalMarkdown.ts b/components/waves/quorum/quorumProposalMarkdown.ts
index 8a53ea4585..2f46b8cb83 100644
--- a/components/waves/quorum/quorumProposalMarkdown.ts
+++ b/components/waves/quorum/quorumProposalMarkdown.ts
@@ -18,6 +18,17 @@ export interface QuorumProposalFormValues {
readonly risksTradeoffs: string;
}
+export interface ParsedQuorumProposalSection {
+ readonly heading: string;
+ readonly markdown: string;
+}
+
+export interface ParsedQuorumProposalMarkdown {
+ readonly title: string;
+ readonly summaryMarkdown: string;
+ readonly sections: readonly ParsedQuorumProposalSection[];
+}
+
export const EMPTY_QUORUM_PROPOSAL_FORM_VALUES: QuorumProposalFormValues = {
title: "",
summary: "",
@@ -66,6 +77,540 @@ const normalizeTitle = (title: string): string => {
return normalizedTitle.length ? normalizedTitle : "Untitled QUORUM Proposal";
};
+const normalizeSectionHeading = (heading: string): string =>
+ heading.trim().toLowerCase();
+
+const QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS = [
+ "Summary",
+ "Problem Statement",
+ "Proposed Solution",
+ "Working Spec (Required)",
+ "Implementation Path",
+ "Impact & Priority",
+ "Success Criteria",
+ "Risks & Trade-offs",
+] as const;
+
+type QuorumProposalTopLevelHeading =
+ (typeof QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS)[number];
+
+const QUORUM_PROPOSAL_TOP_LEVEL_HEADING_SET =
+ new Set
(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS);
+
+const QUORUM_PROPOSAL_TOP_LEVEL_HEADING_INDEX = new Map<
+ QuorumProposalTopLevelHeading,
+ number
+>(
+ QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS.map(
+ (heading, index) => [heading, index] as const
+ )
+);
+
+const parseTopLevelHeading = (line: string): string | null => {
+ const headingMatch = /^##\s+(.+?)\s*$/.exec(line);
+ const heading = headingMatch?.[1]?.trim();
+ return heading && heading.length > 0 ? heading : null;
+};
+
+const parseCanonicalTopLevelHeading = (
+ line: string
+): QuorumProposalTopLevelHeading | null => {
+ const heading = parseTopLevelHeading(line);
+
+ if (
+ !heading ||
+ !QUORUM_PROPOSAL_TOP_LEVEL_HEADING_SET.has(
+ heading as QuorumProposalTopLevelHeading
+ )
+ ) {
+ return null;
+ }
+
+ return heading as QuorumProposalTopLevelHeading;
+};
+
+const formatTopLevelHeading = (
+ heading: QuorumProposalTopLevelHeading
+): string => `## ${heading}`;
+
+const normalizeMarkdownBlock = (lines: readonly string[]): string =>
+ normalizeMarkdownValue(lines.join("\n"));
+
+const skipLeadingEmptyLines = (lines: readonly string[]): number => {
+ let lineIndex = 0;
+ while (lineIndex < lines.length && lines[lineIndex]?.trim() === "") {
+ lineIndex++;
+ }
+ return lineIndex;
+};
+
+const pushParsedSection = (
+ parsedSections: ParsedQuorumProposalSection[],
+ heading: string,
+ lines: readonly string[]
+): void => {
+ parsedSections.push({
+ heading,
+ markdown: normalizeMarkdownBlock(lines),
+ });
+};
+
+interface MarkdownFenceState {
+ marker: "`" | "~";
+ markerLength: number;
+}
+
+interface ParsedMarkdownFenceLine extends MarkdownFenceState {
+ trailingText: string;
+}
+
+interface QuorumProposalSectionBoundaryCandidate {
+ heading: QuorumProposalTopLevelHeading;
+ headingIndex: number;
+ lineIndex: number;
+}
+
+const parseFenceLine = (line: string): ParsedMarkdownFenceLine | null => {
+ const fenceMatch = /^\s*([`~]{3,})(.*)$/.exec(line);
+ const marker = fenceMatch?.[1];
+ if (!marker) {
+ return null;
+ }
+
+ const fenceCharacter = marker[0];
+ if (
+ (fenceCharacter !== "`" && fenceCharacter !== "~") ||
+ !marker.split("").every((character) => character === fenceCharacter)
+ ) {
+ return null;
+ }
+
+ return {
+ marker: fenceCharacter,
+ markerLength: marker.length,
+ trailingText: fenceMatch[2] ?? "",
+ };
+};
+
+const getNextFenceState = (
+ currentFenceState: MarkdownFenceState | null,
+ line: string
+): MarkdownFenceState | null => {
+ const parsedFenceState = parseFenceLine(line);
+ if (!parsedFenceState) {
+ return currentFenceState;
+ }
+
+ if (!currentFenceState) {
+ return {
+ marker: parsedFenceState.marker,
+ markerLength: parsedFenceState.markerLength,
+ };
+ }
+
+ return currentFenceState.marker === parsedFenceState.marker &&
+ parsedFenceState.markerLength >= currentFenceState.markerLength &&
+ parsedFenceState.trailingText.trim() === ""
+ ? null
+ : currentFenceState;
+};
+
+const linesAreBlank = (
+ lines: readonly string[],
+ startIndex: number,
+ endIndex: number
+): boolean => {
+ for (let lineIndex = startIndex; lineIndex < endIndex; lineIndex++) {
+ if ((lines[lineIndex] ?? "").trim().length > 0) {
+ return false;
+ }
+ }
+
+ return true;
+};
+
+const collectSectionBoundaryCandidates = (
+ lines: readonly string[],
+ startIndex: number
+): QuorumProposalSectionBoundaryCandidate[] => {
+ const candidates: QuorumProposalSectionBoundaryCandidate[] = [];
+ let fenceState: MarkdownFenceState | null = null;
+
+ for (let lineIndex = startIndex; lineIndex < lines.length; lineIndex++) {
+ const line = lines[lineIndex] ?? "";
+
+ if (!fenceState) {
+ const heading = parseCanonicalTopLevelHeading(line);
+ const headingIndex = heading
+ ? QUORUM_PROPOSAL_TOP_LEVEL_HEADING_INDEX.get(heading)
+ : undefined;
+
+ if (heading && headingIndex !== undefined) {
+ candidates.push({
+ heading,
+ headingIndex,
+ lineIndex,
+ });
+ }
+ }
+
+ fenceState = getNextFenceState(fenceState, line);
+ }
+
+ return candidates;
+};
+
+const compareCandidatePaths = (
+ left: readonly QuorumProposalSectionBoundaryCandidate[],
+ right: readonly QuorumProposalSectionBoundaryCandidate[]
+): number => {
+ if (left.length !== right.length) {
+ return left.length - right.length;
+ }
+
+ for (let index = 0; index < left.length; index++) {
+ const lineDifference =
+ (left[index]?.lineIndex ?? -1) - (right[index]?.lineIndex ?? -1);
+
+ if (lineDifference !== 0) {
+ return lineDifference;
+ }
+ }
+
+ return 0;
+};
+
+const collectRequiredFutureHeadingIndexes = (
+ candidates: readonly QuorumProposalSectionBoundaryCandidate[],
+ currentCandidateIndex: number,
+ nextCandidateIndex: number
+): Set | null => {
+ const currentCandidate = candidates[currentCandidateIndex];
+ const nextCandidate = candidates[nextCandidateIndex];
+
+ if (!currentCandidate || !nextCandidate) {
+ return null;
+ }
+
+ const requiredFutureHeadingIndexes = new Set();
+
+ for (
+ let candidateIndex = currentCandidateIndex + 1;
+ candidateIndex < nextCandidateIndex;
+ candidateIndex++
+ ) {
+ const candidate = candidates[candidateIndex];
+ if (!candidate) {
+ continue;
+ }
+
+ if (candidate.headingIndex < currentCandidate.headingIndex) {
+ return null;
+ }
+
+ if (
+ candidate.headingIndex > currentCandidate.headingIndex &&
+ candidate.headingIndex < nextCandidate.headingIndex
+ ) {
+ return null;
+ }
+
+ if (candidate.headingIndex > nextCandidate.headingIndex) {
+ requiredFutureHeadingIndexes.add(candidate.headingIndex);
+ }
+ }
+
+ return requiredFutureHeadingIndexes;
+};
+
+const isTerminalCandidatePath = (
+ candidates: readonly QuorumProposalSectionBoundaryCandidate[],
+ lastCandidateIndex: number
+): boolean => {
+ const lastCandidate = candidates[lastCandidateIndex];
+ if (!lastCandidate) {
+ return false;
+ }
+
+ // Repeated copies of the last chosen heading can stay in the final section
+ // body, but any different canonical heading still makes the tail ambiguous.
+ for (
+ let candidateIndex = lastCandidateIndex + 1;
+ candidateIndex < candidates.length;
+ candidateIndex++
+ ) {
+ const candidate = candidates[candidateIndex];
+ if (!candidate) {
+ continue;
+ }
+
+ if (candidate.headingIndex !== lastCandidate.headingIndex) {
+ return false;
+ }
+ }
+
+ return true;
+};
+
+const shouldPruneCandidatePath = (
+ candidatePathLength: number,
+ lastCandidate: QuorumProposalSectionBoundaryCandidate,
+ bestPath: readonly QuorumProposalSectionBoundaryCandidate[] | null
+): boolean => {
+ if (!bestPath) {
+ return false;
+ }
+
+ const maximumPossiblePathLength =
+ candidatePathLength +
+ (QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS.length -
+ lastCandidate.headingIndex -
+ 1);
+
+ return maximumPossiblePathLength < bestPath.length;
+};
+
+const materializeCandidatePath = (
+ candidates: readonly QuorumProposalSectionBoundaryCandidate[],
+ candidatePathIndexes: readonly number[]
+): readonly QuorumProposalSectionBoundaryCandidate[] =>
+ candidatePathIndexes
+ .map((index) => candidates[index])
+ .filter(
+ (candidate): candidate is QuorumProposalSectionBoundaryCandidate =>
+ !!candidate
+ );
+
+const updateBestCandidatePath = (
+ candidates: readonly QuorumProposalSectionBoundaryCandidate[],
+ candidatePathIndexes: readonly number[],
+ requiredFutureHeadingIndexes: ReadonlySet,
+ lastCandidateIndex: number,
+ bestPath: readonly QuorumProposalSectionBoundaryCandidate[] | null
+): readonly QuorumProposalSectionBoundaryCandidate[] | null => {
+ if (
+ candidatePathIndexes.length < 2 ||
+ requiredFutureHeadingIndexes.size > 0 ||
+ !isTerminalCandidatePath(candidates, lastCandidateIndex)
+ ) {
+ return bestPath;
+ }
+
+ const candidatePath = materializeCandidatePath(
+ candidates,
+ candidatePathIndexes
+ );
+ return !bestPath || compareCandidatePaths(candidatePath, bestPath) > 0
+ ? candidatePath
+ : bestPath;
+};
+
+const buildNextRequiredFutureHeadingIndexes = (
+ candidates: readonly QuorumProposalSectionBoundaryCandidate[],
+ lastCandidateIndex: number,
+ followingCandidate: QuorumProposalSectionBoundaryCandidate,
+ followingCandidateIndex: number,
+ requiredFutureHeadingIndexes: ReadonlySet
+): Set | null => {
+ const gapRequirements = collectRequiredFutureHeadingIndexes(
+ candidates,
+ lastCandidateIndex,
+ followingCandidateIndex
+ );
+ if (!gapRequirements) {
+ return null;
+ }
+
+ const nextRequiredFutureHeadingIndexes = new Set(
+ requiredFutureHeadingIndexes
+ );
+ for (const headingIndex of gapRequirements) {
+ nextRequiredFutureHeadingIndexes.add(headingIndex);
+ }
+ nextRequiredFutureHeadingIndexes.delete(followingCandidate.headingIndex);
+
+ return Array.from(nextRequiredFutureHeadingIndexes).some(
+ (headingIndex) => headingIndex < followingCandidate.headingIndex
+ )
+ ? null
+ : nextRequiredFutureHeadingIndexes;
+};
+
+const selectSectionBoundaryCandidates = (
+ lines: readonly string[],
+ startIndex: number,
+ candidates: readonly QuorumProposalSectionBoundaryCandidate[]
+): readonly QuorumProposalSectionBoundaryCandidate[] | null => {
+ let bestPath: readonly QuorumProposalSectionBoundaryCandidate[] | null = null;
+
+ const visitCandidatePath = (
+ candidatePathIndexes: readonly number[],
+ nextCandidateIndex: number,
+ requiredFutureHeadingIndexes: ReadonlySet
+ ): void => {
+ const lastCandidateIndex = candidatePathIndexes.at(-1);
+ if (lastCandidateIndex === undefined) {
+ return;
+ }
+
+ const lastCandidate = candidates[lastCandidateIndex];
+
+ if (
+ !lastCandidate ||
+ shouldPruneCandidatePath(
+ candidatePathIndexes.length,
+ lastCandidate,
+ bestPath
+ )
+ ) {
+ return;
+ }
+
+ // A skipped canonical heading can stay inside the current section body only
+ // if a later chosen section still accounts for that heading in order.
+ bestPath = updateBestCandidatePath(
+ candidates,
+ candidatePathIndexes,
+ requiredFutureHeadingIndexes,
+ lastCandidateIndex,
+ bestPath
+ );
+
+ for (
+ let followingCandidateIndex = nextCandidateIndex;
+ followingCandidateIndex < candidates.length;
+ followingCandidateIndex++
+ ) {
+ const followingCandidate = candidates[followingCandidateIndex];
+ if (
+ !followingCandidate ||
+ followingCandidate.headingIndex <= lastCandidate.headingIndex
+ ) {
+ continue;
+ }
+
+ const nextRequiredFutureHeadingIndexes =
+ buildNextRequiredFutureHeadingIndexes(
+ candidates,
+ lastCandidateIndex,
+ followingCandidate,
+ followingCandidateIndex,
+ requiredFutureHeadingIndexes
+ );
+ if (!nextRequiredFutureHeadingIndexes) {
+ continue;
+ }
+
+ visitCandidatePath(
+ [...candidatePathIndexes, followingCandidateIndex],
+ followingCandidateIndex + 1,
+ nextRequiredFutureHeadingIndexes
+ );
+ }
+ };
+
+ for (
+ let candidateIndex = 0;
+ candidateIndex < candidates.length;
+ candidateIndex++
+ ) {
+ const candidate = candidates[candidateIndex];
+ if (
+ candidate?.heading !== "Summary" ||
+ !linesAreBlank(lines, startIndex, candidate.lineIndex)
+ ) {
+ continue;
+ }
+
+ visitCandidatePath([candidateIndex], candidateIndex + 1, new Set());
+ }
+
+ return bestPath;
+};
+
+const buildParsedSectionsFromCandidates = (
+ lines: readonly string[],
+ startIndex: number,
+ candidates: readonly QuorumProposalSectionBoundaryCandidate[]
+): ParsedQuorumProposalSection[] | null => {
+ if (
+ candidates.length === 0 ||
+ !linesAreBlank(lines, startIndex, candidates[0]?.lineIndex ?? startIndex)
+ ) {
+ return null;
+ }
+
+ const parsedSections: ParsedQuorumProposalSection[] = [];
+
+ for (
+ let candidateIndex = 0;
+ candidateIndex < candidates.length;
+ candidateIndex++
+ ) {
+ const candidate = candidates[candidateIndex];
+ if (!candidate) {
+ continue;
+ }
+
+ const nextLineIndex =
+ candidates[candidateIndex + 1]?.lineIndex ?? lines.length;
+ pushParsedSection(
+ parsedSections,
+ candidate.heading,
+ lines.slice(candidate.lineIndex + 1, nextLineIndex)
+ );
+ }
+
+ return parsedSections;
+};
+
+const parseQuorumProposalSections = (
+ lines: readonly string[],
+ startIndex: number
+): ParsedQuorumProposalSection[] | null => {
+ const candidates = collectSectionBoundaryCandidates(lines, startIndex);
+ const selectedCandidates = selectSectionBoundaryCandidates(
+ lines,
+ startIndex,
+ candidates
+ );
+
+ if (!selectedCandidates) {
+ return null;
+ }
+
+ return buildParsedSectionsFromCandidates(
+ lines,
+ startIndex,
+ selectedCandidates
+ );
+};
+
+const splitSummarySection = (
+ parsedSections: readonly ParsedQuorumProposalSection[]
+): Omit | null => {
+ const summarySection = parsedSections.find(
+ (section) => normalizeSectionHeading(section.heading) === "summary"
+ );
+
+ if (!summarySection) {
+ return null;
+ }
+
+ const sections = parsedSections.filter(
+ (section) => normalizeSectionHeading(section.heading) !== "summary"
+ );
+
+ if (sections.length === 0) {
+ return null;
+ }
+
+ return {
+ summaryMarkdown: summarySection.markdown,
+ sections,
+ };
+};
+
export const hasQuorumProposalContent = (
values: QuorumProposalFormValues
): boolean =>
@@ -73,6 +618,43 @@ export const hasQuorumProposalContent = (
return values[field].trim().length > 0;
});
+export const parseQuorumProposalMarkdown = (
+ markdown: string | null | undefined
+): ParsedQuorumProposalMarkdown | null => {
+ if (!markdown?.trim()) {
+ return null;
+ }
+
+ const normalizedMarkdown = markdown.replaceAll(/\r\n?/g, "\n");
+ const lines = normalizedMarkdown.split("\n");
+ const lineIndex = skipLeadingEmptyLines(lines);
+
+ const titleMatch = /^#\s+(.+?)\s*$/.exec(lines[lineIndex]?.trim() ?? "");
+ if (!titleMatch) {
+ return null;
+ }
+
+ const title = titleMatch[1];
+ if (!title) {
+ return null;
+ }
+
+ const parsedSections = parseQuorumProposalSections(lines, lineIndex + 1);
+ if (!parsedSections) {
+ return null;
+ }
+
+ const contentSections = splitSummarySection(parsedSections);
+ if (!contentSections) {
+ return null;
+ }
+
+ return {
+ title: normalizeTitle(title),
+ ...contentSections,
+ };
+};
+
export const buildQuorumProposalMarkdown = (
values: QuorumProposalFormValues
): string => {
@@ -81,19 +663,19 @@ export const buildQuorumProposalMarkdown = (
return [
`# ${normalizeTitle(values.title)}`,
"",
- "## Summary",
+ formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[0]),
"",
normalizeMarkdownValue(values.summary),
"",
- "## Problem Statement",
+ formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[1]),
"",
normalizeMarkdownValue(values.problemStatement),
"",
- "## Proposed Solution",
+ formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[2]),
"",
normalizeMarkdownValue(values.proposedSolution),
"",
- "## Working Spec (Required)",
+ formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[3]),
"",
"### Core features",
"",
@@ -111,11 +693,11 @@ export const buildQuorumProposalMarkdown = (
"",
normalizeMarkdownValue(values.scopeBoundaries),
"",
- "## Implementation Path",
+ formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[4]),
"",
normalizeMarkdownValue(values.implementationPath),
"",
- "## Impact & Priority",
+ formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[5]),
"",
"### Who benefits",
"",
@@ -129,7 +711,7 @@ export const buildQuorumProposalMarkdown = (
"",
urgency,
"",
- "## Success Criteria",
+ formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[6]),
"",
"### Observable outcome",
"",
@@ -139,7 +721,7 @@ export const buildQuorumProposalMarkdown = (
"",
normalizeMarkdownValue(values.measurableSignal),
"",
- "## Risks & Trade-offs",
+ formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[7]),
"",
normalizeMarkdownValue(values.risksTradeoffs),
].join("\n");
diff --git a/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx
index 24ef5c1aa7..ad0705dc4d 100644
--- a/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx
+++ b/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx
@@ -1,27 +1,31 @@
import React from "react";
import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
+import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation";
import { WaveSmallLeaderboardTopThreeDrop } from "./WaveSmallLeaderboardTopThreeDrop";
import { WaveSmallLeaderboardDefaultDrop } from "./WaveSmallLeaderboardDefaultDrop";
interface DefaultWaveSmallLeaderboardDropProps {
readonly drop: ExtendedDrop;
readonly onDropClick: () => void;
+ readonly contentPresentation?: DropContentPresentation | undefined;
}
export const DefaultWaveSmallLeaderboardDrop: React.FC<
DefaultWaveSmallLeaderboardDropProps
-> = ({ drop, onDropClick }) => {
+> = ({ drop, onDropClick, contentPresentation = "default" }) => {
return (
{typeof drop.rank === "number" && drop.rank <= 3 ? (
) : (
)}
diff --git a/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.tsx
new file mode 100644
index 0000000000..ae718c6c54
--- /dev/null
+++ b/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
+import { DefaultWaveSmallLeaderboardDrop } from "./DefaultWaveSmallLeaderboardDrop";
+
+interface QuorumWaveSmallLeaderboardDropProps {
+ readonly drop: ExtendedDrop;
+ readonly onDropClick: () => void;
+}
+
+export const QuorumWaveSmallLeaderboardDrop: React.FC<
+ QuorumWaveSmallLeaderboardDropProps
+> = ({ drop, onDropClick }) => {
+ return (
+
+ );
+};
diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx
index 25d651947e..98baa59b10 100644
--- a/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx
+++ b/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx
@@ -1,9 +1,12 @@
import React from "react";
import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
import Link from "next/link";
+import Image from "next/image";
+import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation";
import { CICType } from "@/entities/IProfile";
import { cicToType, formatNumberWithCommas } from "@/helpers/Helpers";
import { assertUnreachable } from "@/helpers/AllowlistToolHelpers";
+import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers";
import { WaveSmallLeaderboardItemContent } from "./WaveSmallLeaderboardItemContent";
import { WaveSmallLeaderboardItemOutcomes } from "./WaveSmallLeaderboardItemOutcomes";
import WaveDropActionsRate from "../drops/WaveDropActionsRate";
@@ -14,11 +17,14 @@ import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileToo
interface WaveSmallLeaderboardDefaultDropProps {
readonly drop: ExtendedDrop;
readonly onDropClick: () => void;
+ readonly contentPresentation?: DropContentPresentation | undefined;
}
export const WaveSmallLeaderboardDefaultDrop: React.FC<
WaveSmallLeaderboardDefaultDropProps
-> = ({ drop, onDropClick }) => {
+> = ({ drop, onDropClick, contentPresentation = "default" }) => {
+ const authorLabel = drop.author.handle ?? drop.author.primary_address;
+
const getCICColor = (cic: number): string => {
const cicType = cicToType(cic);
switch (cicType) {
@@ -75,17 +81,25 @@ export const WaveSmallLeaderboardDefaultDrop: React.FC<
{drop.author.pfp ? (
-

) : (
diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx
index 7ec2e922c2..c9b19fd655 100644
--- a/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx
+++ b/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx
@@ -1,9 +1,7 @@
import React from "react";
import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
import type { ApiWave } from "@/generated/models/ApiWave";
-import { useWave } from "@/hooks/useWave";
-import { MemesWaveSmallLeaderboardDrop } from "./MemesWaveSmallLeaderboardDrop";
-import { DefaultWaveSmallLeaderboardDrop } from "./DefaultWaveSmallLeaderboardDrop";
+import { useWaveLeaderboardRendererSet } from "../leaderboard/leaderboardRendererRegistry";
interface WaveSmallLeaderboardDropProps {
readonly drop: ExtendedDrop;
@@ -14,13 +12,7 @@ interface WaveSmallLeaderboardDropProps {
export const WaveSmallLeaderboardDrop: React.FC<
WaveSmallLeaderboardDropProps
> = ({ drop, wave, onDropClick }) => {
- const { isMemesWave } = useWave(wave);
- if (isMemesWave) {
- return (
-
- );
- }
- return (
-
- );
+ const { SmallLeaderboardDrop } = useWaveLeaderboardRendererSet(wave.id);
+
+ return
;
};
diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx
index 1a249c9e6f..5fef0623da 100644
--- a/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx
+++ b/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx
@@ -5,8 +5,10 @@ import {
ExtendedDrop,
getDropPreviewImageUrl,
} from "@/helpers/waves/drop.helpers";
+import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation";
import { useEffect, useMemo, useRef, useState } from "react";
import { Tooltip } from "react-tooltip";
+import Image from "next/image";
import WaveDropPartContentMedias from "../drops/WaveDropPartContentMedias";
import WaveDropPartContentMarkdown from "../drops/WaveDropPartContentMarkdown";
import { ImageScale, getScaledImageUri } from "@/helpers/image.helpers";
@@ -14,11 +16,12 @@ import { ImageScale, getScaledImageUri } from "@/helpers/image.helpers";
interface WaveSmallLeaderboardItemContentProps {
readonly drop: ExtendedDrop;
readonly onDropClick: () => void;
+ readonly contentPresentation?: DropContentPresentation | undefined;
}
export const WaveSmallLeaderboardItemContent: React.FC<
WaveSmallLeaderboardItemContentProps
-> = ({ drop, onDropClick }) => {
+> = ({ drop, onDropClick, contentPresentation = "default" }) => {
const contentRef = useRef
(null);
const [showGradient, setShowGradient] = useState(false);
@@ -46,11 +49,15 @@ export const WaveSmallLeaderboardItemContent: React.FC<
>
{previewImageUrl ? (
-

+
+
+
) : (
!!drop.parts[0]?.media.length && (
@@ -70,6 +77,7 @@ export const WaveSmallLeaderboardItemContent: React.FC<
wave={drop.wave}
drop={drop}
onQuoteClick={() => {}}
+ contentPresentation={contentPresentation}
/>
{showGradient && (
diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.tsx
index 0c15173d8a..4a95fa1038 100644
--- a/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.tsx
+++ b/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.tsx
@@ -1,9 +1,12 @@
import React from "react";
import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
import Link from "next/link";
+import Image from "next/image";
+import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation";
import { cicToType, formatNumberWithCommas } from "@/helpers/Helpers";
import { CICType } from "@/entities/IProfile";
import { assertUnreachable } from "@/helpers/AllowlistToolHelpers";
+import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers";
import { WaveSmallLeaderboardItemContent } from "./WaveSmallLeaderboardItemContent";
import { WaveSmallLeaderboardItemOutcomes } from "./WaveSmallLeaderboardItemOutcomes";
import WinnerDropBadge from "../drops/winner/WinnerDropBadge";
@@ -13,11 +16,14 @@ import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileToo
interface WaveSmallLeaderboardTopThreeDropProps {
readonly drop: ExtendedDrop;
readonly onDropClick: () => void;
+ readonly contentPresentation?: DropContentPresentation | undefined;
}
export const WaveSmallLeaderboardTopThreeDrop: React.FC<
WaveSmallLeaderboardTopThreeDropProps
-> = ({ drop, onDropClick }) => {
+> = ({ drop, onDropClick, contentPresentation = "default" }) => {
+ const authorLabel = drop.author.handle ?? drop.author.primary_address;
+
const getRankTextColor = (rank: number | null): string | null => {
if (rank === 1) return "tw-text-[#E8D48A]";
if (rank === 2) return "tw-text-[#DDDDDD]";
@@ -100,18 +106,26 @@ export const WaveSmallLeaderboardTopThreeDrop: React.FC<
e.stopPropagation()}
className="tw-flex tw-items-center tw-gap-x-2 tw-no-underline"
>
{drop.author.pfp ? (
-

) : (
diff --git a/helpers/waves/wave-participation-presentation.helpers.ts b/helpers/waves/wave-participation-presentation.helpers.ts
new file mode 100644
index 0000000000..99fe2ccad4
--- /dev/null
+++ b/helpers/waves/wave-participation-presentation.helpers.ts
@@ -0,0 +1,52 @@
+import { normalizeOptionalWaveId } from "./wave.helpers";
+
+export type WaveParticipationVariant =
+ | "default"
+ | "memes"
+ | "curation"
+ | "quorum";
+
+const DEFAULT_WAVE_PARTICIPATION_VARIANT_OVERRIDES: Readonly<
+ Partial
>
+> = {};
+
+export const resolveWaveParticipationVariant = ({
+ waveId,
+ overrides = DEFAULT_WAVE_PARTICIPATION_VARIANT_OVERRIDES,
+ isMemesWave,
+ isCurationWave,
+ isQuorumWave,
+}: {
+ readonly waveId: string | null | undefined;
+ readonly overrides?:
+ | Readonly>>
+ | undefined;
+ readonly isMemesWave: (waveId: string | undefined | null) => boolean;
+ readonly isCurationWave: (waveId: string | undefined | null) => boolean;
+ readonly isQuorumWave: (waveId: string | undefined | null) => boolean;
+}): WaveParticipationVariant => {
+ const normalizedWaveId = normalizeOptionalWaveId(waveId);
+
+ if (!normalizedWaveId) {
+ return "default";
+ }
+
+ const overrideVariant = overrides[normalizedWaveId];
+ if (overrideVariant) {
+ return overrideVariant;
+ }
+
+ if (isMemesWave(normalizedWaveId)) {
+ return "memes";
+ }
+
+ if (isCurationWave(normalizedWaveId)) {
+ return "curation";
+ }
+
+ if (isQuorumWave(normalizedWaveId)) {
+ return "quorum";
+ }
+
+ return "default";
+};