diff --git a/package.json b/package.json index 89fabbb1..a8ea22ae 100644 --- a/package.json +++ b/package.json @@ -124,9 +124,11 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.3", + "@tanstack/react-query": "^5.59.15", "cmdk": "1.0.0", "date-fns": "^4.1.0", "embla-carousel-react": "^8.3.0", + "graphql-request": "^7.1.0", "input-otp": "^1.2.4", "next-themes": "^0.3.0", "react-day-picker": "8.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdfe09bd..e67a1bc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.59.15 + version: 5.59.15(react@18.3.1) cmdk: specifier: 1.0.0 version: 1.0.0(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -101,6 +104,9 @@ importers: embla-carousel-react: specifier: ^8.3.0 version: 8.3.0(react@18.3.1) + graphql-request: + specifier: ^7.1.0 + version: 7.1.0(graphql@16.9.0) input-otp: specifier: ^1.2.4 version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -840,6 +846,11 @@ packages: '@fontsource/dm-mono@5.1.0': resolution: {integrity: sha512-cfowZUJJDHjgVFOEQmIn2KHtQ8LDXnTGgTTunUImzzg3Xf6V92MlYR/pmkqIt7lXq7lfP/pQZ9ph/9JeLDRsCQ==} + '@graphql-typed-document-node/core@3.2.0': + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -1015,6 +1026,12 @@ packages: '@microsoft/tsdoc@0.15.0': resolution: {integrity: sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==} + '@molt/command@0.9.0': + resolution: {integrity: sha512-1JI8dAlpqlZoXyKWVQggX7geFNPxBpocHIXQCsnxDjKy+3WX4SGyZVJXuLlqRRrX7FmQCuuMAfx642ovXmPA9g==} + + '@molt/types@0.2.0': + resolution: {integrity: sha512-p6ChnEZDGjg9PYPec9BK6Yp5/DdSrYQvXTBAtgrnqX6N36cZy37ql1c8Tc5LclfIYBNG7EZp8NBcRTYJwyi84g==} + '@mswjs/interceptors@0.35.9': resolution: {integrity: sha512-SSnyl/4ni/2ViHKkiZb8eajA/eN1DNFaHjhGiLUdZvDz6PKF4COSf/17xqSz64nOo2Ia29SA6B2KNCsyCbVmaQ==} engines: {node: '>=18'} @@ -2252,6 +2269,14 @@ packages: '@swc/types@0.1.13': resolution: {integrity: sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==} + '@tanstack/query-core@5.59.13': + resolution: {integrity: sha512-Oou0bBu/P8+oYjXsJQ11j+gcpLAMpqW42UlokQYEz4dE7+hOtVO9rVuolJKgEccqzvyFzqX4/zZWY+R/v1wVsQ==} + + '@tanstack/react-query@5.59.15': + resolution: {integrity: sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -2631,6 +2656,9 @@ packages: ajv@8.13.0: resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + alge@0.8.1: + resolution: {integrity: sha512-kiV9nTt+XIauAXsowVygDxMZLplZxDWt0W8plE/nB32/V2ziM/P/TxDbSVK7FYIUt2Xo16h3/htDh199LNPCKQ==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -3674,6 +3702,22 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql-request@7.1.0: + resolution: {integrity: sha512-Ouu/lYVFhARS1aXeZoVJWnGT6grFJXTLwXJuK4mUGGRo0EUk1JkyYp43mdGmRgUVezpRm6V5Sq3t8jBDQcajng==} + hasBin: true + peerDependencies: + '@dprint/formatter': ^0.3.0 + '@dprint/typescript': ^0.91.1 + dprint: ^0.46.2 + graphql: 14 - 16 + peerDependenciesMeta: + '@dprint/formatter': + optional: true + '@dprint/typescript': + optional: true + dprint: + optional: true + graphql@16.9.0: resolution: {integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -4231,12 +4275,21 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + lodash.ismatch@4.4.0: + resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4945,6 +4998,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + recast@0.23.9: resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==} engines: {node: '>= 4'} @@ -4976,6 +5033,9 @@ packages: resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==} engines: {node: '>=4'} + remeda@1.61.0: + resolution: {integrity: sha512-caKfSz9rDeSKBQQnlJnVW3mbVdFgxgGWQKq1XlFokqjf+hQD5gxutLGTTY2A/x24UxVyJe9gH5fAkFI63ULw4A==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5195,6 +5255,10 @@ packages: resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} engines: {node: '>=12.20'} + string-length@6.0.0: + resolution: {integrity: sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==} + engines: {node: '>=16'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5357,6 +5421,9 @@ packages: ts-pattern@5.5.0: resolution: {integrity: sha512-jqbIpTsa/KKTJYWgPNsFNbLVpwCgzXfFJ1ukNn4I8hMwyQzHMJnk/BqWzggB0xpkILuKzaO/aMYhS0SkaJyKXg==} + ts-toolbelt@9.6.0: + resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -6207,6 +6274,10 @@ snapshots: '@fontsource/dm-mono@5.1.0': {} + '@graphql-typed-document-node/core@3.2.0(graphql@16.9.0)': + dependencies: + graphql: 16.9.0 + '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -6511,6 +6582,24 @@ snapshots: '@microsoft/tsdoc@0.15.0': {} + '@molt/command@0.9.0': + dependencies: + '@molt/types': 0.2.0 + alge: 0.8.1 + chalk: 5.3.0 + lodash.camelcase: 4.3.0 + lodash.snakecase: 4.1.1 + readline-sync: 1.4.10 + string-length: 6.0.0 + strip-ansi: 7.1.0 + ts-toolbelt: 9.6.0 + type-fest: 4.26.1 + zod: 3.23.8 + + '@molt/types@0.2.0': + dependencies: + ts-toolbelt: 9.6.0 + '@mswjs/interceptors@0.35.9': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -7879,6 +7968,13 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tanstack/query-core@5.59.13': {} + + '@tanstack/react-query@5.59.15(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.59.13 + react: 18.3.1 + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.25.7 @@ -8345,6 +8441,13 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 + alge@0.8.1: + dependencies: + lodash.ismatch: 4.4.0 + remeda: 1.61.0 + ts-toolbelt: 9.6.0 + zod: 3.23.8 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -9439,6 +9542,13 @@ snapshots: graphemer@1.4.0: {} + graphql-request@7.1.0(graphql@16.9.0): + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) + '@molt/command': 0.9.0 + graphql: 16.9.0 + zod: 3.23.8 + graphql@16.9.0: {} has-flag@3.0.0: {} @@ -10205,10 +10315,16 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.flattendeep@4.4.0: {} + lodash.ismatch@4.4.0: {} + lodash.merge@4.6.2: {} + lodash.snakecase@4.1.1: {} + lodash@4.17.21: {} log-update@6.1.0: @@ -10842,6 +10958,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readline-sync@1.4.10: {} + recast@0.23.9: dependencies: ast-types: 0.16.1 @@ -10895,6 +11013,8 @@ snapshots: dependencies: es6-error: 4.1.1 + remeda@1.61.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -11132,6 +11252,10 @@ snapshots: char-regex: 2.0.1 strip-ansi: 7.1.0 + string-length@6.0.0: + dependencies: + strip-ansi: 7.1.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -11298,6 +11422,8 @@ snapshots: ts-pattern@5.5.0: {} + ts-toolbelt@9.6.0: {} + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 diff --git a/src/features/project/components/ProjectCard.mdx b/src/features/project/components/ProjectCard.mdx new file mode 100644 index 00000000..4849a7ac --- /dev/null +++ b/src/features/project/components/ProjectCard.mdx @@ -0,0 +1,25 @@ +import { Meta, Title, Primary, Stories, Controls, Story } from "@storybook/blocks"; +import * as ProjectCardStories from "./ProjectCard.stories"; +import { ProjectCard } from "./ProjectCard"; + +# ProjectCard + + + +The ProjectCard component renders a Gitcoin Project in a card Format. These can be used to share on social media or to render in a list. + +This component provides a Loading and an Error State. + +This component natively supports a Tanstack Query for a single Project, and will appropriately display Loading, Error, and Success states. + +# Example + + + +# Controls + + + +# Other variations + + diff --git a/src/features/project/components/ProjectCard.stories.tsx b/src/features/project/components/ProjectCard.stories.tsx new file mode 100644 index 00000000..0d919abd --- /dev/null +++ b/src/features/project/components/ProjectCard.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, within } from "@storybook/test"; + +import { ProjectCard } from "./ProjectCard"; +import { QueryError, QueryPending, QuerySuccess, singleProject } from "../mocks/objects"; + +const meta: Meta = { + title: "Components/Project/ProjectCard", + component: ProjectCard, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + project: singleProject, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const avatar = await canvas.findByAltText("avatar"); + let src = avatar.getAttribute("src"); + expect(src).toContain(singleProject.metadata.logoImg); + + const banner = await canvas.findByAltText("banner"); + src = banner.getAttribute("src"); + + expect(src).toContain(singleProject.metadata.bannerImg); + }, +}; + +export const Undefined: Story = { + args: { + project: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const card = canvas.getByRole("presentation"); + expect(card).toBeVisible(); + + const avatarElement = canvas.queryAllByAltText("avatar"); + expect(avatarElement).toHaveLength(0); + + const bannerElement = canvas.queryAllByAltText("banner"); + expect(bannerElement).toHaveLength(0); + }, +}; + +export const SuccessQuery: Story = { + args: { + queryResult: QuerySuccess, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const avatar = await canvas.findByAltText("avatar"); + let src = avatar.getAttribute("src"); + expect(src).toContain(singleProject.metadata.logoImg); + + const banner = await canvas.findByAltText("banner"); + src = banner.getAttribute("src"); + + expect(src).toContain(singleProject.metadata.bannerImg); + }, +}; + +export const PendingQuery: Story = { + args: { + queryResult: QueryPending, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const card = canvas.getByRole("presentation"); + expect(card).toBeVisible(); + + const avatarElement = canvas.queryAllByAltText("avatar"); + expect(avatarElement).toHaveLength(0); + + const bannerElement = canvas.queryAllByAltText("banner"); + expect(bannerElement).toHaveLength(0); + }, +}; + +export const ErroryQuery: Story = { + args: { + queryResult: QueryError, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const avatarElement = canvas.queryByAltText("avatar"); + expect(avatarElement).toBeNull(); + + const bannerElement = canvas.queryByAltText("banner"); + expect(bannerElement).toBeNull(); + }, +}; diff --git a/src/features/project/components/ProjectCard.tsx b/src/features/project/components/ProjectCard.tsx new file mode 100644 index 00000000..873dddaa --- /dev/null +++ b/src/features/project/components/ProjectCard.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Card, CardContent } from "@/ui-shadcn/card"; +import { match, P } from "ts-pattern"; +import { UseQueryResult } from "@tanstack/react-query"; + +import { Project } from "@/features/project/types/types"; +import { BannerImage } from "@/primitives/BannerImage"; +import { Avatar } from "@/primitives/Avatar"; +import { Skeleton } from "@/ui-shadcn/skeleton"; + +interface ProjectCardProps { + project?: Project | undefined; + queryResult?: UseQueryResult | undefined; +} + +interface ErrorCardProps { + error?: Error | null | undefined; +} + +export function ProjectCard({ project, queryResult }: ProjectCardProps) { + return ( + match({ project, queryResult }) + // everything is nullish + .with({ project: P.nullish, queryResult: P.nullish }, () => ) + // Project is explicitly passed in + .with({ project: P.any, queryResult: P.nullish }, () => ) + // TanStack Query result is passed in and it's an error + .with({ project: P.nullish, queryResult: { status: "error" } }, (match) => ( + + )) + // TanStack Query result is passed in and it's pending + .with({ project: P.nullish, queryResult: { status: "pending" } }, () => ) + // TanStack Query result is passed in and it's a success + .with({ project: P.nullish, queryResult: { status: "success" } }, (match) => ( + + )) + .otherwise(() => ) + ); +} + +function LoadingCard() { + return ( + + + + + + + + + + ); +} + +export function DataCard({ project }: ProjectCardProps) { + return ( + + + + + + + + + + {project?.metadata?.title} + + + {project?.metadata?.description} + + + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function ErrorCard({ error }: ErrorCardProps) { + return <>>; +} diff --git a/src/features/project/mocks/objects.ts b/src/features/project/mocks/objects.ts new file mode 100644 index 00000000..867f0a5f --- /dev/null +++ b/src/features/project/mocks/objects.ts @@ -0,0 +1,99 @@ +import { Project } from "../types/types"; +import { UseQueryResult } from "@tanstack/react-query"; + +export const singleProject: Project = { + id: "0x00065ad5b4ac5b42ac82c60ac9e939505f7996e95b6181919a5353fc50e6b664", + metadata: { + title: "Gitcoin Grants Stack", + logoImg: "QmVSEo7Q1NFok7AT3vqD55EoThBgujoF1KXhiph9T9MNTr", + bannerImg: "QmXE6wP4Zsqp6VdNtXjv2EwqJpCTcBZfZNdSKSbjzEKKtn", + description: + "Gitcoin Grants Stack is a protocol-enabled solution that enables any community to easily create, manage and grow a grants program. From deployment and application management, to funds allocation, Grants Stack takes the hassle out of running a grants program.", + projectGithub: "", + projectTwitter: "", + }, +}; + +export const QueryPending: UseQueryResult = { + status: "pending" as const, + data: undefined, + error: null, + isLoading: false, + isFetching: false, + isPending: true, + isSuccess: false, + isError: false, + isLoadingError: false, + isRefetchError: false, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isRefetching: false, + isStale: false, + isFetchedAfterMount: true, + isInitialLoading: false, + isPaused: false, + isPlaceholderData: false, + fetchStatus: "idle" as const, + refetch: () => Promise.resolve(QueryPending), + promise: Promise.resolve(singleProject), +}; + +export const QueryError: UseQueryResult = { + status: "error" as const, + data: undefined, + error: new Error("uh oh"), + isLoading: false, + isFetching: false, + isPending: false, + isSuccess: false, + isError: true, + isLoadingError: true, + isRefetchError: false, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isRefetching: false, + isStale: false, + isFetchedAfterMount: true, + isInitialLoading: false, + isPaused: false, + isPlaceholderData: false, + fetchStatus: "idle" as const, + refetch: () => Promise.resolve(QueryPending), + promise: Promise.resolve(singleProject), +}; + +export const QuerySuccess: UseQueryResult = { + status: "success" as const, + data: singleProject, + error: null, + isLoading: false, + isFetching: false, + isPending: false, + isSuccess: true, + isError: false, + isLoadingError: false, + isRefetchError: false, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isRefetching: false, + isStale: false, + isFetchedAfterMount: true, + isInitialLoading: false, + isPaused: false, + isPlaceholderData: false, + fetchStatus: "idle" as const, + refetch: () => Promise.resolve(QueryPending), + promise: Promise.resolve(singleProject), +}; diff --git a/src/features/project/types/types.ts b/src/features/project/types/types.ts new file mode 100644 index 00000000..4f31ffa0 --- /dev/null +++ b/src/features/project/types/types.ts @@ -0,0 +1,19 @@ +export interface Project { + id: string; + name?: string; + description?: string; + avatarUrl?: string; + bannerUrl?: string; + anchorAddress?: string; + chainId?: number; + metadata: ProjectMetadata; +} + +export interface ProjectMetadata { + title: string | undefined; + logoImg: string | undefined; + bannerImg: string | undefined; + description: string | undefined; + projectGithub: string | undefined; + projectTwitter: string | undefined; +} diff --git a/src/primitives/Avatar.tsx b/src/primitives/Avatar.tsx index 0cd8c7e1..a3e77fda 100644 --- a/src/primitives/Avatar.tsx +++ b/src/primitives/Avatar.tsx @@ -51,10 +51,10 @@ export const Avatar = ({ return ( - + {fallback} ); diff --git a/src/primitives/BannerImage.mdx b/src/primitives/BannerImage.mdx new file mode 100644 index 00000000..907d4f59 --- /dev/null +++ b/src/primitives/BannerImage.mdx @@ -0,0 +1,39 @@ +import { Meta, Title, Primary, Stories, Controls, Story } from "@storybook/blocks"; +import * as BannerImageStories from "./BannerImage.stories"; +import { BannerImage } from "./BannerImage"; + +# BannerImage + + + +The BannerImage component displays a 3:1 image which can be used to render Banner Images. +The Width of the image can be explicitly set in `px`, or it will auto-scale to fit the width of it's parent. + +## Image Source + +This component supports multiple data sources, with an explicit priority ordering if multiple are used + +First, the component will attempt to load an IPFS CID, if that doesn't exist it will attempt to load an image URL. + +If both of those don't exist, it will render the default Logo. + +# Priority of Sources (in order) + +- Nothing is passed in -> Default Logo +- IPFS CID and an empty or null URL -> ipfsBaseURL + ipfsCid +- URL and an empty or null IPFS CID -> URL +- both an IPFS CID and a URL -> ipfsBaseURL + ipfsCID + +# Example + +## With Image + + + +# Controls + + + +# Other variations + + diff --git a/src/primitives/BannerImage.stories.tsx b/src/primitives/BannerImage.stories.tsx new file mode 100644 index 00000000..c2b9b109 --- /dev/null +++ b/src/primitives/BannerImage.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, within } from "@storybook/test"; + +import { BannerImage } from "./BannerImage"; + +const meta: Meta = { + component: BannerImage, +}; + +export default meta; +type Story = StoryObj; + +const gitcoinBannerCID = "QmXE6wP4Zsqp6VdNtXjv2EwqJpCTcBZfZNdSKSbjzEKKtn"; + +const gitcoinBannerURL = "https://ipfs.io/ipfs/QmXE6wP4Zsqp6VdNtXjv2EwqJpCTcBZfZNdSKSbjzEKKtn"; + +export const sourceFromIPFS: Story = { + args: { + ipfsCID: gitcoinBannerCID, + size: 350, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const image = await canvas.findByAltText("banner"); + const src = image.getAttribute("src"); + expect(src).toContain(gitcoinBannerCID); + }, +}; + +export const sourceFromURL: Story = { + args: { + url: gitcoinBannerURL, + size: 350, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const image = await canvas.findByAltText("banner"); + expect(image).toHaveAttribute("src", gitcoinBannerURL); + }, +}; + +export const MissingEverything: Story = { + args: { + size: 350, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const image = await canvas.findByAltText("banner"); + const src = image.getAttribute("src"); + expect(src).toContain("default"); + }, +}; + +export const AllProvided: Story = { + args: { + url: gitcoinBannerURL, + ipfsCID: gitcoinBannerCID, + size: 350, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const image = await canvas.findByAltText("banner"); + const src = image.getAttribute("src"); + expect(src).toContain(gitcoinBannerCID); + }, +}; + +export const Big: Story = { + args: { + ipfsCID: gitcoinBannerCID, + size: 1000, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const image = await canvas.findByAltText("banner"); + expect(image).toHaveStyle("width: 1000px"); + }, +}; + +export const Small: Story = { + args: { + ipfsCID: gitcoinBannerCID, + size: 100, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const image = await canvas.findByAltText("banner"); + expect(image).toHaveStyle("width: 100px"); + }, +}; diff --git a/src/primitives/BannerImage.tsx b/src/primitives/BannerImage.tsx new file mode 100644 index 00000000..5b382429 --- /dev/null +++ b/src/primitives/BannerImage.tsx @@ -0,0 +1,46 @@ +import React, { useMemo } from "react"; +import DefaultBanner from "@/assets/default_banner.jpg"; +import { match, P } from "ts-pattern"; +import clsx from "clsx"; + +interface BannerImageProps { + ipfsCID?: string; + url?: string; + size?: number; + ipfsBaseURL?: string; + defaultImage?: string; +} + +export const BannerImage = ({ + ipfsCID, + url, + size = 0, + ipfsBaseURL = "https://ipfs.io/ipfs/", + defaultImage = DefaultBanner, +}: BannerImageProps) => { + const imageURL = useMemo(() => { + return match({ ipfsCID, url }) + .with({ ipfsCID: P.nullish, url: P.nullish }, () => defaultImage) + .with({ ipfsCID, url: P.string.length(0) }, ({ ipfsCID }) => `${ipfsBaseURL}${ipfsCID}`) + .with({ ipfsCID, url: P.nullish }, ({ ipfsCID }) => `${ipfsBaseURL}${ipfsCID}`) + .with({ ipfsCID: P.string.length(0), url }, ({ url }) => url) + .with({ ipfsCID: P.nullish, url }, ({ url }) => url) + .with( + { ipfsCID: P.string.minLength(1), url: P.string.minLength(1) }, + ({ ipfsCID }) => `${ipfsBaseURL}${ipfsCID}`, + ) + + .otherwise(() => defaultImage); + }, [ipfsCID, url, ipfsBaseURL]); + + const sizeStyle = useMemo(() => { + if (!size || size <= 0) { + return undefined; + } + return { width: `${size}px` }; + }, [size]); + + return ( + + ); +}; diff --git a/src/tokens/colors.ts b/src/tokens/colors.ts index ae999476..be6f5fad 100644 --- a/src/tokens/colors.ts +++ b/src/tokens/colors.ts @@ -9,4 +9,3 @@ export const colors: Colors = { white: "#ffffff", black: "#000000", }; - diff --git a/src/ui-shadcn/skeleton.tsx b/src/ui-shadcn/skeleton.tsx index 26cd4b55..e9c9e10b 100644 --- a/src/ui-shadcn/skeleton.tsx +++ b/src/ui-shadcn/skeleton.tsx @@ -6,7 +6,7 @@ function Skeleton({ }: React.HTMLAttributes) { return ( ) diff --git a/tailwind.config.ts b/tailwind.config.ts index 99a9949e..2483499e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -39,6 +39,9 @@ export default { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", }, + aspectRatio: { + "3/1": "3 / 1", + }, }, }, plugins: [tailwindcssAnimate],
+ {project?.metadata?.description} +