From 018874ea5b6766dec7c0045b3d447a93af83025b Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:00:58 +0100 Subject: [PATCH 01/18] =?UTF-8?q?chore:=20=F0=9F=94=A7=20new=20publish=20w?= =?UTF-8?q?orkflow=20also=20for=20tchibo=20branch=20(#809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd921b1b..f4355766 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,7 +26,7 @@ name: 🐳 Docker image on: push: - branches: ["main", "dtec"] + branches: ["main", "dtec", "tchibo"] env: REGISTRY: ghcr.io @@ -37,4 +37,4 @@ jobs: uses: interfacerproject/workflows/.github/workflows/publish-ghcr.yml@main secrets: inherit with: - image_name: ${{ github.ref == 'refs/heads/dtec' && format('{0}-dtec', github.repository) || github.repository }} + image_name: ${{ github.ref == 'refs/heads/dtec' && format('{0}-dtec', github.repository) || github.ref == 'refs/heads/tchibo' && format('{0}-tchibo', github.repository) || github.repository }} From 2b9aba69ed61fa6219afd12081a54d4752bc5c98 Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:09:25 +0100 Subject: [PATCH 02/18] =?UTF-8?q?style:=20=F0=9F=8E=A8=20=20lighter=20mark?= =?UTF-8?q?down=20editor=20(#763)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Puria Nafisi Azizi --- components/brickroom/BrMdEditor.tsx | 2 ++ .../contribution/CreateContributionForm.tsx | 2 +- .../create/project/steps/MainStep.tsx | 4 +-- lib/MdParser.ts | 29 +------------------ public/locales/de/createProjectProps.json | 2 +- 5 files changed, 7 insertions(+), 32 deletions(-) diff --git a/components/brickroom/BrMdEditor.tsx b/components/brickroom/BrMdEditor.tsx index d4882f7f..195ba846 100644 --- a/components/brickroom/BrMdEditor.tsx +++ b/components/brickroom/BrMdEditor.tsx @@ -48,6 +48,8 @@ export default function BrMdEditor(props: BrMdEditorProps) {
MdParser.render(text)} onChange={onChange} diff --git a/components/partials/create/contribution/CreateContributionForm.tsx b/components/partials/create/contribution/CreateContributionForm.tsx index b168feea..a8ff2f8a 100644 --- a/components/partials/create/contribution/CreateContributionForm.tsx +++ b/components/partials/create/contribution/CreateContributionForm.tsx @@ -167,7 +167,7 @@ const CreateContributionForm = (props: Props) => { editorClass="h-60" value={useWatch({ control, name: "description" })} helpText={`${t("In this markdown editor, the right box shows a preview")}. ${t( - "Type up to 2048 characters" + "Type up to 6000 characters" )}.`} onChange={({ text }) => { setValue("description", text, { shouldValidate: false, shouldDirty: false, shouldTouch: false }); diff --git a/components/partials/create/project/steps/MainStep.tsx b/components/partials/create/project/steps/MainStep.tsx index bc90df88..19e10cea 100644 --- a/components/partials/create/project/steps/MainStep.tsx +++ b/components/partials/create/project/steps/MainStep.tsx @@ -34,9 +34,9 @@ export const mainStepSchema = () => .string() .test( "size-check", - "Description length must be less than 2048 characters. If it's longer, please use the 'project data' field to reference it.", + "Description length must be less than 6000 characters. If it's longer, please use the 'project data' field to reference it.", value => { - if (value) return new Blob([value]).size < 2048; + if (value) return new Blob([value]).size < 6000; else return true; } ), diff --git a/lib/MdParser.ts b/lib/MdParser.ts index ad08712c..58721dba 100644 --- a/lib/MdParser.ts +++ b/lib/MdParser.ts @@ -15,37 +15,10 @@ // along with this program. If not, see . import MarkdownIt from "markdown-it"; -// @ts-ignore -import emoji from "markdown-it-emoji"; -// @ts-ignore -import subscript from "markdown-it-sub"; -// @ts-ignore -import superscript from "markdown-it-sup"; -// @ts-ignore -import footnote from "markdown-it-footnote"; -// @ts-ignore -import deflist from "markdown-it-deflist"; -// @ts-ignore -import abbreviation from "markdown-it-abbr"; -// @ts-ignore -import insert from "markdown-it-ins"; -// @ts-ignore -import mark from "markdown-it-mark"; -// @ts-ignore -import tasklists from "markdown-it-task-lists"; const MdParser = new MarkdownIt({ html: true, linkify: true, typographer: true, -}) - .use(emoji) - .use(subscript) - .use(superscript) - .use(footnote) - .use(deflist) - .use(abbreviation) - .use(insert) - .use(mark) - .use(tasklists); +}); export default MdParser; diff --git a/public/locales/de/createProjectProps.json b/public/locales/de/createProjectProps.json index 296d12df..d77a9706 100644 --- a/public/locales/de/createProjectProps.json +++ b/public/locales/de/createProjectProps.json @@ -32,7 +32,7 @@ "Tags": "Tags", "The name of the place where the project is stored": "Der Name des Ortes, an dem das Project gelagert wird", "Type to search for a user": "Typ für die Suche nach einem Benutzer", - "Type up to 2048 characters": "Geben Sie bis zu 2048 Zeichen ein", + "Type up to 6000 characters": "Geben Sie bis zu 6000 Zeichen ein", "Upload up to 10 pictures": "Bis zu 10 Bilder hochladen", "Working name of the project, visible to the whole community": "Name des Projects, sichtbar für die gesamte Community", "go to the project": "zum Project gehen", From 73f66805f92a787c5e7afff0fe3823c53b3e576d Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:11:46 +0100 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=E2=9C=A8=20links=20to=20projects?= =?UTF-8?q?=20now=20lead=20to=20new=20products=20page=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Puria Nafisi Azizi --- .beads/issues.jsonl | 1 + components/Footer.tsx | 2 +- components/ProjectTypeChip.tsx | 2 +- components/Sidebar.tsx | 2 +- components/brickroom/BrTag.tsx | 2 +- components/partials/project/[id]/ProjectHeader.tsx | 4 ++-- pages/404.tsx | 2 +- pages/index.tsx | 6 +++--- tests/render_ru.spec.ts | 4 ++-- 9 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f1d801a4..f4b0a251 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -56,6 +56,7 @@ {"id":"interfacer-gui-yig.11","title":"Apply Figma design tokens and styling","description":"Apply design system from Figma: IBM Plex Sans font family, Space Grotesk for headings, color tokens (Primary #036A53, Highlight #F1BD4D, Surface #FFFFFF, Warning #F1BD4D, Borders/Subdued #C9CCCF). Use Shadow/sm for cards. Ensure consistent spacing and typography throughout.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-10T13:33:30.374897+01:00","updated_at":"2025-12-10T13:33:30.374897+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.11","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2025-12-10T13:33:30.376705+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yig.12","title":"Add loading states and error handling","description":"Implement skeleton loaders for product cards during data fetch. Add error states with retry functionality. Handle empty states with helpful messaging when no products match filters. Use existing EmptyState component where applicable.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-10T13:33:30.432699+01:00","updated_at":"2025-12-11T17:24:02.588937+01:00","closed_at":"2025-12-11T17:24:02.588937+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.12","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2025-12-10T13:33:30.434848+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yig.13","title":"Test products page functionality and accessibility","description":"Write tests for filter interactions, search, sorting, pagination. Test keyboard navigation and screen reader support. Verify all filters work correctly in combination. Check performance with large datasets.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-10T13:33:30.491983+01:00","updated_at":"2025-12-10T13:33:30.491983+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.13","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2025-12-10T13:33:30.493129+01:00","created_by":"daemon"}]} +{"id":"interfacer-gui-yig.14","title":"Replace /projects links with /products","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T10:37:57.035017+01:00","updated_at":"2026-02-04T10:40:12.808469+01:00","closed_at":"2026-02-04T10:40:12.808469+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.14","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2026-02-04T10:37:57.037817+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yig.2","title":"Implement header section with title, description and stats","description":"Create header component showing 'Browse OSH Designs' title, subtitle description, and three stat cards (Total Projects: 18429, Projects available: 2847, Manufacturers: 512). Use Figma design tokens: Head/H1 font, Primary color #036A53, Surface #FFFFFF. Stats should be fetched from GraphQL or calculated from data.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-10T13:32:03.927837+01:00","updated_at":"2025-12-10T13:42:03.187066+01:00","closed_at":"2025-12-10T13:42:03.187066+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.2","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2025-12-10T13:32:03.929336+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yig.3","title":"Build enhanced left sidebar filter component","description":"Create comprehensive ProductsFilters component with collapsible sections for: Manufacturability (3 options), Machines Needed (checkboxes), Materials Needed (checkboxes), Location (search), Categories \u0026 Tags (search/select), Power Compatibility, Power Requirement, Replicability. Each filter should update URL query params and trigger data refetch.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-10T13:32:03.982255+01:00","updated_at":"2025-12-10T13:47:32.935884+01:00","closed_at":"2025-12-10T13:47:32.935884+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.3","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2025-12-10T13:32:03.983243+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yig.4","title":"Create product card grid component","description":"Build ProductCardGrid component displaying products in responsive grid layout. Each card shows: product image, title, description, badges (Can be Manufactured, etc), machine icons, license info, and like count. Use Shadow/sm for card elevation. Support hover states.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-10T13:32:04.04054+01:00","updated_at":"2025-12-10T14:10:41.267679+01:00","closed_at":"2025-12-10T14:10:41.267679+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.4","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2025-12-10T13:32:04.042544+01:00","created_by":"daemon"}]} diff --git a/components/Footer.tsx b/components/Footer.tsx index 36110be4..b7a3542c 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -23,7 +23,7 @@ const Footer = () => { {t("Projects")} - + {t("All projects")} diff --git a/components/ProjectTypeChip.tsx b/components/ProjectTypeChip.tsx index ec4f0e35..703aa3ef 100644 --- a/components/ProjectTypeChip.tsx +++ b/components/ProjectTypeChip.tsx @@ -28,7 +28,7 @@ export default function ProjectTypeChip(props: Props) { const { project, projectType, introduction = false, link = true } = props; const name = (project?.conformsTo?.name as ProjectType) || projectType || ProjectType.DESIGN; - const href = `/projects?conformsTo=${project?.conformsTo?.id}`; + const href = `/products?conformsTo=${project?.conformsTo?.id}`; const renderProps = ProjectTypeRenderProps[name]; diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index fcb6e339..5d77cd60 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -66,7 +66,7 @@ function Sidebar() { // Dropdown -> Projects latestProjects: { text: t("Projects"), - link: "/projects", + link: "/products", }, resources: { text: t("Import from LOSH"), diff --git a/components/brickroom/BrTag.tsx b/components/brickroom/BrTag.tsx index 24e67fd3..78080306 100644 --- a/components/brickroom/BrTag.tsx +++ b/components/brickroom/BrTag.tsx @@ -7,7 +7,7 @@ export default function BrTag(props: { tag: string }) { const classes = classNames("py-1 px-2", "bg-primary/5 hover:bg-primary/20", "border-1 border-primary/20 rounded-md"); return ( - + {decodeURI(tag)} diff --git a/components/partials/project/[id]/ProjectHeader.tsx b/components/partials/project/[id]/ProjectHeader.tsx index 2e3ccb45..b9a9a06c 100644 --- a/components/partials/project/[id]/ProjectHeader.tsx +++ b/components/partials/project/[id]/ProjectHeader.tsx @@ -13,8 +13,8 @@ const ProjectHeader = ({ isResource }: { isResource?: boolean }) => { { name: project.conformsTo!.name, href: `/resources?conformTo=${project.conformsTo!.id}` }, ] : [ - { name: t("Projects"), href: "/projects" }, - { name: project.conformsTo!.name, href: `/projects?conformTo=${project.conformsTo!.id}` }, + { name: t("Projects"), href: "/products" }, + { name: project.conformsTo!.name, href: `/products?conformTo=${project.conformsTo!.id}` }, ]; return ( diff --git a/pages/404.tsx b/pages/404.tsx index 7afdb520..c5234bc2 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -40,7 +40,7 @@ const FourOhFour: NextPageWithLayout = () => { ) + "."}

- - + @@ -131,7 +131,7 @@ const Home: NextPageWithLayout = () => { {t("Log In")} - + @@ -143,7 +143,7 @@ const Home: NextPageWithLayout = () => { {t("Create a new project")} - + diff --git a/tests/render_ru.spec.ts b/tests/render_ru.spec.ts index 4758c7a7..14598058 100644 --- a/tests/render_ru.spec.ts +++ b/tests/render_ru.spec.ts @@ -37,8 +37,8 @@ test.describe("when user is logged in", () => { await expect(page.getByText(process.env.RESOURCE_ID!)).toBeVisible(); }); - test.skip("Should see /projects", async ({ page }) => { - await page.goto("/projects"); + test.skip("Should see /products", async ({ page }) => { + await page.goto("/products"); await expect(page.getByText("Latest projects")).toBeVisible(); await expect(page.getByRole("link", { name: "Create a new project" })).toBeVisible(); // await expect(page.getByRole("link", { name: "Report a bug" })).toBeVisible(); From 2e8bbc4b382cebcc7a9a94aa99ddd9f4f84b1e5f Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:18:21 +0100 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20=E2=9C=A8=20improve=20UX=20for=20?= =?UTF-8?q?unauthenticated=20users=20(#814)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make 404 page public (accessible without login) - Hide Contributors tab when not authenticated - Hide user info sections (contributors, project by) for logged-out users - Skip GraphQL user queries when not authenticated - Hide contributions count/button when 0 - Show DPP QR-code only for products with DPP metadata - Fix BrBreadcrumb and GeneralCard license footer Related to epic interfacer-gui-m4c Closes: interfacer-gui-m4c.1, interfacer-gui-m4c.2, interfacer-gui-m4c.3, interfacer-gui-m4c.4, interfacer-gui-m4c.5 --------- Co-authored-by: Puria Nafisi Azizi --- components/GeneralCard.tsx | 15 +++-- components/brickroom/BrBreadcrumb.tsx | 6 +- components/brickroom/BrUserAvatar.tsx | 4 +- .../partials/project/[id]/ProjectTabs.tsx | 43 ++++++------- .../[id]/sidebar/ContributionsCard.tsx | 60 ++++++++++++------- .../project/[id]/sidebar/SocialCard.tsx | 16 ++--- pages/404.tsx | 2 + 7 files changed, 90 insertions(+), 56 deletions(-) diff --git a/components/GeneralCard.tsx b/components/GeneralCard.tsx index 76cf1d7a..1c371890 100644 --- a/components/GeneralCard.tsx +++ b/components/GeneralCard.tsx @@ -135,13 +135,18 @@ const LicenseFooter = () => { const { project } = useCardProject(); // Check multiple possible license locations - const license = project.license || project.metadata?.license; + const license = project.license; + const metadataLicenses = project.metadata?.licenses; // Don't render if no license available - if (!license) return null; - - const licenseText = `${t("LICENSE")}: ${license}`; - + if (!license && !metadataLicenses) return null; + + const licenseText = + license == undefined + ? `${t("LICENSE")}: ${license}` + : metadataLicenses + ?.map((l: { scope: string; licenseId: string }) => `${t("LICENSE")} (${l.scope}): ${l.licenseId}`) + .join(", "); return (
diff --git a/components/brickroom/BrBreadcrumb.tsx b/components/brickroom/BrBreadcrumb.tsx index ae1b8801..3e6890a3 100644 --- a/components/brickroom/BrBreadcrumb.tsx +++ b/components/brickroom/BrBreadcrumb.tsx @@ -24,12 +24,14 @@ type Crumb = { const BrBreadcrumb = ({ crumbs }: { crumbs: Crumb[] }) => { return (
-
    +
      {crumbs.map((crumb, i) => { return (
    • - {crumb.name} + + {crumb.name} {i < crumbs.length - 1 ? "/" : ""} +
    • ); diff --git a/components/brickroom/BrUserAvatar.tsx b/components/brickroom/BrUserAvatar.tsx index 9f94120b..f40a117e 100644 --- a/components/brickroom/BrUserAvatar.tsx +++ b/components/brickroom/BrUserAvatar.tsx @@ -1,5 +1,6 @@ import { gql, useQuery } from "@apollo/client"; import Avatar from "boring-avatars"; +import { useAuth } from "hooks/useAuth"; import { getUserImage } from "lib/resourceImages"; import { GetUserImagesQuery, GetUserImagesQueryVariables } from "lib/types"; import { PersonWithFileEssential } from "lib/types/extensions"; @@ -12,10 +13,11 @@ export interface Props { export default function BrUserAvatar(props: Props) { const { user, userId, size = "100%" } = props; + const { authenticated } = useAuth(); const { data, loading } = useQuery(GET_USER_IMAGES, { variables: { userId: userId! }, - skip: !userId, + skip: !userId || !authenticated, }); let u: Partial | null = null; diff --git a/components/partials/project/[id]/ProjectTabs.tsx b/components/partials/project/[id]/ProjectTabs.tsx index 268cf327..18d01f30 100644 --- a/components/partials/project/[id]/ProjectTabs.tsx +++ b/components/partials/project/[id]/ProjectTabs.tsx @@ -1,8 +1,9 @@ import { gql, useQuery } from "@apollo/client"; import { Stack, Tabs } from "@bbtgnn/polaris-interfacer"; -import { Cube, Events, ListBoxes, ParentChild, Purchase } from "@carbon/icons-react"; +import { Cube, Events, ListBoxes, Purchase } from "@carbon/icons-react"; import { useProject } from "components/layout/FetchProjectLayout"; import ProjectDetailOverview from "components/ProjectDetailOverview"; +import { useAuth } from "hooks/useAuth"; import { useTranslation } from "next-i18next"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; @@ -60,6 +61,7 @@ const ProjectTabs = () => { const { t } = useTranslation("common"); const { id } = router.query; const [pageLoaded, setPageLoaded] = useState(false); + const { authenticated } = useAuth(); // Query traceDpp to extract DPP service ULID const { data: traceDppData } = useQuery(QUERY_TRACE_DPP, { @@ -108,11 +110,11 @@ const ProjectTabs = () => { overview: !!project?.note, relationships: !!project?.metadata?.relations?.length, graph: true, - contributors: !!project.metadata?.contributors?.length, - contributions: !!project.metadata?.contributors?.length, + contributors: authenticated && !!project.metadata?.contributors?.length, + contributions: false, // Always hide contributions tab for now dpp: !!dppUlid, // Has DPP ULID }), - [project, dppUlid] + [project, dppUlid, authenticated] ); const allTabs = [ @@ -127,17 +129,18 @@ const ProjectTabs = () => { accessibilityLabel: t("Project overview"), panelID: "overview-content", }, - { - id: "relationships", - content: ( - - - {t("Included")} - - ), - accessibilityLabel: t("Relationship tree"), - panelID: "relationships-content", - }, + // Commented out until backend relations query is fixed + // { + // id: "relationships", + // content: ( + // + // + // {t("Included")} + // + // ), + // accessibilityLabel: t("Relationship tree"), + // panelID: "relationships-content", + // }, { id: "graph", content: ( @@ -219,10 +222,10 @@ const ProjectTabs = () => { {selected == 0 && } - {selected == 1 && } - {selected == 2 && } + {/* {selected == 1 && } */} + {selected == 1 && } - {selected == 3 && ( + {selected == 2 && ( { data={project.trace?.filter((t: any) => !!t.hasPointInTime)[0].hasPointInTime} /> )} - {selected == 4 && } - {selected == 5 && dppUlid && } + {selected == 3 && } + {selected == 4 && dppUlid && } ); }; diff --git a/components/partials/project/[id]/sidebar/ContributionsCard.tsx b/components/partials/project/[id]/sidebar/ContributionsCard.tsx index 4e46268b..3dc8f691 100644 --- a/components/partials/project/[id]/sidebar/ContributionsCard.tsx +++ b/components/partials/project/[id]/sidebar/ContributionsCard.tsx @@ -3,6 +3,7 @@ import { Button, Card, Stack, Text } from "@bbtgnn/polaris-interfacer"; import { ListBoxes, MagicWand } from "@carbon/icons-react"; import { useProject } from "components/layout/FetchProjectLayout"; import ProjectContributors from "components/ProjectContributors"; +import { useAuth } from "hooks/useAuth"; import { QUERY_RESOURCE_PROPOSAlS } from "lib/QueryAndMutation"; import { ResourceProposalsQuery, ResourceProposalsQueryVariables } from "lib/types"; import { useTranslation } from "next-i18next"; @@ -16,6 +17,7 @@ const ContributionsCard = () => { const { t } = useTranslation("common"); const router = useRouter(); const { id } = router.query; + const { authenticated } = useAuth(); const { data: contributions } = useQuery( QUERY_RESOURCE_PROPOSAlS, @@ -25,19 +27,41 @@ const ContributionsCard = () => { ); const url = window.location.protocol + "//" + window.location.host + `/project/${project.id}?tab=gc1dpp`; + const contributionsCount = contributions?.proposals.edges.length || 0; + const isProduct = project.conformsTo?.name === "Product"; + const hasDpp = !!project.metadata?.dpp; + return ( {t("Contributions")} - - {t("{{contributors}} contributors", { contributors: project.metadata?.contributors?.length || 0 })} - - {project.metadata?.contributors?.length && } - - {t("{{contributions}} contributions", { contributions: contributions?.proposals.edges.length })} - + {authenticated && ( + <> + + {t("{{contributors}} contributors", { contributors: project.metadata?.contributors?.length || 0 })} + + {project.metadata?.contributors?.length && } + + )} + {contributionsCount > 0 && ( + <> + + {t("{{contributions}} contributions", { contributions: contributionsCount })} + + + + )} - - - {t("DPP QR-Code")} - - + {isProduct && hasDpp && ( + <> + + {t("DPP QR-Code")} + + + + )} ); diff --git a/components/partials/project/[id]/sidebar/SocialCard.tsx b/components/partials/project/[id]/sidebar/SocialCard.tsx index a90748f3..e99509dd 100644 --- a/components/partials/project/[id]/sidebar/SocialCard.tsx +++ b/components/partials/project/[id]/sidebar/SocialCard.tsx @@ -10,7 +10,7 @@ import { useTranslation } from "next-i18next"; import { useState } from "react"; const SocialCard = () => { - const { user } = useAuth(); + const { user, authenticated } = useAuth(); const { project } = useProject(); const { t } = useTranslation("common"); const { getItem, setItem } = useStorage(); @@ -51,12 +51,14 @@ const SocialCard = () => {
)} -
- - {t("Project by:")} - - -
+ {authenticated && ( +
+ + {t("Project by:")} + + +
+ )}
diff --git a/pages/404.tsx b/pages/404.tsx index c5234bc2..b6409ca5 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -80,4 +80,6 @@ export async function getStaticProps({ locale }: any) { }; } +export const publicPage = true; + export default FourOhFour; From cfcd214bf53f4021a84df94a565c7511249f6f68 Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:19:09 +0100 Subject: [PATCH 05/18] =?UTF-8?q?fix:=20=F0=9F=90=9B=20project=20images=20?= =?UTF-8?q?editing=20(#817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: #770 --- pages/project/[id]/edit/images.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/project/[id]/edit/images.tsx b/pages/project/[id]/edit/images.tsx index 6b114230..16c3a743 100644 --- a/pages/project/[id]/edit/images.tsx +++ b/pages/project/[id]/edit/images.tsx @@ -46,7 +46,7 @@ const EditImages: NextPageWithLayout = () => { const schema = yup.object({ // @ts-ignore - images: imagesStepSchema, + images: imagesStepSchema(), }); const formMethods = useForm({ From cf564f7d50cb0b870b3173abf395a29fb757c214 Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:00:22 +0100 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20=F0=9F=90=9B=20qr=20code=20disappe?= =?UTF-8?q?ar=20for=20projects=20that=20have=20it=20and=20new=20tabs=20map?= =?UTF-8?q?ping=20for=20url=20params=20(#818)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/deletions.jsonl | 1 + .../partials/project/[id]/ProjectTabs.tsx | 14 +++--- .../[id]/sidebar/ContributionsCard.tsx | 50 +++++++++++++++++-- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/.beads/deletions.jsonl b/.beads/deletions.jsonl index 7590a326..e2289fd7 100644 --- a/.beads/deletions.jsonl +++ b/.beads/deletions.jsonl @@ -2,3 +2,4 @@ {"id":"interfacer-gui-9lv.13","ts":"2025-12-11T13:40:28.675257Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} {"id":"interfacer-gui-9lv.12","ts":"2025-12-11T13:40:28.681444Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} {"id":"interfacer-gui-9lv.14","ts":"2025-12-11T13:40:28.687578Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} +{"id":"interfacer-gui-yig.14","ts":"2026-02-06T15:05:42.2713Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} diff --git a/components/partials/project/[id]/ProjectTabs.tsx b/components/partials/project/[id]/ProjectTabs.tsx index 18d01f30..16b10649 100644 --- a/components/partials/project/[id]/ProjectTabs.tsx +++ b/components/partials/project/[id]/ProjectTabs.tsx @@ -83,13 +83,13 @@ const ProjectTabs = () => { // Map tab IDs to their indices for URL parameter handling const allTabsMap: Record = { overview: 0, - relationships: 1, - included: 1, - graph: 2, - contributors: 3, - contributions: 4, - dpp: 5, - gc1dpp: 5, + relationships: 0, + included: 0, + graph: 1, + contributors: 2, + contributions: 3, + dpp: 4, + gc1dpp: 4, }; // Mark page as loaded after client-side mount and check for tab parameter diff --git a/components/partials/project/[id]/sidebar/ContributionsCard.tsx b/components/partials/project/[id]/sidebar/ContributionsCard.tsx index 3dc8f691..f782117a 100644 --- a/components/partials/project/[id]/sidebar/ContributionsCard.tsx +++ b/components/partials/project/[id]/sidebar/ContributionsCard.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@apollo/client"; +import { gql, useQuery } from "@apollo/client"; import { Button, Card, Stack, Text } from "@bbtgnn/polaris-interfacer"; import { ListBoxes, MagicWand } from "@carbon/icons-react"; import { useProject } from "components/layout/FetchProjectLayout"; @@ -9,8 +9,42 @@ import { ResourceProposalsQuery, ResourceProposalsQueryVariables } from "lib/typ import { useTranslation } from "next-i18next"; import { useRouter } from "next/router"; import { useProjectTabs } from "pages/project/[id]"; +import { useMemo } from "react"; import QRCode from "react-qr-code"; +// Query to get traceDpp for extracting DPP service ULID +const QUERY_TRACE_DPP = gql` + query getTraceDpp($id: ID!) { + economicResource(id: $id) { + traceDpp + } + } +`; + +// Helper function to recursively find DPP service ULID from traceDpp tree +function extractDppServiceUlid(traceDpp: any[]): string | undefined { + let dppServiceUlid: string | undefined; + + function traverse(node: any) { + if (!node || dppServiceUlid) return; + + if (node.type === "EconomicResource" && node.node?.metadata?.dppServiceUlid) { + dppServiceUlid = node.node.metadata.dppServiceUlid; + return; + } + + if (node.children && Array.isArray(node.children)) { + node.children.forEach(traverse); + } + } + + if (Array.isArray(traceDpp)) { + traceDpp.forEach(traverse); + } + + return dppServiceUlid; +} + const ContributionsCard = () => { const { project } = useProject(); const { setSelected } = useProjectTabs(); @@ -25,11 +59,19 @@ const ContributionsCard = () => { variables: { id: id as string }, } ); + const { data: traceDppData } = useQuery(QUERY_TRACE_DPP, { + variables: { id: project?.id }, + skip: !project?.id, + }); const url = window.location.protocol + "//" + window.location.host + `/project/${project.id}?tab=gc1dpp`; + const dppServiceUlid = useMemo(() => { + if (!traceDppData?.economicResource?.traceDpp) return undefined; + return extractDppServiceUlid(traceDppData.economicResource.traceDpp); + }, [traceDppData]); + const dppUlid = dppServiceUlid || project.metadata?.dpp; + const showDppQr = project.conformsTo?.name === "Product" && !!dppUlid; const contributionsCount = contributions?.proposals.edges.length || 0; - const isProduct = project.conformsTo?.name === "Product"; - const hasDpp = !!project.metadata?.dpp; return ( @@ -72,7 +114,7 @@ const ContributionsCard = () => { > {t("Make a contribution")} - {isProduct && hasDpp && ( + {showDppQr && ( <> {t("DPP QR-Code")} From bb0db29fad8cb3376f94b9c1802038f72b99492e Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:35:57 +0100 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20recyclability?= =?UTF-8?q?=20and=20repairability=20filters=20(#821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recyclability: numeric percentage (0-100%) with monotonic range tags for stepped filtering via classifiedAs, same pattern as energy/CO2. Repairability: binary toggle with single 'repairability-available' tag in classifiedAs. Both filters are wired through: - Create flow (ProductFiltersStep) - Edit flow (specs.tsx) - Products sidebar filters (ProductsFilters) - Active filters bar chips (ProductsActiveFiltersBar) - Tag generation and normalization (tagging.ts, useProjectCRUD) Closes interfacer-gui-zsv --- .beads/.local_version | 2 +- .beads/issues.jsonl | 8 ++ .beads/last-touched | 1 + components/ProductsActiveFiltersBar.tsx | 50 ++++++- components/ProductsFilters.tsx | 132 ++++++++++++++++++ .../project/steps/ProductFiltersStep.tsx | 46 ++++++ hooks/useProjectCRUD.ts | 5 + lib/tagging.ts | 20 ++- pages/project/[id]/edit/specs.tsx | 14 ++ 9 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 .beads/last-touched diff --git a/.beads/.local_version b/.beads/.local_version index ae6dd4e2..5c4503b7 100644 --- a/.beads/.local_version +++ b/.beads/.local_version @@ -1 +1 @@ -0.29.0 +0.49.0 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f4b0a251..2043c3dc 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -50,6 +50,13 @@ {"id":"interfacer-gui-kjq.2","title":"Add materials selection + consume events + material-* tags","description":"Implement materials consumed flow analogous to machines, with backend-precreated material EconomicResources.\n\nUI:\n- Add materials picker in CreateProjectForm (or equivalent) to select one or more materials to be consumed.\n\nData:\n- For each selected material, create an EconomicEvent with action=consume linking the project/process to the material resource.\n- Also persist tags material-\u003cslug\u003e on the product resource.\n\nAcceptance:\n- Selecting PLA and ABS creates 2 consume events and adds tags [material-pla, material-abs].\n- If backend provides no materials list, UI shows an empty/disabled state (no crash).","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-15T16:19:32.6473+01:00","updated_at":"2025-12-15T16:40:43.628568+01:00","closed_at":"2025-12-15T16:40:43.628568+01:00","dependencies":[{"issue_id":"interfacer-gui-kjq.2","depends_on_id":"interfacer-gui-kjq","type":"parent-child","created_at":"2025-12-15T16:19:32.648913+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-kjq.3","title":"(Later) Remove tag/category selectors from creation form","description":"Deferred cleanup: remove every tag/category selector from the project/product creation flow.\n\nRationale:\n- Tags will be derived automatically only for machine/material requirements (machine-* and material-*).\n- Categories/tags are not user-entered during creation in this UX.\n\nAcceptance:\n- No tag/category selector UI remains in the creation form.\n- Save still auto-adds derived machine-* and material-* tags.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-15T16:19:44.897714+01:00","updated_at":"2025-12-15T17:16:01.568884+01:00","closed_at":"2025-12-15T17:16:01.568884+01:00","dependencies":[{"issue_id":"interfacer-gui-kjq.3","depends_on_id":"interfacer-gui-kjq","type":"parent-child","created_at":"2025-12-15T16:19:44.910024+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-kjq.4","title":"Backend prerequisite: pre-create material resources","description":"Dependency: backend must expose a Material ResourceSpecification and pre-created material EconomicResources for selection.\n\nAcceptance:\n- GraphQL query can list available materials (EconomicResource) for selection.\n- Each material has a stable name suitable for slugging.","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-15T16:19:58.631191+01:00","updated_at":"2025-12-15T16:19:58.631191+01:00","external_ref":"backend","dependencies":[{"issue_id":"interfacer-gui-kjq.4","depends_on_id":"interfacer-gui-kjq","type":"parent-child","created_at":"2025-12-15T16:19:58.632838+01:00","created_by":"daemon"}]} +{"id":"interfacer-gui-m4c","title":"GUI UX fixes for unauthenticated users","description":"","status":"open","priority":1,"issue_type":"epic","created_at":"2026-02-05T17:21:58.120229+01:00","updated_at":"2026-02-05T17:21:58.120229+01:00"} +{"id":"interfacer-gui-m4c.1","title":"Make 404 and other error pages public (accessible without login)","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-05T17:22:16.886114+01:00","updated_at":"2026-02-05T17:25:04.928717+01:00","closed_at":"2026-02-05T17:25:04.928717+01:00","dependencies":[{"issue_id":"interfacer-gui-m4c.1","depends_on_id":"interfacer-gui-m4c","type":"parent-child","created_at":"2026-02-05T17:22:16.888091+01:00","created_by":"daemon"}]} +{"id":"interfacer-gui-m4c.2","title":"Hide broken included projects tab until backend is fixed","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-05T17:22:29.094174+01:00","updated_at":"2026-02-05T17:25:05.019663+01:00","closed_at":"2026-02-05T17:25:05.019663+01:00","dependencies":[{"issue_id":"interfacer-gui-m4c.2","depends_on_id":"interfacer-gui-m4c","type":"parent-child","created_at":"2026-02-05T17:22:29.096798+01:00","created_by":"daemon"}]} +{"id":"interfacer-gui-m4c.3","title":"Fix user info display when not logged in (usercard, contributors, sidebar)","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-05T17:22:41.28499+01:00","updated_at":"2026-02-05T17:28:38.47143+01:00","closed_at":"2026-02-05T17:28:38.47143+01:00","dependencies":[{"issue_id":"interfacer-gui-m4c.3","depends_on_id":"interfacer-gui-m4c","type":"parent-child","created_at":"2026-02-05T17:22:41.286898+01:00","created_by":"daemon"}]} +{"id":"interfacer-gui-m4c.4","title":"Hide contributions tab and section when count is 0","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-05T17:43:13.618441+01:00","updated_at":"2026-02-05T17:44:39.43526+01:00","closed_at":"2026-02-05T17:44:39.43526+01:00","dependencies":[{"issue_id":"interfacer-gui-m4c.4","depends_on_id":"interfacer-gui-m4c","type":"parent-child","created_at":"2026-02-05T17:43:13.624714+01:00","created_by":"daemon"}]} +{"id":"interfacer-gui-m4c.5","title":"Show DPP QR-code only for products type with DPP","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-05T17:43:25.897784+01:00","updated_at":"2026-02-05T17:44:39.703382+01:00","closed_at":"2026-02-05T17:44:39.703382+01:00","dependencies":[{"issue_id":"interfacer-gui-m4c.5","depends_on_id":"interfacer-gui-m4c","type":"parent-child","created_at":"2026-02-05T17:43:25.899035+01:00","created_by":"daemon"}]} +{"id":"interfacer-gui-m4c.6","title":"Fix edit images yup schema to avoid field.resolve error","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-06T11:16:50.597757+01:00","updated_at":"2026-02-06T11:17:17.946726+01:00","closed_at":"2026-02-06T11:17:17.946726+01:00","dependencies":[{"issue_id":"interfacer-gui-m4c.6","depends_on_id":"interfacer-gui-m4c","type":"parent-child","created_at":"2026-02-06T11:16:50.602151+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yig","title":"Create new /products browsing page from Figma design","description":"Implement a new dedicated /products page that allows users to browse and filter OSH (Open Source Hardware) designs with advanced filtering capabilities, stats display, and improved UI based on the DTEC Figma prototype","status":"open","priority":1,"issue_type":"epic","created_at":"2025-12-10T13:30:49.12573+01:00","updated_at":"2025-12-10T13:30:49.12573+01:00"} {"id":"interfacer-gui-yig.1","title":"Create /pages/products.tsx route with basic layout","description":"Create the main products page route at /pages/products.tsx. Set up basic Next.js page structure with SSR translations, layout wrapper, and container divs. Reference existing /pages/projects.tsx as a starting point but adapt for the new design.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-10T13:31:24.239782+01:00","updated_at":"2025-12-10T13:39:31.797546+01:00","closed_at":"2025-12-10T13:39:31.797546+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.1","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2025-12-10T13:31:24.241937+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yig.10","title":"Add i18n translations for products page","description":"Create translation keys in public/locales/en/ for all new strings: 'Browse OSH Designs', filter labels, stat labels, etc. Add to appropriate namespace (createProjectProps or new productsProps). Ensure all hardcoded strings are using t() function.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-10T13:32:58.713695+01:00","updated_at":"2025-12-11T17:43:55.118207+01:00","closed_at":"2025-12-11T17:43:55.118207+01:00","dependencies":[{"issue_id":"interfacer-gui-yig.10","depends_on_id":"interfacer-gui-yig","type":"parent-child","created_at":"2025-12-10T13:32:58.714708+01:00","created_by":"daemon"}]} @@ -69,3 +76,4 @@ {"id":"interfacer-gui-yqb.1","title":"Integrate useSocial hook for real star counts in GeneralCard","description":"Make StarCount component clickable to allow users to star/unstar projects.\n\nCOMPLETED:\n- ✅ Integrated useSocial hook\n- ✅ Display real star count with erFollowerLength\n- ✅ Format count properly (1.2k, 3.9k, etc.)\n\nREMAINING:\n- Make StarCount clickable (button instead of div)\n- Use likeER function from useSocial to star/unstar\n- Show filled/outline star icon based on isLiked state\n- Add hover effects\n- Handle authenticated/non-authenticated users\n- Optimistic UI update on click\n\nIMPLEMENTATION:\n- Transform StarCount from display-only to interactive button\n- Use isLiked(project.id) to determine star state\n- Call likeER() on click to toggle star\n- Similar to AddStar component but integrated in card overlay\n\nFILES: components/GeneralCard.tsx (~line 252)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-11T17:54:41.193732+01:00","updated_at":"2025-12-11T18:14:53.847722+01:00","closed_at":"2025-12-11T18:14:53.847722+01:00","dependencies":[{"issue_id":"interfacer-gui-yqb.1","depends_on_id":"interfacer-gui-yqb","type":"parent-child","created_at":"2025-12-11T17:54:41.196249+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yqb.2","title":"Extract and display real license data from projects","description":"Update LicenseFooter component to show actual project license instead of fallback.\n\nCURRENT: Uses project.metadata?.license || 'CERN-OHL-W'\nNEEDED:\n- Investigate where license data is actually stored in EconomicResource\n- Check project.metadata.license, project.license, or other fields\n- Display actual license name if available\n- Handle missing license gracefully (show 'No license specified' or hide)\n- Verify with real project data from backend\n\nFILES: components/GeneralCard.tsx (~line 109)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-11T17:55:03.479192+01:00","updated_at":"2025-12-11T18:15:18.619958+01:00","closed_at":"2025-12-11T18:15:18.619958+01:00","dependencies":[{"issue_id":"interfacer-gui-yqb.2","depends_on_id":"interfacer-gui-yqb","type":"parent-child","created_at":"2025-12-11T17:55:03.481235+01:00","created_by":"daemon"}]} {"id":"interfacer-gui-yqb.3","title":"Display real resource requirements from project data","description":"Update ResourceRequirements component to show actual project requirements.\n\nCURRENT: Uses project.metadata?.requirements || mock text.\n\nNEW PLAN:\n- Display Machines Needed and Materials Needed based on project data:\n 1) Prefer cited resources if available (machines via cite events; materials via consume events).\n 2) Fallback to prefixed tags on the project (machine-*, material-*) for list/card contexts where cited-resource queries are not available.\n- Format as short list/chips suitable for cards.\n\nACCEPTANCE:\n- Card shows machine requirements when present (e.g., Laser Cutter, 3D Printer).\n- If only tags exist, it still renders from tags.\n- If none exist, hide the section (no mock text).\n\nRELATED:\n- Save-time tags: epic interfacer-gui-kjq\n- Tag-based filtering: epic interfacer-gui-f61.13","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-11T17:55:29.642317+01:00","updated_at":"2025-12-15T16:38:03.914222+01:00","closed_at":"2025-12-15T16:38:03.914222+01:00","dependencies":[{"issue_id":"interfacer-gui-yqb.3","depends_on_id":"interfacer-gui-yqb","type":"parent-child","created_at":"2025-12-11T17:55:29.645284+01:00","created_by":"daemon"}]} +{"id":"interfacer-gui-zsv","title":"Catalog: add recyclability and repairability filters via prefixed classifiedAs tags","description":"Add two new /products filters: recyclability and repairability. Follow existing tag-based filtering approach used for category/power/replicability/environment.\\n\\nScope:\\n- Define deterministic tag prefixes/values for recyclability and repairability\\n- Persist derived tags on create/edit\\n- Wire sidebar filter UI and active filter chips\\n- Ensure filters affect query via classifiedAs tags\\n\\nAcceptance:\\n- Selecting recyclability/repairability filters changes product results without backend changes\\n- Saved/edited products generate matching deterministic tags\\n- Active filters bar supports removing these filters","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-19T17:11:29.090419+01:00","updated_at":"2026-02-19T17:15:46.655469+01:00","closed_at":"2026-02-19T17:15:46.655469+01:00","dependencies":[{"issue_id":"interfacer-gui-zsv","depends_on_id":"interfacer-gui-f61.15","type":"discovered-from","created_at":"2026-02-19T17:11:29.098227+01:00","created_by":"phoebus-84"}]} diff --git a/.beads/last-touched b/.beads/last-touched new file mode 100644 index 00000000..8e4e5f58 --- /dev/null +++ b/.beads/last-touched @@ -0,0 +1 @@ +interfacer-gui-zsv diff --git a/components/ProductsActiveFiltersBar.tsx b/components/ProductsActiveFiltersBar.tsx index 67717963..11494642 100644 --- a/components/ProductsActiveFiltersBar.tsx +++ b/components/ProductsActiveFiltersBar.tsx @@ -18,7 +18,7 @@ import { useQuery } from "@apollo/client"; import { Tag } from "@bbtgnn/polaris-interfacer"; import { useResourceSpecs } from "hooks/useResourceSpecs"; import { QUERY_MACHINES } from "lib/QueryAndMutation"; -import { isPrefixedTag, prefixedTag, TAG_PREFIX } from "lib/tagging"; +import { isPrefixedTag, prefixedTag, REPAIRABILITY_AVAILABLE_TAG, TAG_PREFIX } from "lib/tagging"; import { useTranslation } from "next-i18next"; import { useRouter } from "next/router"; @@ -99,6 +99,8 @@ export default function ProductsActiveFiltersBar() { TAG_PREFIX.POWER_COMPAT, TAG_PREFIX.POWER_REQ, TAG_PREFIX.REPLICABILITY, + TAG_PREFIX.RECYCLABILITY, + TAG_PREFIX.REPAIRABILITY, TAG_PREFIX.ENV_ENERGY, TAG_PREFIX.ENV_CO2, ]) @@ -139,7 +141,10 @@ export default function ProductsActiveFiltersBar() { Boolean(energyMax) || Boolean(co2Min) || Boolean(co2Max) || - asCsvArray(router.query.replicability).length > 0; + asCsvArray(router.query.replicability).length > 0 || + Boolean(asString(router.query.recyclabilityMin)) || + Boolean(asString(router.query.recyclabilityMax)) || + asString(router.query.repairability) === "true"; if (!hasAnyActive) return null; @@ -411,6 +416,47 @@ export default function ProductsActiveFiltersBar() { }); } + const recyclabilityMin = asString(router.query.recyclabilityMin); + const recyclabilityMax = asString(router.query.recyclabilityMax); + + const recyclabilityLabel = (() => { + const min = recyclabilityMin ? `${recyclabilityMin}%` : ""; + const max = recyclabilityMax ? `${recyclabilityMax}%` : ""; + if (min && max) return `${min}–${max}`; + if (min) return `≥${min}`; + if (max) return `≤${max}`; + return ""; + })(); + + if (recyclabilityLabel) { + chips.push({ + key: `recyclability:${recyclabilityMin}:${recyclabilityMax}`, + label: `${t("Recyclability")}: ${recyclabilityLabel}`, + onRemove: () => { + const next = { ...router.query }; + delete next.recyclabilityMin; + delete next.recyclabilityMax; + const nextTags = removeTagsByPrefix(rawTags, TAG_PREFIX.RECYCLABILITY); + next.tags = nextTags.length > 0 ? nextTags.join(",") : undefined; + pushQuery(next); + }, + }); + } + + if (asString(router.query.repairability) === "true") { + chips.push({ + key: "repairability:true", + label: t("Available for repair"), + onRemove: () => { + const next = { ...router.query }; + delete next.repairability; + const nextTags = rawTags.filter(tg => tg !== REPAIRABILITY_AVAILABLE_TAG); + next.tags = nextTags.length > 0 ? nextTags.join(",") : undefined; + pushQuery(next); + }, + }); + } + return (
diff --git a/components/ProductsFilters.tsx b/components/ProductsFilters.tsx index a888f010..a3518920 100644 --- a/components/ProductsFilters.tsx +++ b/components/ProductsFilters.tsx @@ -26,7 +26,9 @@ import { POWER_COMPATIBILITY_OPTIONS, POWER_REQUIREMENT_THRESHOLDS_W, prefixedTag, + RECYCLABILITY_THRESHOLDS_PCT, rangeFilterTags, + REPAIRABILITY_AVAILABLE_TAG, REPLICABILITY_OPTIONS, TAG_PREFIX, } from "lib/tagging"; @@ -63,6 +65,9 @@ export interface ProductsFiltersState { powerRequirementMin: string; powerRequirementMax: string; replicability: string[]; + recyclabilityMin: string; + recyclabilityMax: string; + repairability: boolean; energyMin: string; energyMax: string; co2Min: string; @@ -142,6 +147,8 @@ export default function ProductsFilters() { powerCompatibility: false, powerRequirement: false, replicability: false, + recyclability: false, + repairability: false, environmentalImpact: false, }); @@ -156,6 +163,9 @@ export default function ProductsFilters() { powerRequirementMin: "", powerRequirementMax: "", replicability: [], + recyclabilityMin: "", + recyclabilityMax: "", + repairability: false, energyMin: "", energyMax: "", co2Min: "", @@ -177,6 +187,8 @@ export default function ProductsFilters() { TAG_PREFIX.POWER_COMPAT, TAG_PREFIX.POWER_REQ, TAG_PREFIX.REPLICABILITY, + TAG_PREFIX.RECYCLABILITY, + TAG_PREFIX.REPAIRABILITY, TAG_PREFIX.ENV_ENERGY, TAG_PREFIX.ENV_CO2, ]) @@ -201,6 +213,9 @@ export default function ProductsFilters() { powerRequirementMin: (query.powerMin as string) || "", powerRequirementMax: (query.powerMax as string) || "", replicability: query.replicability ? (query.replicability as string).split(",") : [], + recyclabilityMin: (query.recyclabilityMin as string) || "", + recyclabilityMax: (query.recyclabilityMax as string) || "", + repairability: query.repairability === "true", energyMin: (query.energyMin as string) || "", energyMax: (query.energyMax as string) || "", co2Min: (query.co2Min as string) || "", @@ -255,6 +270,17 @@ export default function ProductsFilters() { .map(value => prefixedTag(TAG_PREFIX.REPLICABILITY, value)) .filter((t): t is string => Boolean(t)); + const recyclabilityMin = filters.recyclabilityMin ? Number(filters.recyclabilityMin) : undefined; + const recyclabilityMax = filters.recyclabilityMax ? Number(filters.recyclabilityMax) : undefined; + const recyclabilityTags = rangeFilterTags( + TAG_PREFIX.RECYCLABILITY, + recyclabilityMin, + recyclabilityMax, + RECYCLABILITY_THRESHOLDS_PCT + ); + + const repairabilityTags = filters.repairability ? [REPAIRABILITY_AVAILABLE_TAG] : []; + const powerMin = filters.powerRequirementMin ? Number(filters.powerRequirementMin) : undefined; const powerMax = filters.powerRequirementMax ? Number(filters.powerRequirementMax) : undefined; const powerReqTags = rangeFilterTags(TAG_PREFIX.POWER_REQ, powerMin, powerMax, POWER_REQUIREMENT_THRESHOLDS_W); @@ -276,6 +302,8 @@ export default function ProductsFilters() { materialTags, powerCompatTags, replicabilityTags, + recyclabilityTags, + repairabilityTags, powerReqTags, energyTags, co2Tags @@ -297,6 +325,9 @@ export default function ProductsFilters() { if (filters.powerRequirementMin) query.powerMin = filters.powerRequirementMin; if (filters.powerRequirementMax) query.powerMax = filters.powerRequirementMax; if (filters.replicability.length > 0) query.replicability = filters.replicability.join(","); + if (filters.recyclabilityMin) query.recyclabilityMin = filters.recyclabilityMin; + if (filters.recyclabilityMax) query.recyclabilityMax = filters.recyclabilityMax; + if (filters.repairability) query.repairability = "true"; if (filters.energyMin) query.energyMin = filters.energyMin; if (filters.energyMax) query.energyMax = filters.energyMax; if (filters.co2Min) query.co2Min = filters.co2Min; @@ -316,6 +347,9 @@ export default function ProductsFilters() { powerRequirementMin: "", powerRequirementMax: "", replicability: [], + recyclabilityMin: "", + recyclabilityMax: "", + repairability: false, energyMin: "", energyMax: "", co2Min: "", @@ -686,6 +720,104 @@ export default function ProductsFilters() { )}
+ {/* Recyclability (%) */} +
+ + {openSections.recyclability && ( +
+
+
+ +
+ setFilters(prev => ({ ...prev, recyclabilityMin: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#036A53] focus:border-transparent text-sm" + placeholder="0" + min={0} + max={100} + /> + {"%"} +
+
+
+ +
+ setFilters(prev => ({ ...prev, recyclabilityMax: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#036A53] focus:border-transparent text-sm" + placeholder="100" + min={0} + max={100} + /> + {"%"} +
+
+
+
+ )} +
+ + {/* Repairability */} +
+ + {openSections.repairability && ( +
+ +
+ )} +
+ {/* Environmental Impact */}
+ setValue("productFilters.recyclabilityPct", value, formSetValueOptions)} + helpText={t("Percentage of product that can be recycled (0–100).")} + autoComplete="off" + min={0} + max={100} + /> + + + + + +
diff --git a/hooks/useProjectCRUD.ts b/hooks/useProjectCRUD.ts index 6faffaef..3a932d94 100644 --- a/hooks/useProjectCRUD.ts +++ b/hooks/useProjectCRUD.ts @@ -325,11 +325,14 @@ export const useProjectCRUD = () => { const powerRequirementW = pf.powerRequirementW ? Number(pf.powerRequirementW) : undefined; const energyKwh = pf.energyKwh ? Number(pf.energyKwh) : undefined; const co2Kg = pf.co2Kg ? Number(pf.co2Kg) : undefined; + const recyclabilityPct = pf.recyclabilityPct ? Number(pf.recyclabilityPct) : undefined; return { categories: pf.categories || [], powerCompatibility: pf.powerCompatibility || [], replicability: pf.replicability || [], + recyclabilityPct: Number.isFinite(recyclabilityPct as number) ? (recyclabilityPct as number) : undefined, + repairability: Boolean(pf.repairability), powerRequirementW: Number.isFinite(powerRequirementW as number) ? (powerRequirementW as number) : undefined, energyKwh: Number.isFinite(energyKwh as number) ? (energyKwh as number) : undefined, co2Kg: Number.isFinite(co2Kg as number) ? (co2Kg as number) : undefined, @@ -343,6 +346,8 @@ export const useProjectCRUD = () => { TAG_PREFIX.POWER_COMPAT, TAG_PREFIX.POWER_REQ, TAG_PREFIX.REPLICABILITY, + TAG_PREFIX.RECYCLABILITY, + TAG_PREFIX.REPAIRABILITY, TAG_PREFIX.ENV_ENERGY, TAG_PREFIX.ENV_CO2, ]); diff --git a/lib/tagging.ts b/lib/tagging.ts index 8b6a73f9..d983d426 100644 --- a/lib/tagging.ts +++ b/lib/tagging.ts @@ -17,6 +17,8 @@ export const TAG_PREFIX = { POWER_COMPAT: "powercompat", POWER_REQ: "powerreq", REPLICABILITY: "replicability", + RECYCLABILITY: "recyclability", + REPAIRABILITY: "repairability", ENV_ENERGY: "env-energy", ENV_CO2: "env-co2", } as const; @@ -47,6 +49,13 @@ export const POWER_COMPATIBILITY_OPTIONS = [ export const REPLICABILITY_OPTIONS = ["High", "Medium", "Low"] as const; +// Recyclability uses a numeric percentage (0–100) stored with monotonic range tags, +// following the same pattern as energy consumption and CO2 emissions. +export const RECYCLABILITY_THRESHOLDS_PCT = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] as const; + +// Repairability is a simple binary tag: either the product is repairable or not. +export const REPAIRABILITY_AVAILABLE_TAG = "repairability-available"; + // Numeric thresholds used for monotonic range tags. // Keep these lists stable: changing them will change the derived tags. export const POWER_REQUIREMENT_THRESHOLDS_W = [ @@ -61,6 +70,8 @@ export interface ProductFilterMetadata { categories?: string[]; powerCompatibility?: string[]; replicability?: string[]; + recyclabilityPct?: number; + repairability?: boolean; powerRequirementW?: number; energyKwh?: number; co2Kg?: number; @@ -126,6 +137,13 @@ export function derivedProductFilterTags(filters: ProductFilterMetadata): string .map(value => prefixedTag(TAG_PREFIX.REPLICABILITY, value)) .filter((t): t is string => Boolean(t)); + const recyclability = + typeof filters.recyclabilityPct === "number" + ? monotonicRangeTags(TAG_PREFIX.RECYCLABILITY, filters.recyclabilityPct, RECYCLABILITY_THRESHOLDS_PCT) + : []; + + const repairability = filters.repairability ? [REPAIRABILITY_AVAILABLE_TAG] : []; + const powerReq = typeof filters.powerRequirementW === "number" ? monotonicRangeTags(TAG_PREFIX.POWER_REQ, filters.powerRequirementW, POWER_REQUIREMENT_THRESHOLDS_W) @@ -139,7 +157,7 @@ export function derivedProductFilterTags(filters: ProductFilterMetadata): string const co2 = typeof filters.co2Kg === "number" ? monotonicRangeTags(TAG_PREFIX.ENV_CO2, filters.co2Kg, CO2_THRESHOLDS_KG) : []; - return mergeTags(categories, powerCompatibility, replicability, powerReq, energy, co2); + return mergeTags(categories, powerCompatibility, replicability, recyclability, repairability, powerReq, energy, co2); } export function removeTagsWithPrefixes(tags: ReadonlyArray, prefixes: ReadonlyArray): string[] { diff --git a/pages/project/[id]/edit/specs.tsx b/pages/project/[id]/edit/specs.tsx index 65815b88..eaf3ba3a 100644 --- a/pages/project/[id]/edit/specs.tsx +++ b/pages/project/[id]/edit/specs.tsx @@ -17,6 +17,7 @@ import { POWER_COMPATIBILITY_OPTIONS, prefixedTag, PRODUCT_CATEGORY_OPTIONS, + REPAIRABILITY_AVAILABLE_TAG, removeTagsWithPrefixes, REPLICABILITY_OPTIONS, TAG_PREFIX, @@ -75,6 +76,14 @@ const EditSpecs: NextPageWithLayout = () => { replicability: (existing.replicability as string[] | undefined) || inferFromTags(TAG_PREFIX.REPLICABILITY, REPLICABILITY_OPTIONS), + recyclabilityPct: + typeof existing.recyclabilityPct === "number" + ? String(existing.recyclabilityPct) + : productFiltersStepDefaultValues.recyclabilityPct, + repairability: + typeof existing.repairability === "boolean" + ? existing.repairability + : (project.classifiedAs || []).includes(REPAIRABILITY_AVAILABLE_TAG), energyKwh: typeof existing.energyKwh === "number" ? String(existing.energyKwh) : productFiltersStepDefaultValues.energyKwh, co2Kg: typeof existing.co2Kg === "number" ? String(existing.co2Kg) : productFiltersStepDefaultValues.co2Kg, @@ -100,11 +109,14 @@ const EditSpecs: NextPageWithLayout = () => { const powerRequirementW = values.powerRequirementW ? Number(values.powerRequirementW) : undefined; const energyKwh = values.energyKwh ? Number(values.energyKwh) : undefined; const co2Kg = values.co2Kg ? Number(values.co2Kg) : undefined; + const recyclabilityPct = values.recyclabilityPct ? Number(values.recyclabilityPct) : undefined; return { categories: values.categories || [], powerCompatibility: values.powerCompatibility || [], replicability: values.replicability || [], + recyclabilityPct: Number.isFinite(recyclabilityPct as number) ? (recyclabilityPct as number) : undefined, + repairability: Boolean(values.repairability), powerRequirementW: Number.isFinite(powerRequirementW as number) ? (powerRequirementW as number) : undefined, energyKwh: Number.isFinite(energyKwh as number) ? (energyKwh as number) : undefined, co2Kg: Number.isFinite(co2Kg as number) ? (co2Kg as number) : undefined, @@ -121,6 +133,8 @@ const EditSpecs: NextPageWithLayout = () => { TAG_PREFIX.POWER_COMPAT, TAG_PREFIX.POWER_REQ, TAG_PREFIX.REPLICABILITY, + TAG_PREFIX.RECYCLABILITY, + TAG_PREFIX.REPAIRABILITY, TAG_PREFIX.ENV_ENERGY, TAG_PREFIX.ENV_CO2, ]); From 611d8d65e4c4fafa6eb8d0f6e94174076574f470 Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:33:27 +0200 Subject: [PATCH 08/18] feat: interfacer gui big rework phase 1 (#825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **feat: ✨ Epic 1 - Design System Foundation** - **feat: ✨ Epic 2 - Navigation and Layout Redesign** - **feat: ✨ Epic 3 - Catalog Pages (Designs, Products, Services)** - **feat: ✨ redesign project detail page with new layout** - **feat: ✨ Epic 5 - Profile Page Redesign with new layout and tabs** - **feat: ✨ Epic 6 - Creation Forms Redesign with new design tokens** - **chore: 🔧 close epics 7-9 in bd tracker** - **feat: ✨ add creation form access from profile page (cxo.7)** - **fix: 🐛 align catalog titles, stats & placeholders with prototype (cxo.1, cxo.2, cxo.3)** - **feat: ✨ add Show All dropdown to catalog toolbar (cxo.5)** - **feat: ✨ add missing sidebar filters (cxo.4)** - **feat: ✨ rename My list to Saved Lists, add Track Record nav item (cxo.8)** - **feat: ✨ enhance project cards with requires/nearby and based-on links (cxo.6)** - **feat: ✨ enhance detail page sidebar and add product passport section (cxo.9)** --- .beads/.gitignore | 23 +- .beads/last-touched | 2 +- components/CatalogFilterSidebar.tsx | 838 +++++++ components/CatalogLayout.tsx | 370 +++ components/CheckboxFilter.tsx | 81 + components/DetailSection.tsx | 67 + components/DualRangeSlider.tsx | 121 + components/EntityTypeIcon.tsx | 103 + components/FilterSection.tsx | 39 + components/InterfacerLogo.tsx | 21 + components/NavigationMenu.tsx | 498 ++++ components/ProfilePageNew.tsx | 895 +++++++ components/ProjectCardNew.tsx | 442 ++++ components/ProjectDetailNew.tsx | 2078 +++++++++++++++++ components/ProjectTypeChip.tsx | 12 +- components/ProjectTypeRenderProps.tsx | 32 +- components/ProjectTypeRoundIcon.tsx | 7 +- components/SearchProjects.tsx | 1 + components/StatCard.tsx | 31 + components/TagBadge.tsx | 16 + components/ToggleSwitch.tsx | 32 + components/ToolbarDropdown.tsx | 75 + components/UserDropdown.tsx | 196 ++ components/layout/CreateProjectLayout.tsx | 8 +- components/layout/Layout.tsx | 30 +- components/layout/SearchLayout.tsx | 30 +- .../partials/create/dpp/CreateDppForm.tsx | 799 +++++++ .../create/project/CreateProjectForm.tsx | 91 +- .../project/parts/CreateProjectFields.tsx | 57 +- .../create/project/parts/CreateProjectNav.tsx | 83 +- .../project/parts/CreateProjectSubmit.tsx | 29 +- .../partials/create/project/steps/DPPStep.tsx | 214 +- .../DPPStep/components/RangeSliderField.tsx | 117 + .../DPPStep/sections/EnvironmentalSection.tsx | 35 +- .../create/project/steps/MachinesStep.tsx | 23 +- .../project/steps/ServiceFiltersStep.tsx | 91 + .../partials/project/projectSections.tsx | 15 +- components/partials/topbar/Topbar.tsx | 252 +- components/types/index.ts | 1 + history/redesign-issues.md | 29 + hooks/useProjectCRUD.ts | 26 +- lib/QueryAndMutation.ts | 1 - lib/dpp-types.ts | 222 ++ lib/dpp.ts | 227 ++ lib/fetchLocation.ts | 10 +- lib/findProjectImages.ts | 8 +- lib/isProjectType.ts | 1 + lib/tagging.ts | 12 + lib/types/index.ts | 3 + pages/_app.tsx | 11 +- pages/api/image/[hash].ts | 29 + pages/create/project/index.tsx | 148 +- pages/designs.tsx | 52 + pages/dpps/new.tsx | 25 + pages/products.tsx | 244 +- pages/profile/[id]/index.tsx | 8 +- pages/project/[id]/index.tsx | 36 +- pages/services.tsx | 52 + styles/globals.scss | 20 +- styles/theme.css | 145 ++ tailwind.config.js | 95 +- 61 files changed, 8369 insertions(+), 890 deletions(-) create mode 100644 components/CatalogFilterSidebar.tsx create mode 100644 components/CatalogLayout.tsx create mode 100644 components/CheckboxFilter.tsx create mode 100644 components/DetailSection.tsx create mode 100644 components/DualRangeSlider.tsx create mode 100644 components/EntityTypeIcon.tsx create mode 100644 components/FilterSection.tsx create mode 100644 components/InterfacerLogo.tsx create mode 100644 components/NavigationMenu.tsx create mode 100644 components/ProfilePageNew.tsx create mode 100644 components/ProjectCardNew.tsx create mode 100644 components/ProjectDetailNew.tsx create mode 100644 components/StatCard.tsx create mode 100644 components/TagBadge.tsx create mode 100644 components/ToggleSwitch.tsx create mode 100644 components/ToolbarDropdown.tsx create mode 100644 components/UserDropdown.tsx create mode 100644 components/partials/create/dpp/CreateDppForm.tsx create mode 100644 components/partials/create/project/steps/DPPStep/components/RangeSliderField.tsx create mode 100644 components/partials/create/project/steps/ServiceFiltersStep.tsx create mode 100644 history/redesign-issues.md create mode 100644 lib/dpp-types.ts create mode 100644 lib/dpp.ts create mode 100644 pages/api/image/[hash].ts create mode 100644 pages/designs.tsx create mode 100644 pages/dpps/new.tsx create mode 100644 pages/services.tsx create mode 100644 styles/theme.css diff --git a/.beads/.gitignore b/.beads/.gitignore index f438450f..d27a1db5 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -10,11 +10,20 @@ daemon.lock daemon.log daemon.pid bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version # Legacy database files db.sqlite bd.db +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + # Merge artifacts (temporary files from 3-way merge) beads.base.jsonl beads.base.meta.json @@ -23,7 +32,13 @@ beads.left.meta.json beads.right.jsonl beads.right.meta.json -# Keep JSONL exports and config (source of truth for git) -!issues.jsonl -!metadata.json -!config.json +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/last-touched b/.beads/last-touched index 8e4e5f58..5bbacc32 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -interfacer-gui-zsv +interfacer-gui-ve1.1 diff --git a/components/CatalogFilterSidebar.tsx b/components/CatalogFilterSidebar.tsx new file mode 100644 index 00000000..d4fbf7ea --- /dev/null +++ b/components/CatalogFilterSidebar.tsx @@ -0,0 +1,838 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import { Chemistry, Close, Cube, Flash, LocationStar, Recycle, Settings, Tag, Time, Tools } from "@carbon/icons-react"; +import { ScaleIcon } from "@heroicons/react/outline"; +import CheckboxFilter from "components/CheckboxFilter"; +import DualRangeSlider from "components/DualRangeSlider"; +import FilterSection from "components/FilterSection"; +import ToggleSwitch from "components/ToggleSwitch"; +import { FetchLocation, fetchLocation, lookupLocation } from "lib/fetchLocation"; +import { + AVAILABILITY_OPTIONS, + POWER_COMPATIBILITY_OPTIONS, + PRODUCT_CATEGORY_OPTIONS, + REPAIRABILITY_AVAILABLE_TAG, + SERVICE_TYPE_OPTIONS, + TAG_PREFIX, + slugifyTagValue, +} from "lib/tagging"; +import { useTranslation } from "next-i18next"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export type CatalogVariant = "designs" | "products" | "services"; + +interface CatalogFilterSidebarProps { + variant: CatalogVariant; + collapsed?: boolean; + onToggle?: () => void; +} + +/** Given current URL tags, a tag prefix, and the items list, return which items are currently selected */ +function getSelectedItems(tags: string[], prefix: string, items: readonly string[]): string[] { + const tagSet = new Set(tags); + return items.filter(item => { + const tag = `${prefix}-${slugifyTagValue(item)}`; + return tagSet.has(tag); + }); +} + +const MACHINES = [ + "3D Printer", + "CNC Mill", + "Laser Cutter", + "PCB Mill", + "Vinyl Cutter", + "Embroidery Machine", + "Soldering Iron", + "Router", + "Drill Press", + "Band Saw", + "Lathe", + "Waterjet Cutter", +]; + +const MATERIALS = [ + "PLA", + "ABS", + "PETG", + "Aluminum", + "Steel", + "Wood", + "Acrylic", + "Plywood", + "Carbon Fiber", + "Copper", + "FR4 (PCB)", + "Resin", + "Nylon", + "TPU", +]; + +const LICENSES = [ + "CERN-OHL-S v2", + "CERN-OHL-W v2", + "CERN-OHL-P v2", + "CC BY 4.0", + "CC BY-SA 4.0", + "CC BY-NC 4.0", + "MIT", + "GPL v3", + "Apache 2.0", +]; + +export default function CatalogFilterSidebar({ variant, collapsed = false, onToggle }: CatalogFilterSidebarProps) { + const { t } = useTranslation("common"); + const router = useRouter(); + const [manufacturingFilter, setManufacturingFilter] = useState("all"); + + // --- Geo location state --- + const urlRadius = router.query.nearDistanceKm ? Number(router.query.nearDistanceKm) : 50; + const [searchRadius, setSearchRadius] = useState(urlRadius); + const [locationLabel, setLocationLabel] = useState((router.query.locationLabel as string) || ""); + const [locationInput, setLocationInput] = useState(""); + const [locationOptions, setLocationOptions] = useState([]); + const [locationLoading, setLocationLoading] = useState(false); + const [showLocationDropdown, setShowLocationDropdown] = useState(false); + const locationDropdownRef = useRef(null); + const debounceRef = useRef | null>(null); + + // Sync location state from URL on mount / navigation + useEffect(() => { + const km = router.query.nearDistanceKm; + if (km) setSearchRadius(Number(km)); + const label = router.query.locationLabel as string; + if (label) setLocationLabel(label); + else if (!router.query.nearLat) setLocationLabel(""); + }, [router.query.nearDistanceKm, router.query.locationLabel, router.query.nearLat]); + + // Debounced location search + useEffect(() => { + if (!locationInput.trim()) { + setLocationOptions([]); + return; + } + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setLocationLoading(true); + const results = await fetchLocation(locationInput); + setLocationOptions(results); + setLocationLoading(false); + setShowLocationDropdown(true); + }, 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [locationInput]); + + // Close dropdown when clicking outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (locationDropdownRef.current && !locationDropdownRef.current.contains(e.target as Node)) { + setShowLocationDropdown(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const handleLocationSelect = useCallback( + async (loc: FetchLocation.Location) => { + setShowLocationDropdown(false); + setLocationInput(""); + const detail = await lookupLocation(loc.id); + if (!detail) return; + setLocationLabel(detail.title); + const radius = router.query.nearDistanceKm ? String(router.query.nearDistanceKm) : "50"; + const query = { + ...router.query, + nearLat: String(detail.position.lat), + nearLong: String(detail.position.lng), + nearDistanceKm: radius, + locationLabel: detail.title, + }; + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + }, + [router] + ); + + const handleRadiusChange = useCallback( + (km: number) => { + setSearchRadius(km); + if (router.query.nearLat && router.query.nearLong) { + const query = { ...router.query, nearDistanceKm: String(km) }; + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + } + }, + [router] + ); + + const clearLocation = useCallback(() => { + setLocationLabel(""); + setLocationInput(""); + setSearchRadius(50); + const query = { ...router.query }; + delete query.nearLat; + delete query.nearLong; + delete query.nearDistanceKm; + delete query.locationLabel; + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + }, [router]); + + const hasActiveLocation = !!router.query.nearLat; + + // Range slider states for products + const [powerRange, setPowerRange] = useState<[number, number]>([0, 2000]); + const [co2Range, setCo2Range] = useState<[number, number]>([0, 500]); + const [energyRange, setEnergyRange] = useState<[number, number]>([0, 1000]); + + // Parse current tags from URL + const currentTags = useMemo(() => { + const t = router.query.tags; + if (!t) return [] as string[]; + return typeof t === "string" ? t.split(",") : (t as string[]); + }, [router.query.tags]); + + const selectedMachines = useMemo(() => getSelectedItems(currentTags, TAG_PREFIX.MACHINE, MACHINES), [currentTags]); + const selectedMaterials = useMemo(() => getSelectedItems(currentTags, TAG_PREFIX.MATERIAL, MATERIALS), [currentTags]); + const selectedLicenses = useMemo(() => getSelectedItems(currentTags, TAG_PREFIX.LICENSE, LICENSES), [currentTags]); + const selectedServiceTypes = useMemo( + () => getSelectedItems(currentTags, TAG_PREFIX.SERVICE_TYPE, SERVICE_TYPE_OPTIONS), + [currentTags] + ); + const selectedAvailability = useMemo( + () => getSelectedItems(currentTags, TAG_PREFIX.AVAILABILITY, AVAILABILITY_OPTIONS), + [currentTags] + ); + const selectedPower = useMemo( + () => getSelectedItems(currentTags, TAG_PREFIX.POWER_COMPAT, POWER_COMPATIBILITY_OPTIONS), + [currentTags] + ); + const repairInfo = useMemo(() => currentTags.includes(REPAIRABILITY_AVAILABLE_TAG), [currentTags]); + + // Toggle a tag in the URL + const toggleTag = useCallback( + (prefix: string) => (item: string) => { + const encoded = `${prefix}-${slugifyTagValue(item)}`; + const newTags = currentTags.includes(encoded) + ? currentTags.filter(t => t !== encoded) + : [...currentTags, encoded]; + + const query = { ...router.query }; + if (newTags.length > 0) { + query.tags = newTags.join(","); + } else { + delete query.tags; + } + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + }, + [currentTags, router] + ); + + const toggleCategory = useCallback( + (cat: string) => { + const encoded = `${TAG_PREFIX.CATEGORY}-${slugifyTagValue(cat)}`; + const newTags = currentTags.includes(encoded) + ? currentTags.filter(t => t !== encoded) + : [...currentTags, encoded]; + + const query = { ...router.query }; + if (newTags.length > 0) { + query.tags = newTags.join(","); + } else { + delete query.tags; + } + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + }, + [currentTags, router] + ); + + const selectedCategories = useMemo( + () => getSelectedItems(currentTags, TAG_PREFIX.CATEGORY, PRODUCT_CATEGORY_OPTIONS), + [currentTags] + ); + + const clearAllFilters = () => { + router.push({ pathname: router.pathname }, undefined, { shallow: true }); + }; + + const hasActiveFilters = currentTags.length > 0 || !!router.query.q || hasActiveLocation; + + return ( +
+
+ {/* Header */} +
+

+ {t("Filter by")} +

+
+ + {/* DESIGNS variant */} + {variant === "designs" && ( + <> + } + label="Machines Needed" + defaultOpen + badge={selectedMachines.length || undefined} + > + + + + } + label="Materials Needed" + badge={selectedMaterials.length || undefined} + > + + + + } + label="License" + badge={selectedLicenses.length || undefined} + > + + + + } + label="Manufacturability" + defaultOpen + badge={manufacturingFilter !== "all" ? 1 : undefined} + > +
+ {[ + { value: "all", label: "All" }, + { value: "can_be_manufactured", label: "Can be manufactured" }, + { value: "in_progress", label: "In progress" }, + ].map(option => ( +
setManufacturingFilter(option.value)} + > +
+ {manufacturingFilter === option.value && ( +
+ )} +
+ + {t(option.label)} + +
+ ))} +
+ + + )} + + {/* PRODUCTS variant */} + {variant === "products" && ( + <> + } + label="Machines Needed" + defaultOpen + badge={selectedMachines.length || undefined} + > + + + + } label="Materials" badge={selectedMaterials.length || undefined}> + + + + } label="Power Requirement"> + setPowerRange([low, high])} + /> + + + } label="Repairability"> + { + const newTags = checked + ? [...currentTags, REPAIRABILITY_AVAILABLE_TAG] + : currentTags.filter(t => t !== REPAIRABILITY_AVAILABLE_TAG); + const query = { ...router.query }; + if (newTags.length > 0) { + query.tags = newTags.join(","); + } else { + delete query.tags; + } + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + }} + /> + + + } label="Manufacturability"> +
+ {[ + { value: "all", label: "All" }, + { value: "can_be_manufactured", label: "Can be manufactured" }, + { value: "in_progress", label: "In progress" }, + ].map(option => ( +
setManufacturingFilter(option.value)} + > +
+ {manufacturingFilter === option.value && ( +
+ )} +
+ + {t(option.label)} + +
+ ))} +
+ + + )} + + {/* SERVICES variant */} + {variant === "services" && ( + <> + } + label="Location" + defaultOpen + badge={hasActiveLocation ? 1 : undefined} + > +
+ {locationLabel ? ( +
+ + + {locationLabel} + + +
+ ) : ( +
+ setLocationInput(e.target.value)} + onFocus={() => locationOptions.length > 0 && setShowLocationDropdown(true)} + placeholder={t("Search city or address...")} + className="w-full px-3 bg-ifr-form-input border border-ifr-form-input rounded-ifr-sm outline-none focus:border-ifr-green" + style={{ + height: "var(--ifr-control-height)", + fontFamily: "var(--ifr-font-body)", + fontSize: "var(--ifr-fs-base)", + }} + /> + {showLocationDropdown && locationOptions.length > 0 && ( +
+ {locationOptions.map(loc => ( + + ))} +
+ )} + {locationLoading && ( +
+
+
+ )} +
+ )} + +
+ {[10, 25, 50, 100, 250].map(km => ( + + ))} +
+
+ + + } + label="Service Type" + defaultOpen + badge={selectedServiceTypes.length || undefined} + > + + + + } + label="Availability" + badge={selectedAvailability.length || undefined} + > + + + + } + label="Machines Available" + badge={selectedMachines.length || undefined} + > + + + + )} + + {/* Shared sections */} + + {/* Location — designs and products only */} + {variant !== "services" && ( + } label="Location" badge={hasActiveLocation ? 1 : undefined}> +
+ {locationLabel ? ( +
+ + + {locationLabel} + + +
+ ) : ( +
+ setLocationInput(e.target.value)} + onFocus={() => locationOptions.length > 0 && setShowLocationDropdown(true)} + placeholder={t("Search city or address...")} + className="w-full px-3 bg-ifr-form-input border border-ifr-form-input rounded-ifr-sm outline-none focus:border-ifr-green" + style={{ + height: "var(--ifr-control-height)", + fontFamily: "var(--ifr-font-body)", + fontSize: "var(--ifr-fs-base)", + }} + /> + {showLocationDropdown && locationOptions.length > 0 && ( +
+ {locationOptions.map(loc => ( + + ))} +
+ )} + {locationLoading && ( +
+
+
+ )} +
+ )} + +
+ {[10, 25, 50, 100, 250].map(km => ( + + ))} +
+
+ + )} + + } label="Categories & Tags"> +
+ {PRODUCT_CATEGORY_OPTIONS.map(cat => { + const active = selectedCategories.includes(cat); + return ( + + ); + })} +
+
+ + {/* Power/Environmental — designs and products */} + {variant !== "services" && ( + <> + } + label="Power Compatibility" + badge={selectedPower.length || undefined} + > + + + + } label="Environmental Impact"> +
+
+ + {t("CO\u2082 Emissions")} + + setCo2Range([low, high])} + /> +
+
+ + {t("Energy Consumption")} + + setEnergyRange([low, high])} + /> +
+
+
+ + )} + + {/* Sticky bottom action bar */} +
+ + {hasActiveFilters && ( + + )} +
+
+
+ ); +} diff --git a/components/CatalogLayout.tsx b/components/CatalogLayout.tsx new file mode 100644 index 00000000..c1c6065c --- /dev/null +++ b/components/CatalogLayout.tsx @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import { useQuery } from "@apollo/client"; +import { AdjustmentsIcon, SearchIcon } from "@heroicons/react/outline"; +import CatalogFilterSidebar, { CatalogVariant } from "components/CatalogFilterSidebar"; +import EmptyState from "components/EmptyState"; +import ProductCardSkeleton from "components/ProductCardSkeleton"; +import ProjectCardNew from "components/ProjectCardNew"; +import ToolbarDropdown from "components/ToolbarDropdown"; +import useLoadMore from "hooks/useLoadMore"; +import { FETCH_RESOURCES } from "lib/QueryAndMutation"; +import { EconomicResource, EconomicResourceFilterParams, FetchInventoryQuery } from "lib/types"; +import { useTranslation } from "next-i18next"; +import { useRouter } from "next/router"; +import React, { ReactNode, useState } from "react"; + +interface CatalogHeroProps { + title: string; + description: string; + stats: ReactNode; +} + +interface CatalogLayoutProps { + variant: CatalogVariant; + hero: CatalogHeroProps; + searchPlaceholder: string; + filter: EconomicResourceFilterParams; + sortOptions?: string[]; + onDataLoaded?: (data: { totalCount: number; loading: boolean }) => void; +} + +const SORT_OPTIONS_DEFAULT = ["Latest", "Most Popular", "A\u2013Z", "Z\u2013A"]; + +export default function CatalogLayout({ + variant, + hero, + searchPlaceholder, + filter, + sortOptions = SORT_OPTIONS_DEFAULT, + onDataLoaded, +}: CatalogLayoutProps) { + const { t } = useTranslation("common"); + const router = useRouter(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [searchQuery, setSearchQuery] = useState((router.query.q as string) || ""); + + const sortBy = (router.query.sort as string) || "Latest"; + const showFilter = (router.query.show as string) || "All"; + + const handleSortChange = (value: string) => { + const query = { ...router.query, sort: value }; + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + }; + + const handleShowChange = (value: string) => { + const query = { ...router.query, show: value }; + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + const query = { ...router.query }; + if (searchQuery.trim()) { + query.q = searchQuery.trim(); + } else { + delete query.q; + } + router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); + }; + + // Apply search + tag + geo filters from URL + const tagsParam = router.query.tags as string | undefined; + const tagsList = tagsParam ? tagsParam.split(",").map(t => encodeURI(t)) : undefined; + + const nearLat = router.query.nearLat as string | undefined; + const nearLong = router.query.nearLong as string | undefined; + const nearDistanceKm = router.query.nearDistanceKm as string | undefined; + + const effectiveFilter: EconomicResourceFilterParams = { + ...filter, + ...(router.query.q && { orName: router.query.q as string }), + ...(tagsList && tagsList.length > 0 && { classifiedAs: tagsList }), + ...(nearLat && nearLong && nearDistanceKm && { nearLat, nearLong, nearDistanceKm }), + }; + + const dataQueryIdentifier = "economicResources"; + const isFilterReady = !!effectiveFilter.conformsTo?.length; + + const { loading, data, fetchMore, refetch, variables, error } = useQuery(FETCH_RESOURCES, { + variables: { last: 12, filter: effectiveFilter }, + skip: !isFilterReady, + }); + + const { loadMore, showEmptyState, items, getHasNextPage } = useLoadMore({ + fetchMore, + refetch, + variables, + data, + dataQueryIdentifier, + }); + + // Treat "waiting for filter" as loading to avoid premature empty state + const isLoading = loading || !isFilterReady; + const projects = items; + const totalCount = data?.economicResources?.pageInfo?.totalCount || 0; + const hasNext = !!getHasNextPage; + + // Notify parent of data changes + React.useEffect(() => { + if (onDataLoaded && data?.economicResources?.pageInfo) { + onDataLoaded({ totalCount, loading: isLoading }); + } + }, [data, isLoading, onDataLoaded, totalCount]); + + const heroGradients: Record = { + designs: "linear-gradient(83deg, rgb(3, 106, 83) 0%, rgb(57, 170, 145) 100%)", + products: "linear-gradient(83deg, rgb(20, 59, 181) 0%, rgb(106, 140, 246) 100%)", + services: "linear-gradient(83deg, rgb(130, 0, 219) 0%, rgb(193, 125, 240) 100%)", + }; + + return ( +
+ {/* Filter Sidebar */} + setSidebarCollapsed(v => !v)} + /> + + {/* Main Content */} +
+ {/* Hero Section */} +
+
+
+ {/* Left: Title + Description */} +
+

+ {hero.title} +

+

+ {hero.description} +

+
+ + {/* Right: Stats */} +
{hero.stats}
+
+
+
+ + {/* Search & Sort Bar */} +
+
+ {/* Filters toggle */} + + + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="flex-1 bg-transparent text-ifr-text-primary placeholder:text-ifr-text-muted outline-none" + style={{ fontFamily: "var(--ifr-font-body)", fontSize: "var(--ifr-fs-base)", lineHeight: "21px" }} + /> +
+
+ + {/* Sort & Show */} +
+ + +
+
+
+ + {/* Results */} +
+ {/* Results count */} +

+ {t("Showing") + " "} + + {isLoading ? "..." : totalCount} + + {" " + t("results")} +

+ + {/* Loading skeleton */} + {isLoading && !data && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {/* Error state */} + {error && ( +
+

+ {t("Error loading projects")} +

+

{error.message}

+ +
+ )} + + {/* Empty state */} + {!isLoading && !error && (showEmptyState || !projects?.length) && ( + + )} + + {/* Cards Grid */} + {projects && projects.length > 0 && ( + <> +
+ {projects.map(({ node }: { node: EconomicResource }) => ( + + ))} +
+ + {/* Load more */} + {hasNext && ( +
+ +
+ )} + + )} +
+
+
+ ); +} + +/** Reusable stat card for hero sections — compact prototype style */ +export function HeroStatCard({ value, label }: { icon?: ReactNode; value: string | number; label: string }) { + return ( +
+ + {value} + + + {label} + +
+ ); +} + +/** Stat icon wrapper for consistent sizing/coloring */ +export function StatIcon({ children, bgColor }: { children: ReactNode; bgColor: string }) { + return ( +
+ {children} +
+ ); +} diff --git a/components/CheckboxFilter.tsx b/components/CheckboxFilter.tsx new file mode 100644 index 00000000..43b87419 --- /dev/null +++ b/components/CheckboxFilter.tsx @@ -0,0 +1,81 @@ +import { Search } from "@carbon/icons-react"; +import { useMemo, useState } from "react"; + +interface CheckboxFilterProps { + items: string[]; + searchPlaceholder?: string; + selectedItems?: string[]; + onToggle?: (item: string) => void; +} + +export default function CheckboxFilter({ + items, + searchPlaceholder = "Search...", + selectedItems = [], + onToggle, +}: CheckboxFilterProps) { + const [search, setSearch] = useState(""); + + const selectedSet = useMemo(() => new Set(selectedItems.map(s => s.toLowerCase())), [selectedItems]); + + const filteredItems = useMemo( + () => (search ? items.filter(item => item.toLowerCase().includes(search.toLowerCase())) : items), + [items, search] + ); + + return ( +
+
+ + setSearch(e.target.value)} + placeholder={searchPlaceholder} + className="w-full h-9 pl-9 pr-3 bg-ifr-form-input border border-ifr-form-input rounded-ifr-sm focus:outline-none focus:border-ifr-green" + style={{ fontSize: "var(--ifr-fs-base)", lineHeight: "21px" }} + /> +
+
+ {filteredItems.map(item => { + const checked = selectedSet.has(item.toLowerCase()); + return ( +
onToggle?.(item)} + onKeyDown={e => { + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + onToggle?.(item); + } + }} + className="flex items-center gap-2 cursor-pointer" + > + + {checked && ( + + + + )} + + {item} +
+ ); + })} +
+
+ ); +} diff --git a/components/DetailSection.tsx b/components/DetailSection.tsx new file mode 100644 index 00000000..631df7f5 --- /dev/null +++ b/components/DetailSection.tsx @@ -0,0 +1,67 @@ +import { ChevronDown } from "@carbon/icons-react"; +import { ReactNode, useCallback, useEffect, useState } from "react"; + +interface DetailSectionProps { + icon: ReactNode; + iconBg: string; + title: string; + subtitle?: string; + badge?: ReactNode; + defaultOpen?: boolean; + sectionId?: string; + children: ReactNode; +} + +export default function DetailSection({ + icon, + iconBg, + title, + subtitle, + badge, + defaultOpen = false, + sectionId, + children, +}: DetailSectionProps) { + const [open, setOpen] = useState(defaultOpen); + + const handleOpenEvent = useCallback( + (e: Event) => { + if (sectionId && (e as CustomEvent).detail === sectionId) { + setOpen(true); + } + }, + [sectionId] + ); + + useEffect(() => { + window.addEventListener("open-section", handleOpenEvent); + return () => window.removeEventListener("open-section", handleOpenEvent); + }, [handleOpenEvent]); + + return ( +
+ + {open && ( + <> +
+
{children}
+ + )} +
+ ); +} diff --git a/components/DualRangeSlider.tsx b/components/DualRangeSlider.tsx new file mode 100644 index 00000000..1697d3b9 --- /dev/null +++ b/components/DualRangeSlider.tsx @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import React, { useCallback, useRef } from "react"; + +interface DualRangeSliderProps { + min: number; + max: number; + valueLow: number; + valueHigh: number; + step?: number; + unit?: string; + onChange: (low: number, high: number) => void; +} + +export default function DualRangeSlider({ + min, + max, + valueLow, + valueHigh, + step = 1, + unit = "", + onChange, +}: DualRangeSliderProps) { + const trackRef = useRef(null); + + const clamp = (v: number) => Math.round(Math.min(max, Math.max(min, v)) / step) * step; + + const pctLow = ((valueLow - min) / (max - min)) * 100; + const pctHigh = ((valueHigh - min) / (max - min)) * 100; + + const handlePointerDown = useCallback( + (handle: "low" | "high") => (e: React.PointerEvent) => { + e.preventDefault(); + const track = trackRef.current; + if (!track) return; + + const onMove = (ev: PointerEvent) => { + const rect = track.getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)); + const raw = min + pct * (max - min); + const val = clamp(raw); + if (handle === "low") { + onChange(Math.min(val, valueHigh - step), valueHigh); + } else { + onChange(valueLow, Math.max(val, valueLow + step)); + } + }; + + const onUp = () => { + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + }; + + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [min, max, step, valueLow, valueHigh, onChange] + ); + + const formatVal = (v: number) => `${v}${unit ? ` ${unit}` : ""}`; + + return ( +
+ {/* Track */} +
+ {/* Background track */} +
+ + {/* Active range */} +
+ + {/* Low handle */} +
+ + {/* High handle */} +
+
+ + {/* Labels */} +
+ + {formatVal(valueLow)} + + + {formatVal(valueHigh)} + +
+
+ ); +} diff --git a/components/EntityTypeIcon.tsx b/components/EntityTypeIcon.tsx new file mode 100644 index 00000000..9abd6880 --- /dev/null +++ b/components/EntityTypeIcon.tsx @@ -0,0 +1,103 @@ +import { ProjectType } from "./types"; + +interface EntityTypeIconProps { + type: ProjectType; + size?: "default" | "small"; + className?: string; + fill?: string; +} + +// SVG path data extracted from DTEC 03/2026 prototype +const svgPaths = { + // Design icon - compass/pen-nib + design16: + "M11 0L16 5L13 8V13L3 16L2.20711 15.2071L6.48196 10.9323C6.64718 10.9764 6.82084 11 7 11C8.10457 11 9 10.1046 9 9C9 7.89543 8.10457 7 7 7C5.89543 7 5 7.89543 5 9C5 9.17916 5.02356 9.35282 5.06774 9.51804L0.792893 13.7929L0 13L3 3H8L11 0Z", + design12: + "M8.25 0L12 3.75L9.75 6V9.75L2.25 12L1.65533 11.4053L4.86147 8.19922C4.98538 8.2323 5.11563 8.25 5.25 8.25C6.07843 8.25 6.75 7.57845 6.75 6.75C6.75 5.92157 6.07843 5.25 5.25 5.25C4.42157 5.25 3.75 5.92157 3.75 6.75C3.75 6.88437 3.76767 7.01461 3.8008 7.13853L0.59467 10.3447L0 9.75L2.25 2.25H6L8.25 0Z", + + // Product icon - price tag + product16: + "M15.6773 1.63893C15.659 1.29237 15.5149 0.964923 15.2728 0.719521C15.0307 0.474119 14.7077 0.328087 14.3658 0.309497L8.34938 0L0.507936 7.94844C0.182705 8.27821 0 8.72541 0 9.19171C0 9.658 0.182705 10.1052 0.507936 10.435L5.71244 15.7105C6.03777 16.0402 6.47895 16.2254 6.93896 16.2254C7.39898 16.2254 7.84016 16.0402 8.16549 15.7105L16 7.73742L15.6773 1.63893ZM13.1236 5.02229C12.9172 5.23423 12.6533 5.37917 12.3654 5.43867C12.0775 5.49818 11.7786 5.46956 11.5068 5.35646C11.235 5.24337 11.0024 5.05089 10.8388 4.80352C10.6752 4.55614 10.5878 4.26503 10.5878 3.96719C10.5878 3.66935 10.6752 3.37824 10.8388 3.13086C11.0024 2.88348 11.235 2.69101 11.5068 2.57791C11.7786 2.46481 12.0775 2.4362 12.3654 2.4957C12.6533 2.5552 12.9172 2.70014 13.1236 2.91208C13.3974 3.19315 13.5509 3.57222 13.5509 3.96719C13.5509 4.36216 13.3974 4.74123 13.1236 5.02229Z", + product12: + "M11.758 1.2292C11.7442 0.96928 11.6362 0.723692 11.4546 0.539641C11.273 0.355589 11.0308 0.246065 10.7743 0.232123L6.26204 0L0.380952 5.96133C0.137028 6.20866 0 6.54406 0 6.89378C0 7.2435 0.137028 7.5789 0.380952 7.82623L4.28433 11.7829C4.52832 12.0301 4.85921 12.169 5.20422 12.169C5.54923 12.169 5.88012 12.0301 6.12412 11.7829L12 5.80307L11.758 1.2292ZM9.84273 3.76672C9.68791 3.92568 9.48995 4.03438 9.27402 4.07901C9.05809 4.12363 8.83395 4.10217 8.63008 4.01735C8.42622 3.93252 8.25184 3.78817 8.12911 3.60264C8.00639 3.4171 7.94086 3.19877 7.94086 2.97539C7.94086 2.75201 8.00639 2.53368 8.12911 2.34815C8.25184 2.16261 8.42622 2.01826 8.63008 1.93343C8.83395 1.84861 9.05809 1.82715 9.27402 1.87178C9.48995 1.9164 9.68791 2.02511 9.84273 2.18406C10.0481 2.39486 10.1632 2.67916 10.1632 2.97539C10.1632 3.27162 10.0481 3.55592 9.84273 3.76672Z", + + // Service icon - multi-tool + service16: + "M15.5 5H14.793C14.665 5 14.537 5.049 14.439 5.146L11 8.586L7.414 5L10.853 1.561C10.951 1.463 11 1.335 11 1.207V0.5C11 0.224 10.776 0 10.5 0H6.793C6.665 0 6.537 0.049 6.439 0.146L3.146 3.439C3.049 3.537 3 3.665 3 3.793V8.586L0.146 11.44C0.049 11.537 0 11.665 0 11.793V12.207C0 12.335 0.049 12.463 0.146 12.561L3.439 15.854C3.537 15.951 3.665 16 3.793 16H4.207C4.335 16 4.463 15.951 4.561 15.854L7.414 13H12.207C12.335 13 12.463 12.951 12.561 12.854L15.854 9.561C15.951 9.463 16 9.335 16 9.207V5.5C16 5.224 15.776 5 15.5 5Z", + service12: + "M11.625 3.75H11.0948C10.9988 3.75 10.9028 3.78675 10.8293 3.8595L8.25 6.4395L5.5605 3.75L8.13975 1.17075C8.21325 1.09725 8.25 1.00125 8.25 0.90525V0.375C8.25 0.168 8.082 0 7.875 0H5.09475C4.99875 0 4.90275 0.03675 4.82925 0.1095L2.3595 2.57925C2.28675 2.65275 2.25 2.74875 2.25 2.84475V6.4395L0.1095 8.58C0.03675 8.65275 0 8.74875 0 8.84475V9.15525C0 9.25125 0.03675 9.34725 0.1095 9.42075L2.57925 11.8905C2.65275 11.9633 2.74875 12 2.84475 12H3.15525C3.25125 12 3.34725 11.9633 3.42075 11.8905L5.5605 9.75H9.15525C9.25125 9.75 9.34725 9.71325 9.42075 9.6405L11.8905 7.17075C11.9633 7.09725 12 7.00125 12 6.90525V4.125C12 3.918 11.832 3.75 11.625 3.75Z", + + // DPP icon - QR code composite (multiple paths) + dpp16: [ + { d: "M0 8.83697H7.11382V15.9508H0V8.83697Z" }, + { d: "M8.78757 0.0492897H15.9014V7.16311H8.78757V0.0492897Z" }, + { d: "M8.78757 8.83697H12.3445V12.3939H8.78757V8.83697Z" }, + { + d: "M7.11391 7.1632H0V0.0492897H7.11391V7.1632ZM2.40006 2.35079V4.86149H4.91076V2.35079H2.40006Z", + fillRule: "evenodd" as const, + }, + { d: "M12.4431 12.3939H16V15.9508H12.4431V12.3939Z" }, + ], + dpp12: [ + { d: "M0 6.62773H5.33537V11.9631H0V6.62773Z" }, + { d: "M6.59068 0.0369768H11.926V5.37234H6.59068V0.0369768Z" }, + { d: "M6.59068 6.62773H9.25836V9.29542H6.59068V6.62773Z" }, + { + d: "M5.33543 5.37241H0V0.0369768H5.33543V5.37241ZM1.80005 1.7631V3.64613H3.68307V1.7631H1.80005Z", + fillRule: "evenodd" as const, + }, + { d: "M9.33232 9.29541H12V11.9631H9.33232V9.29541Z" }, + ], +}; + +const iconConfig: Record = { + [ProjectType.DESIGN]: { viewBox16: "0 0 16 16", viewBox12: "0 0 12 12" }, + [ProjectType.PRODUCT]: { viewBox16: "0 0 16 16.2254", viewBox12: "0 0 12 12.169" }, + [ProjectType.SERVICE]: { viewBox16: "0 0 16 16", viewBox12: "0 0 12 12" }, + [ProjectType.DPP]: { viewBox16: "0 0 16 16", viewBox12: "0 0 12 12" }, + [ProjectType.MACHINE]: { viewBox16: "0 0 16 16", viewBox12: "0 0 12 12" }, +}; + +function getSinglePath(type: ProjectType, size: "default" | "small"): string | null { + const key = `${type.toLowerCase()}${size === "default" ? "16" : "12"}` as keyof typeof svgPaths; + const val = svgPaths[key]; + return typeof val === "string" ? val : null; +} + +function getMultiPaths( + type: ProjectType, + size: "default" | "small" +): Array<{ d: string; fillRule?: "evenodd" }> | null { + const key = `${type.toLowerCase()}${size === "default" ? "16" : "12"}` as keyof typeof svgPaths; + const val = svgPaths[key]; + return Array.isArray(val) ? val : null; +} + +export default function EntityTypeIcon({ + type, + size = "default", + className, + fill = "currentColor", +}: EntityTypeIconProps) { + const config = iconConfig[type]; + if (!config) return null; + + const viewBox = size === "default" ? config.viewBox16 : config.viewBox12; + const px = size === "default" ? 16 : 12; + const singlePath = getSinglePath(type, size); + const multiPaths = getMultiPaths(type, size); + + // Machine falls back to the Design icon (legacy type, no dedicated prototype icon) + if (type === ProjectType.MACHINE) { + return ; + } + + return ( + + {singlePath && } + {multiPaths?.map((p, i) => ( + + ))} + + ); +} diff --git a/components/FilterSection.tsx b/components/FilterSection.tsx new file mode 100644 index 00000000..a0b03409 --- /dev/null +++ b/components/FilterSection.tsx @@ -0,0 +1,39 @@ +import { ChevronDown } from "@carbon/icons-react"; +import { ReactNode, useState } from "react"; + +interface FilterSectionProps { + icon: ReactNode; + label: string; + children: ReactNode; + defaultOpen?: boolean; + badge?: number; +} + +export default function FilterSection({ icon, label, children, defaultOpen = false, badge }: FilterSectionProps) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + {open &&
{children}
} +
+ ); +} diff --git a/components/InterfacerLogo.tsx b/components/InterfacerLogo.tsx new file mode 100644 index 00000000..58ae76fb --- /dev/null +++ b/components/InterfacerLogo.tsx @@ -0,0 +1,21 @@ +interface InterfacerLogoProps { + className?: string; + color?: string; +} + +export default function InterfacerLogo({ className, color = "currentColor" }: InterfacerLogoProps) { + return ( + + + + + ); +} diff --git a/components/NavigationMenu.tsx b/components/NavigationMenu.tsx new file mode 100644 index 00000000..e3b307a3 --- /dev/null +++ b/components/NavigationMenu.tsx @@ -0,0 +1,498 @@ +import { ScanAlt } from "@carbon/icons-react"; +import { + BellIcon, + BookmarkIcon, + ChatIcon, + ChevronDownIcon, + ChevronUpIcon, + CogIcon, + DocumentTextIcon, + SupportIcon, + UploadIcon, + UserIcon, +} from "@heroicons/react/outline"; +import { LocationMarkerIcon } from "@heroicons/react/solid"; +import EntityTypeIcon from "components/EntityTypeIcon"; +import InterfacerLogo from "components/InterfacerLogo"; +import BrUserAvatar from "components/brickroom/BrUserAvatar"; +import { ProjectType } from "components/types"; +import { useAuth } from "hooks/useAuth"; +import useInBox from "hooks/useInBox"; +import { useTranslation } from "next-i18next"; +import { useRouter } from "next/router"; +import { useState } from "react"; + +/* ── Reusable sub-components ── */ + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function NavBadge({ value, color, textColor }: { value: string; color: string; textColor: string }) { + return ( + + {value} + + ); +} + +interface NavItemProps { + icon: React.ReactNode; + label: string; + active?: boolean; + onClick?: () => void; + expandable?: boolean; + expanded?: boolean; + onToggleExpand?: () => void; + badge?: React.ReactNode; + activeBg?: string; + activeTextColor?: string; +} + +function NavItem({ + icon, + label, + active = false, + onClick, + expandable = false, + expanded = false, + onToggleExpand, + badge, + activeBg, + activeTextColor, +}: NavItemProps) { + return ( + + ); +} + +function SubNavItem({ + label, + active = false, + onClick, + icon, +}: { + label: string; + active?: boolean; + onClick?: () => void; + icon?: React.ReactNode; +}) { + return ( + + ); +} + +function Divider() { + return
; +} + +/* ── Main component ── */ + +interface NavigationMenuProps { + open: boolean; + onClose: () => void; +} + +export default function NavigationMenu({ open, onClose }: NavigationMenuProps) { + const router = useRouter(); + const { user } = useAuth(); + const { t } = useTranslation("SideBarProps"); + const { unread } = useInBox(); + const [myProjectsExpanded, setMyProjectsExpanded] = useState(true); + + const handleNavigate = (path: string) => { + router.push(path); + onClose(); + }; + + const handleProfileTab = (tab: string) => { + if (user) { + router.push(`${user.profileUrl}?tab=${tab}`); + onClose(); + } + }; + + const isActive = (path: string) => router.asPath === path || router.pathname === path; + const isProfileTab = (tab: string) => { + if (!user) return false; + const url = router.asPath; + return url.startsWith(user.profileUrl) && url.includes(`tab=${tab}`); + }; + const isProfileDefault = user ? router.asPath === user.profileUrl : false; + + const iconColor = (active: boolean) => (active ? "var(--ifr-text-primary)" : "var(--ifr-text-secondary)"); + const entityIconColor = (path: string, entityColor: string) => + isActive(path) ? entityColor : "var(--ifr-text-secondary)"; + + return ( + <> + {/* Backdrop */} + {open && ( +
+ )} + + {/* Drawer */} + + + ); +} diff --git a/components/ProfilePageNew.tsx b/components/ProfilePageNew.tsx new file mode 100644 index 00000000..6a4441ec --- /dev/null +++ b/components/ProfilePageNew.tsx @@ -0,0 +1,895 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import { useQuery } from "@apollo/client"; +import { Add, ArrowRight, Search, SortAscending } from "@carbon/icons-react"; +import { ExternalLinkIcon, LocationMarkerIcon } from "@heroicons/react/outline"; +import BrUserAvatar from "components/brickroom/BrUserAvatar"; +import EntityTypeIcon from "components/EntityTypeIcon"; +import { useUser } from "components/layout/FetchUserLayout"; +import ProjectCardNew from "components/ProjectCardNew"; +import { ProjectType } from "components/types"; +import { useAuth } from "hooks/useAuth"; +import useFilters from "hooks/useFilters"; +import useLoadMore from "hooks/useLoadMore"; +import useDppApi from "lib/dpp"; +import type { DppDocument, ListDppsResponse } from "lib/dpp-types"; +import { FETCH_RESOURCES } from "lib/QueryAndMutation"; +import { FetchInventoryQuery } from "lib/types"; +import { useTranslation } from "next-i18next"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +// ─── Tab definitions ──────────────────────────────────────────────────────── + +type ProfileTabId = "designs" | "products" | "services" | "dpps" | "community"; + +interface TabDef { + id: ProfileTabId; + labelKey: string; + type: ProjectType; +} + +const tabs: TabDef[] = [ + { id: "designs", labelKey: "Designs", type: ProjectType.DESIGN }, + { id: "products", labelKey: "Products", type: ProjectType.PRODUCT }, + { id: "services", labelKey: "Services", type: ProjectType.SERVICE }, + { id: "dpps", labelKey: "DPPs", type: ProjectType.DPP }, +]; + +// ─── Per-tab CTA & toolbar config ─────────────────────────────────────────── + +interface TabCtaConfig { + ctaTitle: string; + ctaDescription: string; + createLabel: string; + createUrl: string; + searchPlaceholder: string; +} + +const tabCtaConfig: Record, TabCtaConfig> = { + designs: { + ctaTitle: "Publish your design documentation", + ctaDescription: + "Allow the world to see, build and replicate your design. Add all the relevant info to help other users discover your work.", + createLabel: "Create a new Design", + createUrl: "/create/project/design", + searchPlaceholder: "Search designs by name, tags...", + }, + products: { + ctaTitle: "Turn designs into manufacturable products", + ctaDescription: + "Create a product listing on Interfacer. Products are displayed inside the design page and in the Products Catalog.", + createLabel: "Create a new Product", + createUrl: "/create/project/product", + searchPlaceholder: "Search products by name, tags...", + }, + services: { + ctaTitle: "Offer your skills and services", + ctaDescription: + "List your workshop, lab, or service. Help makers find the equipment and expertise they need to build their projects.", + createLabel: "Create a new Service", + createUrl: "/create/project/service", + searchPlaceholder: "Search services by name, tags...", + }, + dpps: { + ctaTitle: "Create Digital Product Passports", + ctaDescription: + "Document the lifecycle, materials, and sustainability information of your products. Help consumers and regulators access transparent product data.", + createLabel: "Create a new DPP", + createUrl: "/dpps/new", + searchPlaceholder: "Search DPPs by batch ID, status...", + }, +}; + +// ─── Profile Tab Content ──────────────────────────────────────────────────── + +function ProfileTabContent({ + userId, + specId, + tabType, + isOwner, + ctaConfig, +}: { + userId: string; + specId?: string; + tabType: ProjectType; + isOwner: boolean; + ctaConfig: TabCtaConfig; +}) { + const { t } = useTranslation("common"); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState("latest"); + const [showSortMenu, setShowSortMenu] = useState(false); + + const filter = useMemo( + () => ({ + primaryAccountable: [userId], + conformsTo: specId ? [specId] : undefined, + }), + [userId, specId] + ); + + const dataQueryIdentifier = "economicResources"; + + const { loading, data, fetchMore, refetch, variables } = useQuery(FETCH_RESOURCES, { + variables: { last: 12, filter }, + }); + + const { loadMore, showEmptyState, items, getHasNextPage } = useLoadMore({ + fetchMore, + refetch, + variables, + data, + dataQueryIdentifier, + }); + + const projects = items; + const hasNext = !!getHasNextPage; + + return ( +
+ {/* CTA + Stats row (owner only) */} + {isOwner && ( +
+ {/* Left: CTA */} +
+
+

+ {t(ctaConfig.ctaTitle)} +

+

+ {t(ctaConfig.ctaDescription)} +

+
+ +
+
+ )} + + {/* Search & Sort toolbar */} +
+ {/* Search input */} +
+ + setSearchQuery(e.target.value)} + placeholder={t(ctaConfig.searchPlaceholder)} + className="flex-1 bg-transparent border-none outline-none text-ifr-text-primary" + style={{ + fontFamily: "var(--ifr-font-body)", + fontSize: "var(--ifr-fs-base)", + }} + /> +
+ + {/* Sort dropdown */} +
+ + {showSortMenu && ( +
+ {["latest", "oldest"].map(opt => ( + + ))} +
+ )} +
+ + {/* Create button (owner only) */} + {isOwner && ( + + + + {t(ctaConfig.createLabel)} + + + )} +
+ + {/* Results grid */} + {loading && !projects?.length ? ( +
+
+
+ ) : showEmptyState ? ( +
+ +

+ {t("No items yet")} +

+
+ ) : ( +
+ {projects?.map((edge: any) => ( + + ))} +
+ )} + + {/* Load more */} + {hasNext && ( +
+ +
+ )} +
+ ); +} + +// ─── DPPs Tab ─────────────────────────────────────────────────────────── + +function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwner: boolean; ctaConfig: TabCtaConfig }) { + const { t } = useTranslation("common"); + const dppApi = useDppApi(); + const [dpps, setDpps] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState("latest"); + const [showSortMenu, setShowSortMenu] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + dppApi + .listDpps({ createdBy: userId, limit: 50 }) + .then((res: ListDppsResponse) => { + if (!cancelled) { + setDpps(res.dpps || []); + setTotal(res.total || 0); + } + }) + .catch((err: Error) => { + console.error("Failed to load DPPs:", err); + if (!cancelled) { + setDpps([]); + setTotal(0); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [userId]); // eslint-disable-line react-hooks/exhaustive-deps + + const filteredDpps = useMemo(() => { + let items = dpps; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + items = items.filter( + d => + d.batchId?.toLowerCase().includes(q) || + d.status?.toLowerCase().includes(q) || + d.batchType?.toLowerCase().includes(q) || + d.productOverview?.productName?.value?.toLowerCase().includes(q) || + d.productOverview?.brandName?.value?.toLowerCase().includes(q) + ); + } + if (sortBy === "oldest") items = [...items].reverse(); + return items; + }, [dpps, searchQuery, sortBy]); + + const statusColors: Record = { + active: { bg: "var(--ifr-green)", text: "#fff" }, + draft: { bg: "var(--ifr-gray, #6C707C)", text: "#fff" }, + archived: { bg: "var(--ifr-yellow)", text: "var(--ifr-text-primary)" }, + }; + + return ( +
+ {/* CTA + Stats row (owner only) */} + {isOwner && ( +
+
+
+

+ {t(ctaConfig.ctaTitle)} +

+

+ {t(ctaConfig.ctaDescription)} +

+
+ +
+
+ )} + + {/* Search & Sort toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t(ctaConfig.searchPlaceholder)} + className="flex-1 bg-transparent border-none outline-none text-ifr-text-primary" + style={{ fontFamily: "var(--ifr-font-body)", fontSize: "var(--ifr-fs-base)" }} + /> +
+ +
+ + {showSortMenu && ( +
+ {["latest", "oldest"].map(opt => ( + + ))} +
+ )} +
+ + {isOwner && ( + + + + {t(ctaConfig.createLabel)} + + + )} +
+ + {/* Results */} + {loading ? ( +
+
+
+ ) : filteredDpps.length === 0 ? ( +
+

+ {t("No DPPs yet")} +

+
+ ) : ( +
+ {/* Table header */} +
+ {t("Product")} + {t("Batch / Serial")} + {t("Type")} + {t("Status")} + {t("Created")} +
+ + {/* Table rows */} + {filteredDpps.map(dpp => { + const productName = + dpp.productOverview?.productName?.value || dpp.productOverview?.brandName?.value || t("Untitled DPP"); + const status = dpp.status || "draft"; + const colors = statusColors[status] || statusColors.draft; + + return ( +
+ + {productName} + + {dpp.batchId || "—"} + + + {dpp.batchType === "unit" ? t("Unit") : t("Batch")} + + + + + {t(status.charAt(0).toUpperCase() + status.slice(1))} + + + + {dpp.createdAt ? new Date(dpp.createdAt).toLocaleDateString() : "—"} + +
+ ); + })} +
+ )} +
+ ); +} + +// ─── Community Tab ────────────────────────────────────────────────────────── + +function CommunityTabContent() { + const { t } = useTranslation("common"); + + return ( +
+

+ {t("Community features coming soon")} +

+
+ ); +} + +// ─── Main Profile Page ────────────────────────────────────────────────────── + +export default function ProfilePageNew() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { person, id } = useUser(); + const { user } = useAuth(); + const { designId, productId, serviceId } = useFilters(); + + const isOwner = user?.ulid === id; + + // Tab state from URL + const tabParam = (router.query.tab as string) || "designs"; + const activeTab: ProfileTabId = ( + ["designs", "products", "services", "dpps", "community"].includes(tabParam) ? tabParam : "designs" + ) as ProfileTabId; + + const setActiveTab = useCallback( + (tab: ProfileTabId) => { + router.push({ pathname: router.pathname, query: { ...router.query, tab } }, undefined, { shallow: true }); + }, + [router] + ); + + // Spec ID for filtering + const specIdMap: Record = { + designs: designId, + products: productId, + services: serviceId, + }; + + return ( +
+
+ {/* Profile Header */} +
+ {/* Avatar */} +
+
+ +
+
+ + {/* Info */} +
+
+
+ {/* Name + verified */} +
+

+ {isOwner ? `${t("Hi,")} ${person?.user || person?.name}` : person?.user || person?.name} +

+ {person?.isVerified && ( + + {t("Verified")} + + )} +
+ + {/* Subtitle (owner) */} + {isOwner && ( +

+ {t("Manage and track all your project documentation")} +

+ )} + + {/* Bio */} + {person?.note && ( +

+ {person.note} +

+ )} + + {/* Meta row */} +
+ {person?.primaryLocation?.name && ( +
+ + + {person.primaryLocation.name} + +
+ )} + {person?.email && ( +
+ + + {person.email} + +
+ )} +
+
+ + {/* Action buttons */} +
+ {isOwner ? ( + + + {t("Edit Profile")} + + + ) : ( + + )} +
+
+
+
+ + {/* Tab Navigation */} +
+ {tabs.map(tab => ( + + ))} + +
+ + {/* Tab Content */} + {activeTab === "community" ? ( + + ) : activeTab === "dpps" ? ( + + ) : ( + t.id === activeTab)?.type || ProjectType.DESIGN} + isOwner={isOwner} + ctaConfig={tabCtaConfig[activeTab as Exclude]} + /> + )} +
+
+ ); +} diff --git a/components/ProjectCardNew.tsx b/components/ProjectCardNew.tsx new file mode 100644 index 00000000..67759384 --- /dev/null +++ b/components/ProjectCardNew.tsx @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import { BookmarkIcon, ClockIcon, ExternalLinkIcon, LocationMarkerIcon, StarIcon } from "@heroicons/react/outline"; +import { StarIcon as StarIconSolid } from "@heroicons/react/solid"; +import { useAuth } from "hooks/useAuth"; +import useSocial from "hooks/useSocial"; +import useWallet from "hooks/useWallet"; +import findProjectImages from "lib/findProjectImages"; +import { isProjectType } from "lib/isProjectType"; +import { IdeaPoints } from "lib/PointsDistribution"; +import { EconomicResource } from "lib/types"; +import { useTranslation } from "next-i18next"; +import Link from "next/link"; +import React from "react"; +import BrUserAvatar from "./brickroom/BrUserAvatar"; +import EntityTypeIcon from "./EntityTypeIcon"; +import ProjectCardImage from "./ProjectCardImage"; +import { ProjectType } from "./types"; + +interface ProjectCardNewProps { + project: Partial; +} + +const entityTypeColors: Record = { + [ProjectType.DESIGN]: "var(--ifr-green)", + [ProjectType.PRODUCT]: "var(--ifr-type-product)", + [ProjectType.SERVICE]: "var(--ifr-type-service)", + [ProjectType.DPP]: "var(--ifr-type-dpp)", +}; + +const entityTypeBg: Record = { + [ProjectType.DESIGN]: "var(--ifr-green)", + [ProjectType.PRODUCT]: "var(--ifr-type-product)", + [ProjectType.SERVICE]: "var(--ifr-type-service)", + [ProjectType.DPP]: "var(--ifr-type-dpp)", +}; + +function getProjectType(project: Partial): ProjectType { + const name = project.conformsTo?.name; + if (!name) return ProjectType.DESIGN; + const check = isProjectType(name); + if (check[ProjectType.PRODUCT]) return ProjectType.PRODUCT; + if (check[ProjectType.SERVICE]) return ProjectType.SERVICE; + if (check[ProjectType.DPP]) return ProjectType.DPP; + if (check[ProjectType.MACHINE]) return ProjectType.MACHINE; + return ProjectType.DESIGN; +} + +function humanizeSlug(slug: string): string { + return slug + .replace(/^[a-z]+-/, "") + .split("-") + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatCount(count: number): string { + if (count === 0) return "0"; + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.floor(count / 1000)}k`; + return `${(count / 1000000).toFixed(1)}M`; +} + +const SERVICE_TYPE_MAP: Record = { + fabrication: "Fabrication", + "learning-&-education": "Learning & Education", + "space-access": "Space Access", +}; + +function detectServiceType(classifiedAs: string[]): string | undefined { + for (const tag of classifiedAs) { + if (tag.startsWith("category-")) { + const slug = tag.replace("category-", ""); + if (SERVICE_TYPE_MAP[slug]) return SERVICE_TYPE_MAP[slug]; + } + } + return undefined; +} + +export default function ProjectCardNew({ project }: ProjectCardNewProps) { + const { t } = useTranslation("common"); + const { user: authUser } = useAuth(); + const { likeER, isLiked, erFollowerLength } = useSocial(project.id); + const { addIdeaPoints } = useWallet({}); + const [bookmarked, setBookmarked] = React.useState(false); + + const projectType = getProjectType(project); + const images = findProjectImages(project); + const user = project.primaryAccountable; + const hasStarred = project.id ? isLiked(project.id) : false; + const displayCount = formatCount(erFollowerLength); + + // Extract tags (filter out encoded machine/material tags) + const tags = (project.classifiedAs || []) + .filter( + tag => + !tag.startsWith("machine-") && + !tag.startsWith("material-") && + !tag.startsWith("power_") && + !tag.startsWith("replicability-") && + !tag.startsWith("recyclability-") && + !tag.startsWith("repairability") && + !tag.startsWith("env_") + ) + .map(tag => (tag.startsWith("category-") ? humanizeSlug(tag) : decodeURIComponent(tag))) + .slice(0, 4); + + // Design-specific: requirements + const machineTags = (project.classifiedAs || []).filter(tag => tag.startsWith("machine-")).map(humanizeSlug); + const requirements = machineTags.length > 0 ? machineTags.join(", ") : undefined; + + // Product-specific: materials + const materialTags = (project.classifiedAs || []).filter(tag => tag.startsWith("material-")).map(humanizeSlug); + + // License + const license = project.license || project.metadata?.licenses?.[0]?.licenseId; + + const handleStar = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!authUser) return; + await likeER(); + if (project.primaryAccountable?.id) { + addIdeaPoints(project.primaryAccountable.id, IdeaPoints.OnStar); + } + }; + + return ( + + +
+ {/* Image Section */} +
+ + + {/* Gradient overlay */} +
+ + {/* Type label — collapses to icon-only, expands on hover */} +
+
+ + + {projectType} + +
+
+ + {/* Bookmark */} +
+ +
+ + {/* Author + Star count */} +
+ {user && ( +
+
+ +
+ + {user.name} + +
+ )} + + {/* Star count */} +
+ {hasStarred ? ( + + ) : ( + + )} + + {displayCount} + +
+
+
+ + {/* Content Section */} +
+ {/* Title + Description */} +
+

+ {project.name} +

+

+ {project.note} +

+
+ + {/* Tags */} + {tags.length > 0 && ( +
+ {tags.map(tag => ( + + {tag} + + ))} +
+ )} + + {/* DESIGN footer */} + {projectType === ProjectType.DESIGN && ( + <> + {requirements && ( +
+ )} + {license && ( +
+ + {t("LICENSE: {{license}}", { license })} + +
+ )} + + )} + + {/* PRODUCT footer */} + {projectType === ProjectType.PRODUCT && ( + <> + {project.metadata?.basedOnDesign && ( +
+ + + {t("Based on:")}{" "} + + {String(project.metadata.basedOnDesign.name || project.metadata.basedOnDesign)} + + +
+ )} + {materialTags.length > 0 && ( +
+ {materialTags.slice(0, 4).map(mat => ( + + {mat} + + ))} + {materialTags.length > 4 && ( + +{materialTags.length - 4} + )} +
+ )} + + )} + + {/* SERVICE footer */} + {projectType === ProjectType.SERVICE && ( + <> + {(() => { + const serviceType = detectServiceType(project.classifiedAs || []); + return serviceType ? ( +
+ + {serviceType} + +
+ ) : null; + })()} +
+ {project.currentLocation && ( +
+ + + {project.currentLocation.name} + +
+ )} +
+ + + {t("Available")} + +
+
+ + )} + + {/* Hover action links */} +
+ + {t("Show {{type}}", { type: projectType.toLowerCase() })} + + +
+
+
+ + + ); +} diff --git a/components/ProjectDetailNew.tsx b/components/ProjectDetailNew.tsx new file mode 100644 index 00000000..da97704e --- /dev/null +++ b/components/ProjectDetailNew.tsx @@ -0,0 +1,2078 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import { useQuery } from "@apollo/client"; +import { ChevronDown, ChevronLeft, ChevronRight } from "@carbon/icons-react"; +import { BookmarkIcon, ExternalLinkIcon, StarIcon } from "@heroicons/react/outline"; +import BrUserAvatar from "components/brickroom/BrUserAvatar"; +import DetailMap from "components/DetailMap"; +import DetailSection from "components/DetailSection"; +import EntityTypeIcon from "components/EntityTypeIcon"; +import { useProject } from "components/layout/FetchProjectLayout"; +import ProjectCardImage from "components/ProjectCardImage"; +import ProjectsCards from "components/ProjectsCards"; +import { ProjectType } from "components/types"; +import { useAuth } from "hooks/useAuth"; + +import { SEARCH_PROJECT } from "components/ProjectDisplay"; +import useDppApi from "lib/dpp"; +import type { DppDocument } from "lib/dpp-types"; +import findProjectImages from "lib/findProjectImages"; +import { isProjectType } from "lib/isProjectType"; +import MdParser from "lib/MdParser"; + +import { EconomicResource } from "lib/types"; +import { useTranslation } from "next-i18next"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { ReactNode, useEffect, useMemo, useState } from "react"; + +function getProjectType(project: Partial): ProjectType { + const name = project.conformsTo?.name; + if (!name) return ProjectType.DESIGN; + const check = isProjectType(name); + if (check[ProjectType.PRODUCT]) return ProjectType.PRODUCT; + if (check[ProjectType.SERVICE]) return ProjectType.SERVICE; + if (check[ProjectType.DPP]) return ProjectType.DPP; + if (check[ProjectType.MACHINE]) return ProjectType.MACHINE; + return ProjectType.DESIGN; +} + +const typeColors: Record = { + [ProjectType.DESIGN]: "var(--ifr-green)", + [ProjectType.PRODUCT]: "var(--ifr-type-product)", + [ProjectType.SERVICE]: "var(--ifr-type-service)", + [ProjectType.DPP]: "var(--ifr-type-dpp)", +}; + +interface ProjectSidebarNewProps { + project: Partial; + projectType: ProjectType; +} + +/** Redesigned sidebar following DTEC prototype */ +function ProjectSidebarNew({ project, projectType }: ProjectSidebarNewProps) { + const { t } = useTranslation("common"); + const { user } = useAuth(); + + // Extract metadata fields (product-specific) + const meta = (project.metadata || {}) as Record; + const price = meta.price as string | undefined; + const availability = meta.availability as string | undefined; + const websiteLink = meta.websiteLink as string | undefined; + const basedOnDesignMeta = meta.basedOnDesign as { id?: string; name?: string } | string | undefined; + const designId = basedOnDesignMeta + ? typeof basedOnDesignMeta === "object" + ? basedOnDesignMeta.id + : undefined + : (meta.design as string | undefined); + + // Resolve design name when we only have an ID from metadata.design + const { data: designData } = useQuery(SEARCH_PROJECT, { + variables: { id: designId! }, + skip: !designId || (typeof basedOnDesignMeta === "object" && !!basedOnDesignMeta.name), + }); + + const basedOnDesign = basedOnDesignMeta + ? basedOnDesignMeta + : designId + ? { id: designId, name: designData?.economicResource?.name || undefined } + : undefined; + + return ( +
+
+ {/* Price & CTA section */} +
+ {/* Product: Price & Availability */} + {projectType === ProjectType.PRODUCT && price && ( +
+
+

+ {price} +

+ + {t("estimated")} + +
+ {availability && ( +
+
+ + {availability} + +
+ )} + + {t("Contact the manufacturer for accurate pricing and availability details.")} + +
+ )} + + {projectType === ProjectType.DESIGN && + basedOnDesign && + typeof basedOnDesign === "object" && + basedOnDesign.id ? ( + + + {t("Build It Yourself")} + + + ) : projectType === ProjectType.DESIGN ? ( + + ) : null} + {projectType === ProjectType.PRODUCT && project.primaryAccountable?.name ? ( + + {t("Contact Manufacturer")} + + ) : projectType === ProjectType.PRODUCT ? ( + + ) : null} + {projectType === ProjectType.PRODUCT && + (websiteLink ? ( + + + {t("Visit Store")} + + ) : ( + + ))} + {projectType === ProjectType.SERVICE && ( + + )} +
+ + {/* Created by / Manufactured by */} + {project.primaryAccountable && ( + <> +
+ + + )} + + {/* Based on design — products only */} + {projectType === ProjectType.PRODUCT && basedOnDesign && ( + <> +
+
+

+ {t("Based on open source design")} +

+ {typeof basedOnDesign === "object" && basedOnDesign.id ? ( + + +
+ +
+ + {basedOnDesign.name || t("Design")} + + +
+ + ) : ( +
+
+ +
+ + {typeof basedOnDesign === "string" ? basedOnDesign : basedOnDesign.name || t("Design")} + + +
+ )} +
+ + )} + + {/* Save + Watch */} +
+
+ + +
+
+
+ ); +} + +/** Image gallery with thumbnail strip */ +function ImageGallery({ images }: { images: string[] }) { + const [activeIndex, setActiveIndex] = useState(0); + const { t } = useTranslation("common"); + + if (!images.length) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Main image */} +
+ + + {/* Navigation arrows */} + {images.length > 1 && ( + <> + + + + {/* Counter */} +
+ {activeIndex + 1}/{images.length} +
+ + )} +
+ + {/* Thumbnails */} + {images.length > 1 && ( +
+ {images.map((src, i) => ( + + ))} +
+ )} +
+ ); +} + +/** Tag badge */ +function TagBadgeDetail({ text }: { text: string }) { + return ( + + {text} + + ); +} + +/** DPP field row */ +function DppFieldRow({ label, value }: { label: string; value?: string }) { + if (!value) return null; + return ( +
+ + {label} + + + {value} + +
+ ); +} + +/** Collapsible DPP subsection with colored icon */ +function DppSubsection({ + icon, + iconBg, + title, + subtitle, + children, +}: { + icon: ReactNode; + iconBg: string; + title: string; + subtitle: string; + children: ReactNode; +}) { + const [open, setOpen] = useState(true); + return ( +
+ + {open && ( + <> +
+
{children}
+ + )} +
+ ); +} + +/** Check if any of the given fields have values in the dpp object */ +function hasAnyField(dpp: Record, fields: string[]): boolean { + return fields.some(f => dpp[f] !== undefined && dpp[f] !== null && dpp[f] !== ""); +} + +/** Sustainability metric card */ +function MetricCard({ + icon, + iconBg, + label, + value, + unit, +}: { + icon: ReactNode; + iconBg: string; + label: string; + value: string | undefined; + unit: string; +}) { + if (!value) return null; + return ( +
+
+ {icon} +
+
+

+ {label} +

+

+ {value} {unit} +

+
+
+ ); +} + +/** Sustainability metric cards grid */ +function SustainabilityMetrics({ dpp }: { dpp: Record }) { + const { t } = useTranslation("common"); + + const leafIcon = ( + + + + ); + + const boltIcon = ( + + + + ); + + const metrics = [ + { label: t("Energy Consumption"), value: dpp.energyConsumption, unit: "kWh", icon: boltIcon, iconBg: "#A65F00" }, + { label: t("CO\u2082 Emissions"), value: dpp.co2eEmissions, unit: "kg", icon: leafIcon, iconBg: "#036A53" }, + { label: t("Water Consumption"), value: dpp.waterConsumption, unit: "L", icon: leafIcon, iconBg: "#036A53" }, + { label: t("Chemical Consumption"), value: dpp.chemicalConsumption, unit: "kg", icon: leafIcon, iconBg: "#036A53" }, + ].filter(m => m.value); + + if (metrics.length === 0) return null; + + return ( +
+ {metrics.map(m => ( + + ))} +
+ ); +} + +/** Categorized DPP display with collapsible subsections */ +function DppDisplay({ dpp }: { dpp: Record }) { + const { t } = useTranslation("common"); + + const overviewFields = [ + "brandName", + "productName", + "countrySale", + "countryOrigin", + "dimensions", + "modelName", + "netWeight", + "conditionProduct", + "warrantyDuration", + ]; + const complianceFields = ["ceMarking", "rohsCompliance"]; + const envFields = ["energyConsumption", "co2eEmissions", "waterConsumption", "chemicalConsumption"]; + const energyFields = [ + "maxPower", + "maxVoltage", + "maxCurrent", + "batteryType", + "batteryChargingTime", + "batteryLife", + "chargerType", + "powerRating", + "dcVoltage", + ]; + const repairFields = ["sparePartsAvailability", "reasonForRepair", "performedAction", "materialsUsed"]; + + return ( +
+ {/* Overview */} + {hasAnyField(dpp, overviewFields) && ( + + + + + } + iconBg="#1447E6" + title={t("DPP Overview")} + subtitle={t("Basic product information and identification")} + > + + + + + + + + + + + )} + + {/* Compliance & Certifications */} + {hasAnyField(dpp, complianceFields) && ( + + + + } + iconBg="#0B1324" + title={t("Compliance & Certifications")} + subtitle={t("Regulatory compliance and standards")} + > + + + + )} + + {/* Environmental Impact */} + {hasAnyField(dpp, envFields) && ( + + + + } + iconBg="#036A53" + title={t("Environmental Impact")} + subtitle={t("Energy, emissions, and resource consumption")} + > + + + + + + )} + + {/* Energy & Power */} + {hasAnyField(dpp, energyFields) && ( + + + + } + iconBg="#A65F00" + title={t("Energy & Power")} + subtitle={t("Power consumption and electrical specifications")} + > + + + + + + + + + + + )} + + {/* Repairability */} + {hasAnyField(dpp, repairFields) && ( + + + + } + iconBg="#8200DB" + title={t("Repairability")} + subtitle={t("Repair availability and spare parts")} + > + + + + + + )} +
+ ); +} + +/** Card for a single DPP in the Digital Product Passports list. */ +function DppListCard({ dpp, index, color }: { dpp: DppDocument; index: number; color: string }) { + const { t } = useTranslation("common"); + const [expanded, setExpanded] = useState(false); + + const label = dpp.productOverview?.productName?.value || `DPP-${String(index + 1).padStart(3, "0")}`; + const batchLabel = dpp.batchType === "unit" ? t("Unit") : t("Batch"); + const dateStr = dpp.createdAt + ? new Date(dpp.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) + : ""; + + return ( +
+
+ {/* DPP icon */} +
+ + + + +
+ + {/* Name + batch + serial + date */} +
+

+ {label} +

+
+ + {batchLabel} + + {dpp.batchId && ( + + {dpp.batchId} + + )} + {dateStr && ( + + {t("Published")} {dateStr} + + )} +
+
+ + {/* View DPP button */} + +
+ + {/* Expanded detail */} + {expanded && ( +
+ +
+ )} +
+ ); +} + +/** Renders DPP document fields in a readable format. */ +function DppDocumentDetail({ dpp }: { dpp: DppDocument }) { + const { t } = useTranslation("common"); + + const sections: { title: string; fields: { label: string; value: any }[] }[] = []; + + // Product Overview + if (dpp.productOverview) { + const po = dpp.productOverview; + const fields = [ + { label: t("Brand"), value: po.brandName?.value }, + { label: t("Product Name"), value: po.productName?.value }, + { label: t("Description"), value: po.productDescription?.value }, + { label: t("Model"), value: po.modelName?.value }, + { label: t("GTIN"), value: po.gtin?.value }, + { label: t("Country of Origin"), value: po.countryOfOrigin?.value }, + { label: t("Country of Sale"), value: po.countryOfSale?.value }, + { label: t("Color"), value: po.color?.value }, + { label: t("Dimensions"), value: po.dimensions?.value }, + { label: t("Net Weight"), value: po.netWeight?.value }, + { label: t("Condition"), value: po.conditionOfTheProduct?.value }, + { label: t("Warranty"), value: po.warrantyDuration?.value }, + ].filter(f => f.value != null && f.value !== ""); + if (fields.length > 0) sections.push({ title: t("Product Overview"), fields }); + } + + // Compliance + if (dpp.complianceAndStandards) { + const cs = dpp.complianceAndStandards; + const fields = [ + { label: t("CE Marking"), value: cs.ceMarking?.value }, + { label: t("RoHS Compliance"), value: cs.rohsCompliance?.value }, + ].filter(f => f.value != null && f.value !== ""); + if (fields.length > 0) sections.push({ title: t("Compliance & Standards"), fields }); + } + + // Environmental Impact + if (dpp.environmentalImpact) { + const ei = dpp.environmentalImpact; + const fields = [ + { + label: t("Energy Consumption"), + value: ei.energyConsumptionPerUnit?.value, + units: ei.energyConsumptionPerUnit?.units, + }, + { label: t("CO₂ Emissions"), value: ei.co2eEmissionsPerUnit?.value, units: ei.co2eEmissionsPerUnit?.units }, + { + label: t("Water Consumption"), + value: ei.waterConsumptionPerUnit?.value, + units: ei.waterConsumptionPerUnit?.units, + }, + { + label: t("Chemical Consumption"), + value: ei.chemicalConsumptionPerUnit?.value, + units: ei.chemicalConsumptionPerUnit?.units, + }, + ].filter(f => f.value != null && String(f.value) !== ""); + if (fields.length > 0) sections.push({ title: t("Environmental Impact"), fields }); + } + + // Energy Use + if (dpp.energyUseAndEfficiency) { + const eu = dpp.energyUseAndEfficiency; + const fields = [ + { label: t("Battery Type"), value: eu.batteryType?.value }, + { label: t("Power Rating"), value: eu.powerRating?.value, units: eu.powerRating?.units }, + { label: t("Max Voltage"), value: eu.maximumVoltage?.value, units: eu.maximumVoltage?.units }, + { label: t("Battery Life"), value: eu.batteryLife?.value, units: eu.batteryLife?.units }, + ].filter(f => f.value != null && f.value !== ""); + if (fields.length > 0) sections.push({ title: t("Energy Use & Efficiency"), fields }); + } + + // Reparability + if (dpp.reparability) { + const r = dpp.reparability; + const fields = [ + { label: t("Service & Repair Instructions"), value: r.serviceAndRepairInstructions?.value }, + { label: t("Spare Parts Availability"), value: r.availabilityOfSpareParts?.value }, + ].filter(f => f.value != null && f.value !== ""); + if (fields.length > 0) sections.push({ title: t("Reparability"), fields }); + } + + // Recyclability + if (dpp.recyclability) { + const rc = dpp.recyclability; + const fields = [ + { label: t("Recycling Instructions"), value: rc.recyclingInstructions?.value }, + { label: t("Material Composition"), value: rc.materialComposition?.value }, + { label: t("Substances of Concern"), value: rc.substancesOfConcern?.value }, + ].filter(f => f.value != null && f.value !== ""); + if (fields.length > 0) sections.push({ title: t("Recyclability"), fields }); + } + + if (sections.length === 0) { + return ( +

+ {t("No detailed data available for this passport.")} +

+ ); + } + + return ( +
+ {sections.map(s => ( +
+

+ {s.title} +

+
+ {s.fields.map(f => ( +
+ + {f.label} + + + {String(f.value)} + {(f as any).units ? ` ${(f as any).units}` : ""} + +
+ ))} +
+
+ ))} +
+ ); +} + +/** Banner showing this product was manufactured from an open source design */ +function DesignBanner({ designId, designName }: { designId?: string; designName?: string }) { + const { t } = useTranslation("common"); + const { data } = useQuery(SEARCH_PROJECT, { + variables: { id: designId! }, + skip: !designId || !!designName, + }); + const name = designName || data?.economicResource?.name || t("Design"); + const author = data?.economicResource?.primaryAccountable?.name; + + return ( +
+
+ +
+
+

+ {t("Manufactured from open source design")} +

+

+ {t("Based on")} {name} + {author && ( + <> + {" "} + {/* eslint-disable-next-line i18next/no-literal-string */} + {t("by")}: {author} + + )} +

+
+ {designId && ( + + + {t("View Design")} + + + + )} +
+ ); +} + +/** Main detail page content. Requires FetchProjectLayout wrapper. */ +export default function ProjectDetailNew() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { project, isOwner } = useProject(); + const projectType = getProjectType(project); + const color = typeColors[projectType] || "var(--ifr-green)"; + const images = useMemo(() => findProjectImages(project), [project]); + + // Internal tag prefixes to filter out + const internalPrefixes = [ + "machine-", + "material-", + "category-", + "power_compat-", + "mat:", + "c:", + "pc:", + "env:", + "pwr:", + "rep:", + "m:", + ]; + + // Decode tags + const tags = useMemo( + () => + (project.classifiedAs || []) + .filter((c: string) => !internalPrefixes.some(p => c.startsWith(p))) + .map((c: string) => decodeURIComponent(c)), + [project.classifiedAs] + ); + + const machines = useMemo( + () => + (project.classifiedAs || []) + .filter((c: string) => c.startsWith("machine-")) + .map((c: string) => + decodeURIComponent(c.replace("machine-", "")) + .split("-") + .filter(Boolean) + .map(p => p.charAt(0).toUpperCase() + p.slice(1)) + .join(" ") + ), + [project.classifiedAs] + ); + + const materials = useMemo( + () => + (project.classifiedAs || []) + .filter((c: string) => c.startsWith("material-")) + .map((c: string) => + decodeURIComponent(c.replace("material-", "")) + .split("-") + .filter(Boolean) + .map(p => p.charAt(0).toUpperCase() + p.slice(1)) + .join(" ") + ), + [project.classifiedAs] + ); + + // Fetch DPPs from interfacer-dpp API + const dppApi = useDppApi(); + const [productDpps, setProductDpps] = useState([]); + const [dppsLoading, setDppsLoading] = useState(false); + + useEffect(() => { + if (projectType !== ProjectType.PRODUCT || !project.id) return; + let cancelled = false; + setDppsLoading(true); + dppApi + .listDpps({ productId: project.id }) + .then(res => { + if (!cancelled) setProductDpps(res.dpps || []); + }) + .catch(() => { + if (!cancelled) setProductDpps([]); + }) + .finally(() => { + if (!cancelled) setDppsLoading(false); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project.id, projectType]); + + // Breadcrumb + const typeLabel = + projectType === ProjectType.DESIGN ? "Designs" : projectType === ProjectType.PRODUCT ? "Products" : "Services"; + const typeHref = `/${typeLabel.toLowerCase()}`; + + return ( +
+
+ {/* Main content */} +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+ {/* Type badge + ID */} +
+
+ + + {projectType} + +
+ {projectType === ProjectType.PRODUCT && ( + + + + + {t("Available")} + + )} +
+ + {/* Title */} +

+ {project.name} +

+
+ + {/* Actions */} +
+ {isOwner && ( + + + {t("Edit")} + + + )} +
+
+ + {/* Image gallery */} + + + {/* Manufactured from open source design banner */} + {projectType === ProjectType.PRODUCT && + (() => { + const meta = (project.metadata || {}) as Record; + const basedOn = meta.basedOnDesign as { id?: string; name?: string } | string | undefined; + const fallbackDesignId = meta.design as string | undefined; + const resolvedDesignId = basedOn + ? typeof basedOn === "object" + ? basedOn.id + : undefined + : fallbackDesignId; + const resolvedDesignName = basedOn + ? typeof basedOn === "object" + ? basedOn.name || t("Design") + : String(basedOn) + : undefined; + if (!basedOn && !fallbackDesignId) return null; + return ; + })()} + + {/* Collapsible sections */} +
+ {/* Overview */} + } + iconBg="bg-ifr-hover" + title={t("Overview")} + subtitle={t("Description and key features")} + defaultOpen + sectionId="overview" + > +
+ {project.note && ( +
+ )} + + {/* Tags */} + {tags.length > 0 && ( +
+ {tags.map((tag: string) => ( + + ))} +
+ )} +
+ + + {/* Equipment — designs */} + {(projectType === ProjectType.DESIGN || projectType === ProjectType.MACHINE) && machines.length > 0 && ( + + + + } + iconBg="bg-ifr-hover" + title={t("Equipment Needed")} + subtitle={t("{{count}} machines required", { count: machines.length })} + sectionId="equipment" + > +
+ {machines.map((m: string) => ( +
+ + + + + + {m} + +
+ ))} +
+
+ )} + + {/* Materials — designs and products */} + {materials.length > 0 && ( + + + + + + } + iconBg="bg-ifr-hover" + title={t("Materials")} + subtitle={t("{{count}} materials listed", { count: materials.length })} + sectionId="materials" + > +
+ {materials.map((m: string) => ( + + ))} +
+ {projectType === ProjectType.PRODUCT && + (() => { + const meta = (project.metadata || {}) as Record; + const basedOnDesign = meta.basedOnDesign as { id?: string; name?: string } | string | undefined; + return ( + basedOnDesign && + typeof basedOnDesign === "object" && + basedOnDesign.id && ( +

+ {t("Materials are inherited from the parent Design.")}{" "} + + + {t("See the full bill of materials")} + + +

+ ) + ); + })()} +
+ )} + + {/* Location — services and products with location */} + {project.currentLocation?.name && ( + + + + + } + iconBg="bg-ifr-hover" + title={t("Location")} + subtitle={project.currentLocation.name} + sectionId="location" + > +
+

+ {project.currentLocation.mappableAddress || project.currentLocation.name} +

+ {project.currentLocation.lat != null && project.currentLocation.long != null && ( +
+ +
+ )} +
+
+ )} + + {/* Sustainability Overview — products with DPP data */} + {projectType === ProjectType.PRODUCT && (project.metadata as Record)?.dpp && ( + + + + } + iconBg="bg-[rgba(3,106,83,0.1)]" + title={t("Sustainability Overview")} + subtitle={t("Environmental impact and resource consumption metrics")} + sectionId="sustainability" + > + ).dpp as Record} /> + {/* Recyclable / Repairable badges */} + {productDpps.length > 0 && + (() => { + const dpp = productDpps[0]; + const hasRecyclability = + dpp.recyclability && + (dpp.recyclability.recyclingInstructions?.value || dpp.recyclability.materialComposition?.value); + const hasReparability = + dpp.reparability && + (dpp.reparability.serviceAndRepairInstructions?.value || + dpp.reparability.availabilityOfSpareParts?.value); + if (!hasRecyclability && !hasReparability) return null; + return ( +
+ {hasRecyclability && ( + + + + + + + {t("Recyclable")} + + )} + {hasReparability && ( + + + + + {t("Repairable")} + + )} +
+ ); + })()} +
+ )} + + {/* Recycling Information — from DPP data */} + {projectType === ProjectType.PRODUCT && + productDpps.length > 0 && + (() => { + const dpp = productDpps[0]; + const rc = dpp.recyclability; + if (!rc) return null; + const hasContent = + rc.recyclingInstructions?.value || rc.materialComposition?.value || rc.substancesOfConcern?.value; + if (!hasContent) return null; + return ( + + + + + + } + iconBg="bg-[rgba(3,106,83,0.1)]" + title={t("Recycling Information")} + subtitle={t("How to recycle this product")} + sectionId="recycling" + > +
+ {rc.recyclingInstructions?.value && ( +

+ {String(rc.recyclingInstructions.value)} +

+ )} + {rc.materialComposition?.value && ( +
+

+ {t("Material Composition")} +

+

+ {String(rc.materialComposition.value)} +

+
+ )} + {rc.substancesOfConcern?.value && ( +
+

+ {t("Substances of Concern")} +

+

+ {String(rc.substancesOfConcern.value)} +

+
+ )} +
+
+ ); + })()} + + {/* Repair Information — from DPP data */} + {projectType === ProjectType.PRODUCT && + productDpps.length > 0 && + (() => { + const dpp = productDpps[0]; + const rep = dpp.reparability; + const ri = dpp.repairInformation; + const hasRep = rep?.serviceAndRepairInstructions?.value || rep?.availabilityOfSpareParts?.value; + const hasRi = ri?.reasonForRepair?.value || ri?.performedAction?.value || ri?.materialsUsed?.value; + if (!hasRep && !hasRi) return null; + return ( + + + + } + iconBg="bg-[rgba(130,0,219,0.1)]" + title={t("Repair Information")} + subtitle={t("How to repair this product")} + sectionId="repair" + > +
+ {rep?.serviceAndRepairInstructions?.value && ( +

+ {String(rep.serviceAndRepairInstructions.value)} +

+ )} + {rep?.availabilityOfSpareParts?.value && ( +
+

+ {t("Spare Parts Availability")} +

+

+ {String(rep.availabilityOfSpareParts.value)} +

+
+ )} + {ri?.performedAction?.value && ( +
+

+ {t("Repair Actions")} +

+

+ {String(ri.performedAction.value)} +

+
+ )} +
+
+ ); + })()} + + {/* Get It Made — designs, shows manufacturers */} + {projectType === ProjectType.DESIGN && ( + + + + + + + } + iconBg="bg-[#f1bd4d]" + title={t("Get It Made")} + subtitle={t("Local manufacturers and makerspaces that can produce this")} + sectionId="get-it-made" + > +

+ {t("Manufacturer listings will be available soon. Contact the designer for production enquiries.")} +

+
+ )} + + {/* Community Contributions */} + + + + + + } + iconBg="bg-[rgba(200,212,229,0.5)]" + title={t("Community Contributions")} + subtitle={t("Improvements and modifications from contributors")} + badge={ + (project.metadata as Record)?.contributors?.length > 0 ? ( + + {(project.metadata as Record).contributors.length} + + ) : undefined + } + sectionId="contributions" + > + {(project.metadata as Record)?.contributors?.length > 0 ? ( +
+
+ {((project.metadata as Record).contributors as string[]).map((userId: string) => ( + + + + + {userId.slice(0, 8)} + + + + ))} +
+
+ ) : ( +

+ {t("Community contributions would be displayed here...")} +

+ )} +
+ + {/* Included Projects — sub-assemblies from metadata.relations */} + {(() => { + const relations = (project.metadata as Record)?.relations; + if (!relations || !Array.isArray(relations) || relations.length === 0) return null; + return ( + + + + + + + } + iconBg="bg-[rgba(200,212,229,0.5)]" + title={`${t("Included Projects")} (${relations.length})`} + subtitle={t("Sub-assemblies and components used in this project")} + sectionId="included-projects" + > + + {t("No related projects found.")} +

+ } + /> +
+ ); + })()} + + {/* Product Passport — embedded metadata (legacy) */} + {projectType === ProjectType.PRODUCT && + (project.metadata as Record)?.dpp && + productDpps.length === 0 && ( + + + + + + + } + iconBg="bg-ifr-hover" + title={t("Product Passport")} + subtitle={t("Digital product passport data")} + sectionId="product-passport" + > + ).dpp as Record} /> + + )} + + {/* Digital Product Passports — fetched from interfacer-dpp API */} + {projectType === ProjectType.PRODUCT && (productDpps.length > 0 || dppsLoading) && ( + + + + + + + } + iconBg="bg-ifr-hover" + title={t("Digital Product Passports")} + subtitle={t("Traceability records for individual batches and units of this product")} + badge={ + productDpps.length > 0 ? ( + + {productDpps.length} + + ) : undefined + } + sectionId="digital-product-passports" + defaultOpen + > + {dppsLoading ? ( +

+ {t("Loading product passports...")} +

+ ) : ( +
+ {productDpps.map((dpp, idx) => ( + + ))} +
+ )} +
+ )} +
+
+ + {/* Sidebar */} +
+ +
+
+ + {/* Mobile sidebar */} +
+ +
+
+ ); +} diff --git a/components/ProjectTypeChip.tsx b/components/ProjectTypeChip.tsx index 703aa3ef..ea1e57ad 100644 --- a/components/ProjectTypeChip.tsx +++ b/components/ProjectTypeChip.tsx @@ -1,21 +1,13 @@ -import { Collaborate, DataDefinition, GroupObjectsNew, ToolKit } from "@carbon/icons-react"; import classNames from "classnames"; import { EconomicResource } from "lib/types"; import { useTranslation } from "next-i18next"; -import { ReactNode } from "react"; +import EntityTypeIcon from "./EntityTypeIcon"; import LinkWrapper from "./LinkWrapper"; import { ProjectTypeRenderProps } from "./ProjectTypeRenderProps"; import { ProjectType } from "./types"; // -const icons: Record = { - Design: , - Product: , - Service: , - Machine: , -}; - interface Props { project?: Partial; projectType?: ProjectType; @@ -44,7 +36,7 @@ export default function ProjectTypeChip(props: Props) { const baseChip = ( {renderProps?.label} - {renderProps && } + ); diff --git a/components/ProjectTypeRenderProps.tsx b/components/ProjectTypeRenderProps.tsx index 761e496b..4fe1e24d 100644 --- a/components/ProjectTypeRenderProps.tsx +++ b/components/ProjectTypeRenderProps.tsx @@ -1,9 +1,7 @@ -import { CarbonIconType, Collaborate, DataDefinition, GroupObjectsNew, ToolKit } from "@carbon/icons-react"; import { ProjectType } from "./types"; export type RenderProps = { label: string; - icon: CarbonIconType; classes: { bg: string; content: string; @@ -14,34 +12,38 @@ export type RenderProps = { export const ProjectTypeRenderProps: Record = { [ProjectType.DESIGN]: { label: ProjectType.DESIGN, - icon: GroupObjectsNew, classes: { - bg: "bg-[#E4CCE3]", - content: "text-[#413840] fill-[#413840]", - border: "border-[#C18ABF] ring-[#C18ABF]", + bg: "bg-ifr-green", + content: "text-white fill-white", + border: "border-[#014837] ring-[#014837]", }, }, [ProjectType.PRODUCT]: { label: ProjectType.PRODUCT, - icon: DataDefinition, classes: { - bg: "bg-[#FAE5B7]", - content: "text-[#614C1F] fill-[#614C1F]", - border: "border-[#614C1F] ring-[#614C1F]", + bg: "bg-ifr-product", + content: "text-white fill-white", + border: "border-[#0b1324] ring-[#0b1324]", }, }, [ProjectType.SERVICE]: { label: ProjectType.SERVICE, - icon: Collaborate, classes: { - bg: "bg-[#CDE0E4]", - content: "text-[#024960] fill-[#024960]", - border: "border-[#5D8CA0] ring-[#5D8CA0]", + bg: "bg-ifr-service", + content: "text-white fill-white", + border: "border-[#570093] ring-[#570093]", + }, + }, + [ProjectType.DPP]: { + label: ProjectType.DPP, + classes: { + bg: "bg-ifr-dpp", + content: "text-white fill-white", + border: "border-[#9e3c00] ring-[#9e3c00]", }, }, [ProjectType.MACHINE]: { label: ProjectType.MACHINE, - icon: ToolKit, classes: { bg: "bg-[#D4E5D7]", content: "text-[#2D5035] fill-[#2D5035]", diff --git a/components/ProjectTypeRoundIcon.tsx b/components/ProjectTypeRoundIcon.tsx index 1be1a426..66d64c29 100644 --- a/components/ProjectTypeRoundIcon.tsx +++ b/components/ProjectTypeRoundIcon.tsx @@ -1,14 +1,15 @@ +import EntityTypeIcon from "./EntityTypeIcon"; import { ProjectTypeRenderProps } from "./ProjectTypeRenderProps"; import { ProjectType } from "./types"; export default function ProjectTypeRoundIcon(props: { projectType?: ProjectType }) { const { projectType } = props; - const renderProps = ProjectTypeRenderProps[projectType || ProjectType.DESIGN]; + const type = projectType || ProjectType.DESIGN; + const renderProps = ProjectTypeRenderProps[type]; return (
- {/* @ts-ignore */} - {renderProps && } +
); } diff --git a/components/SearchProjects.tsx b/components/SearchProjects.tsx index 80c73555..240a0bd7 100644 --- a/components/SearchProjects.tsx +++ b/components/SearchProjects.tsx @@ -40,6 +40,7 @@ export default function SearchProjects(props: Props) { [ProjectType.SERVICE]: queryProjectTypes.instanceVariables.specs.specProjectService.id, [ProjectType.PRODUCT]: queryProjectTypes.instanceVariables.specs.specProjectProduct.id, [ProjectType.MACHINE]: queryProjectTypes.instanceVariables.specs.specMachine.id, + [ProjectType.DPP]: queryProjectTypes.instanceVariables.specs.specMachine.id, }; /* Formatting GraphQL query variables based on input */ diff --git a/components/StatCard.tsx b/components/StatCard.tsx new file mode 100644 index 00000000..7620cb72 --- /dev/null +++ b/components/StatCard.tsx @@ -0,0 +1,31 @@ +import { ReactNode } from "react"; + +interface StatItem { + label: string; + value: string | number; + icon?: ReactNode; + iconColor?: string; +} + +interface StatCardProps { + stats: StatItem[]; + className?: string; +} + +export default function StatCard({ stats, className }: StatCardProps) { + return ( +
+ {stats.map((stat, i) => ( +
+
+ {stat.icon && {stat.icon}} + {stat.label} +
+

+ {stat.value} +

+
+ ))} +
+ ); +} diff --git a/components/TagBadge.tsx b/components/TagBadge.tsx new file mode 100644 index 00000000..f499e134 --- /dev/null +++ b/components/TagBadge.tsx @@ -0,0 +1,16 @@ +interface TagBadgeProps { + text: string; + className?: string; +} + +export default function TagBadge({ text, className }: TagBadgeProps) { + return ( + + {text} + + ); +} diff --git a/components/ToggleSwitch.tsx b/components/ToggleSwitch.tsx new file mode 100644 index 00000000..26a30c5f --- /dev/null +++ b/components/ToggleSwitch.tsx @@ -0,0 +1,32 @@ +interface ToggleSwitchProps { + label: string; + description?: string; + checked: boolean; + onChange: (checked: boolean) => void; +} + +export default function ToggleSwitch({ label, description, checked, onChange }: ToggleSwitchProps) { + return ( + + ); +} diff --git a/components/ToolbarDropdown.tsx b/components/ToolbarDropdown.tsx new file mode 100644 index 00000000..2626948b --- /dev/null +++ b/components/ToolbarDropdown.tsx @@ -0,0 +1,75 @@ +import { ChevronDown } from "@carbon/icons-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface ToolbarDropdownProps { + label: string; + value: string; + options: string[]; + onChange: (value: string) => void; +} + +export default function ToolbarDropdown({ label, value, options, onChange }: ToolbarDropdownProps) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const handleClose = useCallback((e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener("mousedown", handleClose); + return () => document.removeEventListener("mousedown", handleClose); + }, [handleClose]); + + return ( +
+ + {label} + + + {open && ( +
+ {options.map(option => ( + + ))} +
+ )} +
+ ); +} diff --git a/components/UserDropdown.tsx b/components/UserDropdown.tsx new file mode 100644 index 00000000..2f8dda93 --- /dev/null +++ b/components/UserDropdown.tsx @@ -0,0 +1,196 @@ +import { Logout } from "@carbon/icons-react"; +import { BellIcon, BookmarkIcon, CogIcon, UserIcon } from "@heroicons/react/outline"; +import BrUserAvatar from "components/brickroom/BrUserAvatar"; +import { useAuth } from "hooks/useAuth"; +import useInBox from "hooks/useInBox"; +import { useTranslation } from "next-i18next"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +function MenuItem({ + icon, + label, + badge, + onClick, + href, +}: { + icon?: React.ReactNode; + label: string; + badge?: React.ReactNode; + onClick?: () => void; + href?: string; +}) { + const content = ( + + ); + + if (href) { + return ( + + {content} + + ); + } + return content; +} + +interface UserDropdownProps { + onClose: () => void; +} + +export default function UserDropdown({ onClose }: UserDropdownProps) { + const { user, logout } = useAuth(); + const { unread } = useInBox(); + const { t } = useTranslation("common"); + const router = useRouter(); + + if (!user) return null; + + const handleLogout = () => { + onClose(); + logout(); + }; + + const handleNavigate = (path: string) => { + onClose(); + router.push(path); + }; + + return ( + <> + {/* Backdrop */} +
+ + {/* Dropdown */} +
+ {/* User header */} +
+
+ +
+
+ + {user.name} + + + {`@${user.user}`} + +
+
+ + {/* Menu section 1 */} +
+ } + label={t("Notifications")} + onClick={() => handleNavigate("/notification")} + badge={ + unread ? ( + + + + {unread} + + + ) : undefined + } + /> + } + label={t("My list")} + onClick={() => handleNavigate(`${user.profileUrl}?tab=1`)} + /> + } + label={t("My profile")} + onClick={() => handleNavigate(user.profileUrl)} + /> +
+ + {/* Menu section 2 */} +
+ } + label={t("Account Settings", "Account Settings")} + onClick={() => handleNavigate(`${user.profileUrl}/edit`)} + /> +
+ + {/* Logout */} +
+ +
+
+ + ); +} diff --git a/components/layout/CreateProjectLayout.tsx b/components/layout/CreateProjectLayout.tsx index f66d5981..9b9108ea 100644 --- a/components/layout/CreateProjectLayout.tsx +++ b/components/layout/CreateProjectLayout.tsx @@ -14,7 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { Link as PLink, Text } from "@bbtgnn/polaris-interfacer"; +import { Link as PLink } from "@bbtgnn/polaris-interfacer"; +import { useAuth } from "hooks/useAuth"; import { useTranslation } from "next-i18next"; import Link from "next/link"; @@ -25,15 +26,16 @@ type LayoutProps = { const CreateProjectLayout: React.FunctionComponent = (layoutProps: LayoutProps) => { const { t } = useTranslation(); const { children } = layoutProps; + const { user } = useAuth(); return (
- + {"← "} - {t("Back to Project Creation")} + {t("Back to Profile")} diff --git a/components/layout/Layout.tsx b/components/layout/Layout.tsx index 4acdd9b8..d4bb2189 100644 --- a/components/layout/Layout.tsx +++ b/components/layout/Layout.tsx @@ -16,11 +16,9 @@ import classNames from "classnames"; import Topbar from "components/partials/topbar/Topbar"; -import { useRouter } from "next/router"; -import React, { ReactNode, useEffect } from "react"; +import React, { ReactNode } from "react"; import { useAuth } from "../../hooks/useAuth"; import Footer from "../Footer"; -import Sidebar from "../Sidebar"; type layoutProps = { children: ReactNode; @@ -30,17 +28,6 @@ type layoutProps = { const Layout: React.FunctionComponent = (layoutProps: layoutProps) => { const { bottomPadding = "lg" } = layoutProps; const { authenticated, loading } = useAuth(); - const router = useRouter(); - - // Closes sidebar automatically when route changes - useEffect(() => { - router.events.on("routeChangeComplete", () => { - let drawer = document.getElementById("my-drawer"); - if (drawer) { - (drawer as HTMLInputElement).checked = false; - } - }); - }, [router.events]); const calcBottomPadding = classNames({ "pb-0": bottomPadding === "none", @@ -58,17 +45,10 @@ const Layout: React.FunctionComponent = (layoutProps: layoutProps) return ( <> {!loading && ( -
- -
- -
{layoutProps?.children}
-
-
-
-
+
+ +
{layoutProps?.children}
+
)} diff --git a/components/layout/SearchLayout.tsx b/components/layout/SearchLayout.tsx index daba5dd4..6a7e2c72 100644 --- a/components/layout/SearchLayout.tsx +++ b/components/layout/SearchLayout.tsx @@ -15,10 +15,8 @@ // along with this program. If not, see . import Topbar from "components/partials/topbar/Topbar"; -import { useRouter } from "next/router"; -import React, { ReactNode, useEffect } from "react"; +import React, { ReactNode } from "react"; import { useAuth } from "../../hooks/useAuth"; -import Sidebar from "../Sidebar"; type layoutProps = { children: ReactNode; @@ -26,33 +24,15 @@ type layoutProps = { const Layout: React.FunctionComponent = (layoutProps: layoutProps) => { const { authenticated, loading } = useAuth(); - const router = useRouter(); - // Closes sidebar automatically when route changes - useEffect(() => { - router.events.on("routeChangeComplete", () => { - let drawer = document.getElementById("my-drawer"); - if (drawer) { - (drawer as HTMLInputElement).checked = false; - } - }); - }, [router.events]); - - if (!authenticated) return
{layoutProps?.children}
; + if (!authenticated) return
{layoutProps?.children}
; return ( <> {!loading && ( -
- -
- -
{layoutProps?.children}
-
-
-
+
+ +
{layoutProps?.children}
)} diff --git a/components/partials/create/dpp/CreateDppForm.tsx b/components/partials/create/dpp/CreateDppForm.tsx new file mode 100644 index 00000000..6a428a46 --- /dev/null +++ b/components/partials/create/dpp/CreateDppForm.tsx @@ -0,0 +1,799 @@ +import { useQuery } from "@apollo/client"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useAuth } from "hooks/useAuth"; +import { useResourceSpecs } from "hooks/useResourceSpecs"; +import useDppApi from "lib/dpp"; +import { UploadFileOnDPP } from "lib/fileUpload"; +import { FETCH_RESOURCES } from "lib/QueryAndMutation"; +import type { FetchInventoryQuery } from "lib/types"; +import { useTranslation } from "next-i18next"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import * as yup from "yup"; + +import LoadingOverlay from "components/LoadingOverlay"; +import { CollapsibleSection } from "components/partials/create/project/steps/DPPStep/components"; +import { dppStepSchema } from "components/partials/create/project/steps/DPPStep/schema"; +import { + CertificatesSection, + ComplianceSection, + ComponentInformationSection, + EconomicOperatorSection, + EnergyUseEfficiencySection, + EnvironmentalSection, + ProductOverviewSection, + RecyclabilitySection, + RecyclingInformationSection, + RefurbishmentInformationSection, + RepairabilitySection, + RepairInformationSection, +} from "components/partials/create/project/steps/DPPStep/sections"; +import type { DPPStepValues } from "components/partials/create/project/steps/DPPStep/types"; +import type { BatchType } from "lib/dpp-types"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface CreateDppFormValues { + batchType: BatchType; + batchId: string; + dpp: DPPStepValues; +} + +// ─── Schema ───────────────────────────────────────────────────────────────── + +const createDppSchema = () => + yup.object({ + batchType: yup.string().oneOf(["batch", "unit"]).required("Batch type is required"), + batchId: yup.string().required("Batch/Serial ID is required"), + dpp: dppStepSchema().required(), + }); + +const defaultValues: CreateDppFormValues = { + batchType: "batch", + batchId: "", + dpp: {} as DPPStepValues, +}; + +// ─── Nav Sections ─────────────────────────────────────────────────────────── + +interface DppNavSection { + id: string; + label: string; +} + +const navSections: DppNavSection[] = [ + { id: "select-product", label: "Select product" }, + { id: "identification", label: "Identification" }, + { id: "product-overview", label: "Product Overview" }, + { id: "repairability", label: "Repairability" }, + { id: "environmentalImpact", label: "Environmental Impact" }, + { id: "compliance", label: "Compliance & Standards" }, + { id: "certificates", label: "Certificates" }, + { id: "recyclability", label: "Recyclability" }, + { id: "energy", label: "Energy Use & Efficiency" }, + { id: "component", label: "Component Information" }, + { id: "economic-operator", label: "Economic Operator" }, + { id: "repair-info", label: "Repair Information" }, + { id: "refurbishment-info", label: "Refurbishment Information" }, + { id: "recycling-info", label: "Recycling Information" }, +]; + +// ─── Sidebar Nav ──────────────────────────────────────────────────────────── + +function CreateDppNav() { + const { t } = useTranslation("createProjectProps"); + const [activeId, setActiveId] = useState(""); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + } + }, + { rootMargin: "-20% 0px -60% 0px", threshold: 0 } + ); + + for (const section of navSections) { + const el = document.getElementById(section.id); + if (el) observer.observe(el); + } + + return () => observer.disconnect(); + }, []); + + const scrollTo = useCallback((id: string) => { + const el = document.getElementById(id); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + return ( + + ); +} + +// ─── Main Form ────────────────────────────────────────────────────────────── + +export default function CreateDppForm() { + const { t } = useTranslation("createProjectProps"); + const router = useRouter(); + const { user } = useAuth(); + const dppApi = useDppApi(); + const [loading, setLoading] = useState(false); + + // Product selection + const { specProjectProduct } = useResourceSpecs(); + const [selectedProduct, setSelectedProduct] = useState<{ id: string; name: string } | null>(null); + const [productSearch, setProductSearch] = useState(""); + const [productDropdownOpen, setProductDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + const productFilter = useMemo( + () => ({ + primaryAccountable: user?.ulid ? [user.ulid] : undefined, + conformsTo: specProjectProduct?.id ? [specProjectProduct.id] : undefined, + }), + [user?.ulid, specProjectProduct?.id] + ); + + const { data: productsData } = useQuery(FETCH_RESOURCES, { + variables: { last: 50, filter: productFilter }, + skip: !user?.ulid || !specProjectProduct?.id, + }); + + const userProducts = useMemo(() => { + const edges = productsData?.economicResources?.edges ?? []; + return edges + .map(e => e.node) + .filter(n => { + if (!productSearch) return true; + const q = productSearch.toLowerCase(); + return n.name.toLowerCase().includes(q) || n.id.toLowerCase().includes(q); + }); + }, [productsData, productSearch]); + + // Close product dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setProductDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + // Accordion states + const [overviewOpen, setOverviewOpen] = useState(true); + const [repairabilityOpen, setRepairabilityOpen] = useState(false); + const [environmentalOpen, setEnvironmentalOpen] = useState(false); + const [complianceOpen, setComplianceOpen] = useState(false); + const [certificatesOpen, setCertificatesOpen] = useState(false); + const [recyclabilityOpen, setRecyclabilityOpen] = useState(false); + const [energyOpen, setEnergyOpen] = useState(false); + const [componentOpen, setComponentOpen] = useState(false); + const [economicOpen, setEconomicOpen] = useState(false); + const [repairInfoOpen, setRepairInfoOpen] = useState(false); + const [refurbishmentOpen, setRefurbishmentOpen] = useState(false); + const [recyclingInfoOpen, setRecyclingInfoOpen] = useState(false); + + const formMethods = useForm({ + mode: "all", + resolver: yupResolver(createDppSchema()), + defaultValues, + }); + + const { handleSubmit, register, watch, formState } = formMethods; + const { isValid } = formState; + const batchType = watch("batchType"); + + // Process DPP values: upload Files to DPP backend, replace with URLs + async function processDppValues(obj: any): Promise { + if (obj === null || obj === undefined) return obj; + if (obj instanceof File) return UploadFileOnDPP(obj); + if (Array.isArray(obj)) return Promise.all(obj.map(item => processDppValues(item))); + if (typeof obj === "object") { + const processed: any = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const val = await processDppValues(obj[key]); + if (val && typeof val === "object" && "value" in val) { + if (val.value === null || val.value === undefined) continue; + } + processed[key] = val; + } + } + return processed; + } + return obj; + } + + async function onSubmit(values: CreateDppFormValues) { + setLoading(true); + try { + const processedDpp = await processDppValues(values.dpp); + const response = await dppApi.createDpp({ + ...processedDpp, + batchType: values.batchType, + batchId: values.batchId, + productId: selectedProduct?.id ?? "", + }); + await router.push(`/profile/${user?.ulid}?tab=dpps`); + } catch (err) { + console.error("Failed to create DPP:", err); + } finally { + setLoading(false); + } + } + + return ( + +
+
+
+ {/* Sidebar Nav */} +
+
+ +
+
+ + {/* Main Content */} +
+
+ {/* Header */} +
+

+ {t("Create Digital Product Passport")} +

+

+ {t( + "Document the lifecycle, materials, and sustainability information of your product. Help consumers and regulators access transparent product data." + )} +

+
+ + {/* Select Product Section */} +
+
+
+
+
+

+ {t("Select product")} * +

+

+ {t( + "A DPP must be linked to one of your published products. Select the product before filling in the passport data." + )} +

+
+ + {/* Selected product badge */} + {selectedProduct && ( +
+
+ + {selectedProduct.name} + + + {selectedProduct.id} + +
+ +
+ )} + + {/* Product search dropdown */} +
+ +
+
setProductDropdownOpen(true)} + > + + + + + { + setProductSearch(e.target.value); + setProductDropdownOpen(true); + }} + placeholder={t("Search by product name or ID…")} + className="flex-1 bg-transparent border-none outline-none text-ifr-text-primary placeholder:text-ifr-text-secondary h-full" + style={{ + fontFamily: "var(--ifr-font-body)", + fontSize: "var(--ifr-fs-base)", + }} + onFocus={() => setProductDropdownOpen(true)} + /> +
+ {productDropdownOpen && userProducts.length > 0 && ( +
+ {userProducts.map(product => { + const isSelected = selectedProduct?.id === product.id; + return ( + + ); + })} +
+ )} +
+

+ {t( + "Only your published products are listed. A DPP cannot be created without a parent product." + )} +

+
+
+
+
+ + {/* Identification Section */} +
+
+
+
+

+ {t("Identification")} +

+ + {/* Batch Type */} +
+ +
+ {(["batch", "unit"] as const).map(type => ( + + ))} +
+

+ {batchType === "batch" + ? t("A DPP covering a batch of identical items (e.g., production run)") + : t("A DPP for a single, individually tracked item")} +

+
+ + {/* Batch / Serial ID */} +
+ + + {formState.errors.batchId && ( +

+ {formState.errors.batchId.message} +

+ )} +
+
+
+
+ + {/* DPP Sections */} +
+
+
+
+ setOverviewOpen(!overviewOpen)} + id="product-overview-collapse" + > + + + +
+ setRepairabilityOpen(!repairabilityOpen)} + id="repairability-collapse" + > + + + +
+ setEnvironmentalOpen(!environmentalOpen)} + id="environmental-collapse" + > + + + +
+ setComplianceOpen(!complianceOpen)} + id="compliance-collapse" + > + + + +
+ setCertificatesOpen(!certificatesOpen)} + id="certificates-collapse" + > + + + +
+ setRecyclabilityOpen(!recyclabilityOpen)} + id="recyclability-collapse" + > + + + +
+ setEnergyOpen(!energyOpen)} + id="energy-collapse" + > + + + +
+ setComponentOpen(!componentOpen)} + id="component-collapse" + > + + + +
+ setEconomicOpen(!economicOpen)} + id="economic-collapse" + > + + + +
+ setRepairInfoOpen(!repairInfoOpen)} + id="repair-info-collapse" + > + + + +
+ setRefurbishmentOpen(!refurbishmentOpen)} + id="refurbishment-collapse" + > + + + +
+ setRecyclingInfoOpen(!recyclingInfoOpen)} + id="recycling-collapse" + > + + +
+
+
+
+
+
+ + {/* Submit Footer */} +
+
+ + +
+
+
+ + + {loading && } + + ); +} diff --git a/components/partials/create/project/CreateProjectForm.tsx b/components/partials/create/project/CreateProjectForm.tsx index bd807e4c..22a1f38f 100644 --- a/components/partials/create/project/CreateProjectForm.tsx +++ b/components/partials/create/project/CreateProjectForm.tsx @@ -38,10 +38,6 @@ import useYupLocaleObject from "hooks/useYupLocaleObject"; import { FormProvider, useForm } from "react-hook-form"; import * as yup from "yup"; -//@ts-ignore -import useSignedPost from "hooks/useSignedPost"; -import { UploadFileOnDPP } from "lib/fileUpload"; -import { dppStepDefaultValues, dppStepSchema, DPPStepValues } from "./steps/DPPStep"; import { machinesStepDefaultValues, machinesStepSchema, MachinesStepValues } from "./steps/MachinesStep"; import { materialsStepDefaultValues, materialsStepSchema, MaterialsStepValues } from "./steps/MaterialsStep"; import { @@ -49,6 +45,11 @@ import { productFiltersStepSchema, ProductFiltersStepValues, } from "./steps/ProductFiltersStep"; +import { + serviceFiltersStepDefaultValues, + serviceFiltersStepSchema, + ServiceFiltersStepValues, +} from "./steps/ServiceFiltersStep"; export interface Props { projectType: ProjectType; @@ -59,6 +60,7 @@ export interface Props { export interface CreateProjectValues { main: MainStepValues; productFilters: ProductFiltersStepValues; + serviceFilters: ServiceFiltersStepValues; linkedDesign: LinkDesignStepValues; location: LocationStepValues; images: ImagesStepValues; @@ -66,7 +68,6 @@ export interface CreateProjectValues { contributors: ContributorsStepValues; relations: RelationsStepValues; licenses: LicenseStepValues; - dpp: DPPStepValues; machines: MachinesStepValues; materials: MaterialsStepValues; } @@ -74,6 +75,7 @@ export interface CreateProjectValues { export const createProjectDefaultValues: CreateProjectValues = { main: mainStepDefaultValues, productFilters: productFiltersStepDefaultValues, + serviceFilters: serviceFiltersStepDefaultValues, linkedDesign: linkDesignStepDefaultValues, location: locationStepDefaultValues, images: imagesStepDefaultValues, @@ -81,7 +83,6 @@ export const createProjectDefaultValues: CreateProjectValues = { contributors: contributorsStepDefaultValues, relations: relationsStepDefaultValues, licenses: licenseStepDefaultValues, - dpp: dppStepDefaultValues, machines: machinesStepDefaultValues, materials: materialsStepDefaultValues, }; @@ -90,6 +91,7 @@ export const createProjectSchema = () => yup.object({ main: mainStepSchema(), productFilters: productFiltersStepSchema(), + serviceFilters: serviceFiltersStepSchema(), linkedDesign: linkDesignStepSchema().when("$projectType", (projectType: ProjectType, schema) => projectType == ProjectType.PRODUCT ? schema.required("A design source is required for products") : schema ), @@ -106,11 +108,6 @@ export const createProjectSchema = () => contributors: contributorsStepSchema(), relations: relationsStepSchema(), licenses: licenseStepSchema(), - dpp: dppStepSchema().when("$projectType", (projectType: ProjectType, schema) => - projectType == ProjectType.PRODUCT - ? schema.required("A DPP is required for products") - : schema.notRequired().nullable() - ), machines: machinesStepSchema(), materials: materialsStepSchema(), }); @@ -122,7 +119,6 @@ export type CreateProjectSchemaContext = LocationStepSchemaContext; export default function CreateProjectForm(props: Props) { const { projectType } = props; const { handleProjectCreation, handleMachineCreation } = useProjectCRUD(); - const { signedPost } = useSignedPost(); const [loading, setLoading] = useState(false); const router = useRouter(); const yupLocaleObject = useYupLocaleObject(); @@ -148,6 +144,7 @@ export default function CreateProjectForm(props: Props) { const createProjectDefaultValues: CreateProjectValues = { main: mainStepDefaultValues, productFilters: productFiltersStepDefaultValues, + serviceFilters: serviceFiltersStepDefaultValues, linkedDesign: linkDesignStepDefaultValues, location: locationStepDefaultUserValues, images: imagesStepDefaultValues, @@ -155,7 +152,6 @@ export default function CreateProjectForm(props: Props) { contributors: contributorsStepDefaultValues, relations: relationsStepDefaultValues, licenses: licenseStepDefaultValues, - dpp: dppStepDefaultValues, machines: machinesStepDefaultValues, materials: materialsStepDefaultValues, }; @@ -188,36 +184,6 @@ export default function CreateProjectForm(props: Props) { const { handleSubmit } = formMethods; - async function processDppValues(obj: any): Promise { - if (obj === null || obj === undefined) { - return obj; - } - - if (obj instanceof File) { - const uploadResponse = await UploadFileOnDPP(obj); - return uploadResponse; - } - if (Array.isArray(obj)) { - return Promise.all(obj.map(item => processDppValues(item))); - } - if (typeof obj === "object") { - const processedObj: any = {}; - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - const processedValue = await processDppValues(obj[key]); - if (processedValue && typeof processedValue === "object" && "value" in processedValue) { - if (processedValue.value === null || processedValue.value === undefined) { - continue; - } - } - processedObj[key] = processedValue; - } - } - return processedObj; - } - return obj; - } - async function onSubmit(values: CreateProjectValues) { setLoading(true); @@ -229,21 +195,7 @@ export default function CreateProjectForm(props: Props) { return; } - let dppUlid: string | undefined = undefined; - - if (values.dpp) { - const processedDpp = await processDppValues(values.dpp); - const response = await signedPost(`${process.env.NEXT_PUBLIC_DPP_URL}/dpp`, processedDpp, true); - if (!response.ok) { - console.error("Failed to submit DPP:", response.statusText); - setLoading(false); - return; - } - dppUlid = (await response.json()).insertedID; - console.log("DPP submitted with ULID:", dppUlid); - } - - const projectID = await handleProjectCreation(values, projectType, dppUlid); + const projectID = await handleProjectCreation(values, projectType); if (projectID) await router.replace(`/project/${projectID}?created=true`); setLoading(false); } @@ -259,22 +211,19 @@ export default function CreateProjectForm(props: Props) { return (
-
-
-
- +
+
+
+
+ +
+
+
+
-
- -
+
- {loading && } diff --git a/components/partials/create/project/parts/CreateProjectFields.tsx b/components/partials/create/project/parts/CreateProjectFields.tsx index 2ad5d01b..a2e05267 100644 --- a/components/partials/create/project/parts/CreateProjectFields.tsx +++ b/components/partials/create/project/parts/CreateProjectFields.tsx @@ -5,11 +5,6 @@ import { CreateProjectValues } from "../CreateProjectForm"; // Steps import { getSectionsByProjectType } from "components/partials/project/projectSections"; -// Components -import { Stack } from "@bbtgnn/polaris-interfacer"; -import Card from "components/Card"; -import PTitleSubtitle from "components/polaris/PTitleSubtitle"; - // export interface Props { @@ -48,6 +43,12 @@ export default function CreateProjectFields(props: Props) { "Share details about fabrication equipment, 3D printers, laser cutters, CNC machines, and other tools available in your maker space or lab. Help others discover the machines they need for their projects." ), }, + [ProjectType.DPP]: { + title: t("Add a DPP"), + subtitle: t( + "Create a Digital Product Passport to document the lifecycle, materials, and sustainability information of your product. Help consumers and regulators access transparent product data." + ), + }, }; const sections = getSectionsByProjectType(projectType); @@ -55,25 +56,41 @@ export default function CreateProjectFields(props: Props) { // return ( - - -
- -
-
+
+ {/* Header card */} +
+

+ {titles[projectType].title} +

+

+ {titles[projectType].subtitle} +

+
+ {/* Section cards */} {sections.map((section, index) => (
-
- -
- - {section.component} - -
-
+
+
+
{section.component}
+
))} - +
); } diff --git a/components/partials/create/project/parts/CreateProjectNav.tsx b/components/partials/create/project/parts/CreateProjectNav.tsx index 3b4ab401..b493883f 100644 --- a/components/partials/create/project/parts/CreateProjectNav.tsx +++ b/components/partials/create/project/parts/CreateProjectNav.tsx @@ -1,8 +1,7 @@ -import { Card } from "@bbtgnn/polaris-interfacer"; import { getSectionsByProjectType } from "components/partials/project/projectSections"; -import TableOfContents from "components/TableOfContents"; import { ProjectType } from "components/types"; import { useTranslation } from "next-i18next"; +import { useCallback, useEffect, useState } from "react"; export interface Props { projectType: ProjectType; @@ -11,21 +10,77 @@ export interface Props { export default function CreateProjectNav(props: Props) { const { t } = useTranslation("createProjectProps"); const { projectType } = props; + const [activeId, setActiveId] = useState(""); - const links = getSectionsByProjectType(projectType).map(section => { - const required = section.required?.includes(projectType); + const sections = getSectionsByProjectType(projectType); - return { - label: {section.navLabel}, - href: `#${section.id}`, - }; - }); + // Scroll-spy: track which section is in view + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + } + }, + { rootMargin: "-20% 0px -60% 0px", threshold: 0 } + ); + + for (const section of sections) { + const el = document.getElementById(section.id); + if (el) observer.observe(el); + } + + return () => observer.disconnect(); + }, [sections]); + + const scrollTo = useCallback((id: string) => { + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }, []); return ( - -
- -
-
+ ); } diff --git a/components/partials/create/project/parts/CreateProjectSubmit.tsx b/components/partials/create/project/parts/CreateProjectSubmit.tsx index 9e6bfe35..5cfb94f1 100644 --- a/components/partials/create/project/parts/CreateProjectSubmit.tsx +++ b/components/partials/create/project/parts/CreateProjectSubmit.tsx @@ -1,4 +1,3 @@ -import { Button } from "@bbtgnn/polaris-interfacer"; import { ProjectType } from "components/types"; import useFormSaveDraft from "hooks/useFormSaveDraft"; import { useTranslation } from "next-i18next"; @@ -18,14 +17,34 @@ export default function CreateProjectSubmit() { ); return ( -
-
+
+
- +
); diff --git a/components/partials/create/project/steps/DPPStep.tsx b/components/partials/create/project/steps/DPPStep.tsx index 7020324d..6a10fd58 100644 --- a/components/partials/create/project/steps/DPPStep.tsx +++ b/components/partials/create/project/steps/DPPStep.tsx @@ -1,214 +1,8 @@ -import { Stack } from "@bbtgnn/polaris-interfacer"; -import PHelp from "components/polaris/PHelp"; -import PTitleSubtitle from "components/polaris/PTitleSubtitle"; -import { useTranslation } from "next-i18next"; -import { useState } from "react"; -import { useFormContext } from "react-hook-form"; -import { CreateProjectValues } from "../CreateProjectForm"; -import { CollapsibleSection } from "./DPPStep/components"; -import { - CertificatesSection, - ComplianceSection, - ComponentInformationSection, - EconomicOperatorSection, - EnergyUseEfficiencySection, - EnvironmentalSection, - ProductOverviewSection, - RecyclabilitySection, - RecyclingInformationSection, - RefurbishmentInformationSection, - RepairabilitySection, - RepairInformationSection, -} from "./DPPStep/sections"; - // Re-export types and schema for backward compatibility export { dppStepDefaultValues, dppStepSchema } from "./DPPStep/schema"; export type { DPPStepValues } from "./DPPStep/types"; -// Import the type for internal use -import type { DPPStepValues } from "./DPPStep/types"; - -export default function DPPStep() { - const { t } = useTranslation("createProjectProps"); - const form = useFormContext(); - - const DPP_FORM_KEY = "dpp"; - - const { formState, control, watch, setValue } = form; - const dpp = watch(DPP_FORM_KEY); - const { errors } = formState; - - // State for toggle and accordion sections - const [dppEnabled, setDppEnabled] = useState(Boolean(dpp)); - const [overviewOpen, setOverviewOpen] = useState(true); - const [repairabilityOpen, setRepairabilityOpen] = useState(false); - const [environmentalImpactOpen, setEnvironmentalOpen] = useState(false); - const [complianceOpen, setComplianceOpen] = useState(false); - const [certificatesOpen, setCertificatesOpen] = useState(false); - const [recyclabilityOpen, setRecyclabilityOpen] = useState(false); - const [energyOpen, setEnergyOpen] = useState(false); - const [componentOpen, setComponentOpen] = useState(false); - const [economicOperatorOpen, setEconomicOperatorOpen] = useState(false); - const [repairInfoOpen, setRepairInfoOpen] = useState(false); - const [refurbishmentInfoOpen, setRefurbishmentInfoOpen] = useState(false); - const [recyclingInfoOpen, setRecyclingInfoOpen] = useState(false); - - // Handle DPP toggle - const handleDppToggle = () => { - const newState = !dppEnabled; - setDppEnabled(newState); - if (!newState) { - setValue(DPP_FORM_KEY, null as any); - } else { - setValue(DPP_FORM_KEY, {} as DPPStepValues); - } - }; - - return ( - - {/* Header with title and help text */} - - - - {/* Toggle to enable/disable DPP */} - - - {/* DPP Form Sections - only show when enabled */} - {dppEnabled && ( - - {/* Product Overview Section */} - setOverviewOpen(!overviewOpen)} - id="product-overview" - > - - - - {/* Repairability Section */} - setRepairabilityOpen(!repairabilityOpen)} - id="repairability" - > - - - - {/* Environmental Section */} - setEnvironmentalOpen(!environmentalImpactOpen)} - id="environmentalImpact" - > - - - - {/* Compliance Section */} - setComplianceOpen(!complianceOpen)} - id="compliance" - > - - - - {/* Certificates Section */} - setCertificatesOpen(!certificatesOpen)} - id="certificates" - > - - - - {/* Recyclability Section */} - setRecyclabilityOpen(!recyclabilityOpen)} - id="recyclability" - > - - - - {/* Energy Use & Efficiency Section */} - setEnergyOpen(!energyOpen)} - id="energy" - > - - - - {/* Component Information Section */} - setComponentOpen(!componentOpen)} - id="component" - > - - - - {/* Economic Operator Section */} - setEconomicOperatorOpen(!economicOperatorOpen)} - id="economic-operator" - > - - - - {/* Repair Information Section */} - setRepairInfoOpen(!repairInfoOpen)} - id="repair-info" - > - - - - {/* Refurbishment Information Section */} - setRefurbishmentInfoOpen(!refurbishmentInfoOpen)} - id="refurbishment-info" - > - - - - {/* Recycling Information Section */} - setRecyclingInfoOpen(!recyclingInfoOpen)} - id="recycling-info" - > - - - - )} - - ); -} +// This component is no longer used in the project creation form. +// DPP creation is now handled by the standalone CreateDppForm at /dpps/new. +// The DPPStep/* sub-modules (schema, types, sections, components) are still +// imported directly by CreateDppForm. diff --git a/components/partials/create/project/steps/DPPStep/components/RangeSliderField.tsx b/components/partials/create/project/steps/DPPStep/components/RangeSliderField.tsx new file mode 100644 index 00000000..ce7ca460 --- /dev/null +++ b/components/partials/create/project/steps/DPPStep/components/RangeSliderField.tsx @@ -0,0 +1,117 @@ +import PLabel from "components/polaris/PLabel"; +import { useCallback, useEffect, useRef } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +interface RangeSliderFieldProps { + label: string; + valueName: string; + unitName: string; + defaultUnit: string; + min: number; + max: number; + step?: number; +} + +export const RangeSliderField = ({ + label, + valueName, + unitName, + defaultUnit, + min, + max, + step = 1, +}: RangeSliderFieldProps) => { + const { control, setValue, watch } = useFormContext(); + const trackRef = useRef(null); + const draggingRef = useRef(false); + + const valueFieldValue = watch(valueName); + const unitFieldValue = watch(unitName); + + useEffect(() => { + if (!unitFieldValue) { + setValue(unitName, defaultUnit); + } + }, [unitFieldValue, unitName, defaultUnit, setValue]); + + const clampAndStep = useCallback( + (raw: number) => { + const stepped = Math.round(raw / step) * step; + return Math.max(min, Math.min(max, stepped)); + }, + [min, max, step] + ); + + const getValueFromPointer = useCallback( + (clientX: number) => { + if (!trackRef.current) return min; + const rect = trackRef.current.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return clampAndStep(min + ratio * (max - min)); + }, + [min, max, clampAndStep] + ); + + const currentValue = typeof valueFieldValue === "number" ? valueFieldValue : Number(valueFieldValue) || min; + const percent = ((currentValue - min) / (max - min)) * 100; + + const formatValue = (val: number) => { + const unit = unitFieldValue || defaultUnit; + if (unit === "%") return `${val}%`; + return `${val} ${unit}`; + }; + + return ( + { + const handlePointerDown = (e: React.PointerEvent) => { + draggingRef.current = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + const newVal = getValueFromPointer(e.clientX); + onChange(newVal); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!draggingRef.current) return; + const newVal = getValueFromPointer(e.clientX); + onChange(newVal); + }; + + const handlePointerUp = () => { + draggingRef.current = false; + }; + + return ( +
+
+ + {formatValue(currentValue)} +
+
+ {/* Background track */} +
+ {/* Filled track */} +
+ {/* Handle */} +
+
+
+ ); + }} + /> + ); +}; diff --git a/components/partials/create/project/steps/DPPStep/sections/EnvironmentalSection.tsx b/components/partials/create/project/steps/DPPStep/sections/EnvironmentalSection.tsx index fd399e77..eacae44e 100644 --- a/components/partials/create/project/steps/DPPStep/sections/EnvironmentalSection.tsx +++ b/components/partials/create/project/steps/DPPStep/sections/EnvironmentalSection.tsx @@ -1,12 +1,33 @@ import { Stack } from "@bbtgnn/polaris-interfacer"; import { useTranslation } from "next-i18next"; import { FieldWithUnit } from "../components/FieldWithUnit"; +import { RangeSliderField } from "../components/RangeSliderField"; export const EnvironmentalSection = () => { const { t } = useTranslation("createProjectProps"); return ( + + + + { unitName="dpp.environmentalImpact.chemicalConsumptionPerUnit.units" defaultUnit="kg" /> - - - - ); }; diff --git a/components/partials/create/project/steps/MachinesStep.tsx b/components/partials/create/project/steps/MachinesStep.tsx index b714514b..ef2d90e2 100644 --- a/components/partials/create/project/steps/MachinesStep.tsx +++ b/components/partials/create/project/steps/MachinesStep.tsx @@ -1,4 +1,4 @@ -import { Stack, Tag } from "@bbtgnn/polaris-interfacer"; +import { Stack } from "@bbtgnn/polaris-interfacer"; import PHelp from "components/polaris/PHelp"; import PTitleSubtitle from "components/polaris/PTitleSubtitle"; import SearchMachines from "components/SearchMachines"; @@ -126,17 +126,28 @@ export default function MachinesStep() { placeholder={t("Search for machines")} /> - {/* Display selected machines as chips */} + {/* Display selected machines as yellow pills */} {selectedMachines.length > 0 && (

{t("Selected machines")}:

- +
{selectedMachines.map(machine => ( - handleMachineRemove(machine.id)}> + {machine.name} - + + ))} - +
)} diff --git a/components/partials/create/project/steps/ServiceFiltersStep.tsx b/components/partials/create/project/steps/ServiceFiltersStep.tsx new file mode 100644 index 00000000..c4504fc4 --- /dev/null +++ b/components/partials/create/project/steps/ServiceFiltersStep.tsx @@ -0,0 +1,91 @@ +import { Stack } from "@bbtgnn/polaris-interfacer"; +import PHelp from "components/polaris/PHelp"; +import PTitleSubtitle from "components/polaris/PTitleSubtitle"; +import { formSetValueOptions } from "lib/formSetValueOptions"; +import { AVAILABILITY_OPTIONS, SERVICE_TYPE_OPTIONS } from "lib/tagging"; +import { useTranslation } from "next-i18next"; +import { useFormContext } from "react-hook-form"; +import * as yup from "yup"; +import { CreateProjectValues } from "../CreateProjectForm"; + +export interface ServiceFiltersStepValues { + serviceType: string[]; + availability: string[]; +} + +export const serviceFiltersStepDefaultValues: ServiceFiltersStepValues = { + serviceType: [], + availability: [], +}; + +export const serviceFiltersStepSchema = () => + yup.object().shape({ + serviceType: yup.array().of(yup.string().required()).default([]), + availability: yup.array().of(yup.string().required()).default([]), + }); + +function toggleValue(list: string[], value: string, checked: boolean): string[] { + if (checked) return list.includes(value) ? list : [...list, value]; + return list.filter(v => v !== value); +} + +export default function ServiceFiltersStep() { + const { t } = useTranslation("createProjectProps"); + const form = useFormContext(); + const { watch, setValue } = form; + + const values = watch("serviceFilters") || serviceFiltersStepDefaultValues; + + return ( + + + + + +
+ {SERVICE_TYPE_OPTIONS.map(option => ( + + ))} +
+
+ + + +
+ {AVAILABILITY_OPTIONS.map(option => ( + + ))} +
+
+
+ ); +} diff --git a/components/partials/project/projectSections.tsx b/components/partials/project/projectSections.tsx index da4177da..f8f3f9c1 100644 --- a/components/partials/project/projectSections.tsx +++ b/components/partials/project/projectSections.tsx @@ -2,7 +2,6 @@ import { ProjectType } from "components/types"; import { CreateProjectValues } from "../create/project/CreateProjectForm"; import ContributorsStep from "../create/project/steps/ContributorsStep"; import DeclarationsStep from "../create/project/steps/DeclarationsStep"; -import DPPStep from "../create/project/steps/DPPStep"; import ImagesStep from "../create/project/steps/ImagesStep"; import ImportDesignStep from "../create/project/steps/ImportDesignStep"; import LicenseStep from "../create/project/steps/LicenseStep"; @@ -13,6 +12,7 @@ import MainStep from "../create/project/steps/MainStep"; import MaterialsStep from "../create/project/steps/MaterialsStep"; import ProductFiltersStep from "../create/project/steps/ProductFiltersStep"; import RelationsStep from "../create/project/steps/RelationsStep"; +import ServiceFiltersStep from "../create/project/steps/ServiceFiltersStep"; // @@ -62,6 +62,12 @@ export const projectSections: Array = [ required: [ProjectType.PRODUCT, ProjectType.SERVICE, ProjectType.DESIGN, ProjectType.MACHINE], editPage: "edit/images", }, + { + navLabel: "Service details", + id: "serviceFilters", + component: , + for: [ProjectType.SERVICE], + }, { navLabel: "Location", id: "location", @@ -113,13 +119,6 @@ export const projectSections: Array = [ for: [ProjectType.DESIGN, ProjectType.PRODUCT, ProjectType.SERVICE, ProjectType.MACHINE], editPage: "edit/relations", }, - { - navLabel: "DPP", - id: "dpp", - component: , - required: [ProjectType.PRODUCT], - for: [ProjectType.PRODUCT], - }, { navLabel: "Machines", id: "machines", diff --git a/components/partials/topbar/Topbar.tsx b/components/partials/topbar/Topbar.tsx index 48e21a71..4ed91a12 100644 --- a/components/partials/topbar/Topbar.tsx +++ b/components/partials/topbar/Topbar.tsx @@ -14,13 +14,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import LocationMenu from "components/LocationMenu"; -import SearchBar from "components/SearchBar"; +import BrUserAvatar from "components/brickroom/BrUserAvatar"; +import InterfacerLogo from "components/InterfacerLogo"; +import NavigationMenu from "components/NavigationMenu"; +import UserDropdown from "components/UserDropdown"; import { useAuth } from "hooks/useAuth"; +import useInBox from "hooks/useInBox"; import { useTranslation } from "next-i18next"; +import Link from "next/link"; import { useRouter } from "next/router"; -import TopbarNotifications from "./TopbarNotifications"; -import TopbarUser from "./TopbarUser"; +import { useCallback, useEffect, useState } from "react"; type topbarProps = { userMenu?: boolean; @@ -30,65 +33,216 @@ type topbarProps = { burger?: boolean; }; -function Topbar({ search = true, children, userMenu = true, cta, burger = true }: topbarProps) { +function Topbar({ search = true, userMenu = true, cta, burger = true }: topbarProps) { const router = useRouter(); const path = router.asPath; const { user } = useAuth(); const { t } = useTranslation("common"); + const { unread } = useInBox(); const isSignup = path === "/sign_up"; const isSignin = path === "/sign_in"; + const [menuOpen, setMenuOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + + // Close menu/dropdown on route change + useEffect(() => { + const handleRouteChange = () => { + setMenuOpen(false); + setDropdownOpen(false); + }; + router.events.on("routeChangeComplete", handleRouteChange); + return () => router.events.off("routeChangeComplete", handleRouteChange); + }, [router.events]); + + const [searchString, setSearchString] = useState(""); + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && searchString.trim()) { + router.push(`/search?q=${encodeURIComponent(searchString.trim())}`); + } + }, + [searchString, router] + ); + + // Active nav link detection + const isDesigns = path === "/" || (path.startsWith("/products") === false && path.startsWith("/services") === false); + const isProducts = path.startsWith("/products"); + const isServices = path.startsWith("/services"); + return ( -
-
- {children} - {burger && ( -
+ + {/* Right section */} +
+ {cta} + {(isSignup || isSignin) && ( +
+ + +
+ )} + + {/* User avatar with notification dot */} + {user && userMenu && ( +
+ + + {dropdownOpen && setDropdownOpen(false)} />} +
+ )} +
+ + + {/* Navigation drawer */} + setMenuOpen(false)} /> + ); } diff --git a/components/types/index.ts b/components/types/index.ts index cdda5613..fc65c820 100644 --- a/components/types/index.ts +++ b/components/types/index.ts @@ -15,6 +15,7 @@ export enum ProjectType { PRODUCT = "Product", SERVICE = "Service", MACHINE = "Machine", + DPP = "DPP", } export const projectTypes = Object.values(ProjectType); diff --git a/history/redesign-issues.md b/history/redesign-issues.md new file mode 100644 index 00000000..992fbbc0 --- /dev/null +++ b/history/redesign-issues.md @@ -0,0 +1,29 @@ +# Redesign Issues Reference + +## Dependency Graph + +``` +Epic 1 (60u) P0 ──► Epic 2 (0i2) P1 ──┐ + ──► Epic 3 (z7y) P1 ──► Epic 7 (ady) P2 + ──► Epic 4 (1ne) P1 ──┤ + ──► Epic 5 (zdx) P1 ──► Epic 8 (5pp) P2 + ──► Epic 6 (dwo) P2 ──┤ + └► Epic 9 (7gg) P3 +``` + +## Epics + +| ID | P | Title | Subtasks | +| ------------------ | --- | ------------------------------------------- | --------- | +| interfacer-gui-60u | 0 | Design System Foundation | .1-.5 (5) | +| interfacer-gui-0i2 | 1 | Navigation and Layout Redesign | .1-.3 (3) | +| interfacer-gui-z7y | 1 | Catalog Pages (Designs, Products, Services) | .1-.5 (5) | +| interfacer-gui-1ne | 1 | Detail Pages (Design, Product, Service) | .1-.4 (4) | +| interfacer-gui-zdx | 1 | Profile Page Redesign | .1-.8 (8) | +| interfacer-gui-dwo | 2 | Creation Forms Redesign | .1-.5 (5) | +| interfacer-gui-ady | 2 | Search and Filtering System | .1-.3 (3) | +| interfacer-gui-5pp | 2 | Interactive Features | .1-.5 (5) | +| interfacer-gui-7gg | 3 | Polish and Legacy Cleanup | .1-.6 (6) | + +Total: 9 epics + 44 subtasks = 53 new issues (73 total in bd) +Created: 2026-03-16 diff --git a/hooks/useProjectCRUD.ts b/hooks/useProjectCRUD.ts index 3a932d94..7e8df858 100644 --- a/hooks/useProjectCRUD.ts +++ b/hooks/useProjectCRUD.ts @@ -79,6 +79,7 @@ export const useProjectCRUD = () => { [ProjectType.SERVICE]: specProjectService!.id, [ProjectType.PRODUCT]: specProjectProduct!.id, [ProjectType.MACHINE]: specMachine!.id, + [ProjectType.DPP]: specDpp!.id, } : undefined; @@ -341,6 +342,18 @@ export const useProjectCRUD = () => { const productFilterTags = derivedProductFilterTags(normalizedProductFilters); + const serviceTypeTags = (formData.serviceFilters?.serviceType || []) + .map(s => prefixedTag(TAG_PREFIX.SERVICE_TYPE, s)) + .filter((t): t is string => Boolean(t)); + + const availabilityTags = (formData.serviceFilters?.availability || []) + .map(a => prefixedTag(TAG_PREFIX.AVAILABILITY, a)) + .filter((t): t is string => Boolean(t)); + + const licenseTags = (formData.licenses || []) + .map(l => prefixedTag(TAG_PREFIX.LICENSE, l.licenseId)) + .filter((t): t is string => Boolean(t)); + const baseTags = removeTagsWithPrefixes(formData.main.tags, [ TAG_PREFIX.CATEGORY, TAG_PREFIX.POWER_COMPAT, @@ -350,9 +363,20 @@ export const useProjectCRUD = () => { TAG_PREFIX.REPAIRABILITY, TAG_PREFIX.ENV_ENERGY, TAG_PREFIX.ENV_CO2, + TAG_PREFIX.SERVICE_TYPE, + TAG_PREFIX.AVAILABILITY, + TAG_PREFIX.LICENSE, ]); - const merged = mergeTags(baseTags, machineTags, materialTags, productFilterTags); + const merged = mergeTags( + baseTags, + machineTags, + materialTags, + productFilterTags, + serviceTypeTags, + availabilityTags, + licenseTags + ); const tags = merged.length > 0 ? merged : undefined; devLog("info: tags prepared", tags); diff --git a/lib/QueryAndMutation.ts b/lib/QueryAndMutation.ts index cfb789c4..48dcc22e 100644 --- a/lib/QueryAndMutation.ts +++ b/lib/QueryAndMutation.ts @@ -505,7 +505,6 @@ export const FETCH_RESOURCES = gql` hash name mimeType - bin } version licensor diff --git a/lib/dpp-types.ts b/lib/dpp-types.ts new file mode 100644 index 00000000..3aea8ae2 --- /dev/null +++ b/lib/dpp-types.ts @@ -0,0 +1,222 @@ +/** + * TypeScript types matching the interfacer-dpp Go backend model. + * These represent the API document shapes (not form input shapes). + * + * @see ~/dyne/interfacer-dpp/internal/model/model.go + */ + +// --- Primitives --- + +export type TransformedValue = { + type: string; + value: T; + units?: string; +}; + +export type Attachment = { + id: string; + fileName: string; + contentType: string; + url: string; + size: number; + checksum: string; + uploadedAt: string; +}; + +// --- Forward-looking types (ie5.1) --- + +export type BatchType = "batch" | "unit"; +export type DppStatus = "active" | "draft" | "archived"; + +// --- DPP Sections --- + +export type ProductOverview = { + brandName?: TransformedValue; + productImage?: TransformedValue; + globalProductClassificationCode?: TransformedValue; + countryOfSale?: TransformedValue; + productDescription?: TransformedValue; + productName?: TransformedValue; + netWeight?: TransformedValue; + gtin?: TransformedValue; + color?: TransformedValue; + countryOfOrigin?: TransformedValue; + dimensions?: TransformedValue; + modelName?: TransformedValue; + taricCode?: TransformedValue; + conditionOfTheProduct?: TransformedValue; + netContent?: TransformedValue; + nominalMaximumRPM?: TransformedValue; + maximumDrillingDiameter?: TransformedValue; + numberOfGears?: TransformedValue; + torque?: TransformedValue; + warrantyDuration?: TransformedValue; + safetyInstructions?: TransformedValue; + consumerUnit?: TransformedValue; + netContentAndUnitOfMeasure?: TransformedValue; + yearOfSale?: TransformedValue; +}; + +export type Reparability = { + serviceAndRepairInstructions?: TransformedValue; + availabilityOfSpareParts?: TransformedValue; +}; + +export type EnvironmentalImpact = { + waterConsumptionPerUnit?: TransformedValue; + chemicalConsumptionPerUnit?: TransformedValue; + co2eEmissionsPerUnit?: TransformedValue; + energyConsumptionPerUnit?: TransformedValue; + cleaningPerformanceAtLowTemperature?: TransformedValue; + minimumContentOfMaterialWithSustainabilityCertification?: TransformedValue; +}; + +export type ComplianceAndStandards = { + ceMarking?: TransformedValue; + rohsCompliance?: TransformedValue; +}; + +export type Certificates = { + nameOfCertificate?: TransformedValue; +}; + +export type Recyclability = { + recyclingInstructions?: TransformedValue; + materialComposition?: TransformedValue; + substancesOfConcern?: TransformedValue; +}; + +export type EnergyUseAndEfficiency = { + batteryType?: TransformedValue; + batteryChargingTime?: TransformedValue; + batteryLife?: TransformedValue; + chargerType?: TransformedValue; + maximumElectricalPower?: TransformedValue; + maximumVoltage?: TransformedValue; + maximumCurrent?: TransformedValue; + powerRating?: TransformedValue; + dcVoltage?: TransformedValue; +}; + +export type ComponentInformation = { + componentDescription?: TransformedValue; + componentGTIN?: TransformedValue; + linkToDPP?: TransformedValue; +}; + +export type EconomicOperator = { + companyName?: TransformedValue; + gln?: TransformedValue; + eoriNumber?: TransformedValue; + addressLine1?: TransformedValue; + addressLine2?: TransformedValue; + contactInformation?: TransformedValue; +}; + +export type RepairInformation = { + reasonForRepair?: TransformedValue; + performedAction?: TransformedValue; + materialsUsed?: TransformedValue; + dateOfRepair?: TransformedValue; +}; + +export type RefurbishmentInformation = { + performedAction?: TransformedValue; + materialsUsed?: TransformedValue; + dateOfRefurbishment?: TransformedValue; +}; + +export type RecyclingInformation = { + performedAction?: TransformedValue; + dateOfRecycling?: TransformedValue; +}; + +export type ConsumerInformation = { + marketingClaim?: TransformedValue; +}; + +export type DosageInstructions = { + usageAndDisposalInfo?: TransformedValue; +}; + +export type Ingredients = { + ingredientList?: TransformedValue; + minimumContentOfBiodegradableSubstances?: TransformedValue; + presenceOfNonBiodegradableMicroplastics?: TransformedValue; +}; + +export type ChemicalConsumption = { + amount?: TransformedValue; + ingredient?: TransformedValue; +}; + +export type Packaging = { + chemicalConsumption?: ChemicalConsumption; + disposalInstructions?: TransformedValue; + minimumRecycledContent?: TransformedValue; + recyclablePackaging?: TransformedValue; +}; + +// --- Main Document --- + +export type DppDocument = { + id: string; + // Forward-looking fields (ie5.1 — not yet in backend) + productId?: string; + batchType?: BatchType; + batchId?: string; + createdBy?: string; + status?: DppStatus; + createdAt?: string; + updatedAt?: string; + // Sections + productOverview?: ProductOverview; + reparability?: Reparability; + environmentalImpact?: EnvironmentalImpact; + complianceAndStandards?: ComplianceAndStandards; + certificates?: Certificates; + recyclability?: Recyclability; + energyUseAndEfficiency?: EnergyUseAndEfficiency; + components?: ComponentInformation[]; + economicOperator?: EconomicOperator; + repairInformation?: RepairInformation; + refurbishmentInformation?: RefurbishmentInformation; + recyclingInformation?: RecyclingInformation; + consumerInformation?: ConsumerInformation; + dosageInstructions?: DosageInstructions; + ingredients?: Ingredients; + packaging?: Packaging; +}; + +// --- API Request/Response types --- + +export type CreateDppResponse = { + insertedID: string; +}; + +export type UpdateDppResponse = { + matchedCount: number; + modifiedCount: number; +}; + +export type DeleteDppResponse = { + deletedCount: number; +}; + +export type ListDppsFilters = { + productId?: string; + createdBy?: string; + status?: DppStatus; + limit?: number; + offset?: number; +}; + +export type ListDppsResponse = { + dpps: DppDocument[]; + total: number; +}; + +export type DppApiError = { + error: string; + details?: string; +}; diff --git a/lib/dpp.ts b/lib/dpp.ts new file mode 100644 index 00000000..21b2895d --- /dev/null +++ b/lib/dpp.ts @@ -0,0 +1,227 @@ +/** + * API client for the interfacer-dpp REST backend. + * Provides a React hook `useDppApi` with typed methods for all DPP operations. + * + * Auth: Signs requests with EdDSA keys via did-sign/did-pk headers + * (same pattern as useSignedPost.ts signDidRequest). + * + * @see ~/dyne/interfacer-dpp/cmd/main/main.go for route definitions + */ + +// @ts-ignore +import { useAuth } from "hooks/useAuth"; +import useStorage from "hooks/useStorage"; +// @ts-ignore +import sign from "zenflows-crypto/src/sign_graphql.zen"; +import type { + Attachment, + CreateDppResponse, + DeleteDppResponse, + DppApiError, + DppDocument, + DppStatus, + ListDppsFilters, + ListDppsResponse, + UpdateDppResponse, +} from "./dpp-types"; + +const DPP_BASE_URL = process.env.NEXT_PUBLIC_DPP_URL; + +class DppRequestError extends Error { + status: number; + details?: string; + + constructor(message: string, status: number, details?: string) { + super(message); + this.name = "DppRequestError"; + this.status = status; + this.details = details; + } +} + +export { DppRequestError }; + +const useDppApi = () => { + const { getItem } = useStorage(); + const { user } = useAuth(); + + async function signBody(body: string): Promise<{ "did-sign": string; "did-pk": string }> { + const zencode_exec = (await import("zenroom")).zencode_exec; + const data = `{"gql": "${Buffer.from(body, "utf8").toString("base64")}"}`; + const keys = `{"keyring": {"eddsa": "${getItem("eddsaPrivateKey")}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + return { + "did-sign": JSON.parse(result).eddsa_signature, + "did-pk": String(user?.publicKey), + }; + } + + async function request(method: string, path: string, body?: any): Promise { + const url = `${DPP_BASE_URL}${path}`; + const jsonBody = body != null ? JSON.stringify(body) : undefined; + + const headers: Record = {}; + + // Always send user ULID so backend can store/filter by it + if (user?.ulid) { + headers["x-user-id"] = user.ulid; + } + + if (jsonBody) { + const authHeaders = await signBody(jsonBody); + Object.assign(headers, authHeaders); + headers["Content-Type"] = "application/json"; + } + + const res = await fetch(url, { method, headers, body: jsonBody }); + + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch { + // response body may not be JSON + } + throw new DppRequestError(apiError.error, res.status, apiError.details); + } + + if (res.status === 204) return undefined as T; + return res.json(); + } + + // --- CRUD --- + + async function createDpp(data: Omit): Promise { + return request("POST", "/dpp", data); + } + + async function getDpp(id: string): Promise { + return request("GET", `/dpp/${encodeURIComponent(id)}`); + } + + async function updateDpp(id: string, data: Partial): Promise { + return request("PUT", `/dpp/${encodeURIComponent(id)}`, data); + } + + async function deleteDpp(id: string): Promise { + return request("DELETE", `/dpp/${encodeURIComponent(id)}`); + } + + // --- List / Query --- + + async function listDpps(filters?: ListDppsFilters): Promise { + const params = new URLSearchParams(); + if (filters?.productId) params.set("productId", filters.productId); + if (filters?.createdBy) params.set("createdBy", filters.createdBy); + if (filters?.status) params.set("status", filters.status); + if (filters?.limit != null) params.set("limit", String(filters.limit)); + if (filters?.offset != null) params.set("offset", String(filters.offset)); + + const qs = params.toString(); + const path = qs ? `/dpps?${qs}` : "/dpps"; + + return request("GET", path); + } + + // --- File operations --- + + async function uploadFile(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const checksum = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + + const zencode_exec = (await import("zenroom")).zencode_exec; + const data = `{"gql": "${checksum}"}`; + const keys = `{"keyring": {"eddsa": "${getItem("eddsaPrivateKey")}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + const signature = JSON.parse(result).eddsa_signature; + + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch(`${DPP_BASE_URL}/upload`, { + method: "POST", + headers: { + "did-pk": String(user?.publicKey), + "did-sign": signature, + }, + body: formData, + }); + + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch {} + throw new DppRequestError(apiError.error, res.status, apiError.details); + } + + return res.json(); + } + + function getFileUrl(id: string): string { + return `${DPP_BASE_URL}/file/${encodeURIComponent(id)}`; + } + + async function updateDppStatus(id: string, status: DppStatus): Promise { + return request("PUT", `/dpp/${encodeURIComponent(id)}/status`, { status }); + } + + async function addAttachment(dppId: string, section: string, file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const checksum = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + + const zencode_exec = (await import("zenroom")).zencode_exec; + const data = `{"gql": "${checksum}"}`; + const keys = `{"keyring": {"eddsa": "${getItem("eddsaPrivateKey")}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + const signature = JSON.parse(result).eddsa_signature; + + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch( + `${DPP_BASE_URL}/dpp/${encodeURIComponent(dppId)}/attachments?section=${encodeURIComponent(section)}`, + { + method: "POST", + headers: { + "did-pk": String(user?.publicKey), + "did-sign": signature, + }, + body: formData, + } + ); + + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch {} + throw new DppRequestError(apiError.error, res.status, apiError.details); + } + + return res.json(); + } + + async function deleteAttachment(dppId: string, attachmentId: string): Promise { + return request("DELETE", `/dpp/${encodeURIComponent(dppId)}/attachments/${encodeURIComponent(attachmentId)}`); + } + + return { + createDpp, + getDpp, + updateDpp, + deleteDpp, + listDpps, + uploadFile, + getFileUrl, + updateDppStatus, + addAttachment, + deleteAttachment, + }; +}; + +export default useDppApi; diff --git a/lib/fetchLocation.ts b/lib/fetchLocation.ts index 78dc08ca..a265ed0f 100644 --- a/lib/fetchLocation.ts +++ b/lib/fetchLocation.ts @@ -22,9 +22,13 @@ import { formatSelectOption, SelectOption } from "components/brickroom/utils/BrS export async function fetchLocation(text: string): Promise> { if (!text) return []; - const result = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_AUTOCOMPLETE}?q=${encodeURI(text)}`); - const data = (await result.json()) as FetchLocation.Response; - return [...data.items]; + try { + const result = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_AUTOCOMPLETE}?q=${encodeURI(text)}`); + const data = (await result.json()) as FetchLocation.Response; + return data?.items ? [...data.items] : []; + } catch { + return []; + } } // Loads the options for the async multiselect diff --git a/lib/findProjectImages.ts b/lib/findProjectImages.ts index f1edfba6..bde92d39 100644 --- a/lib/findProjectImages.ts +++ b/lib/findProjectImages.ts @@ -15,7 +15,13 @@ const findProjectImages = (project: Partial): string[] => { }); const projectImages = project?.images?.length - ? project.images.filter(image => Boolean(image.bin)).map(image => `data:${image.mimeType};base64,${image.bin}`) + ? project.images + .filter(image => Boolean(image.bin) || Boolean(image.hash)) + .map(image => + image.bin + ? `data:${image.mimeType};base64,${image.bin}` + : `/api/image/${image.hash}?type=${encodeURIComponent(image.mimeType || "image/png")}` + ) : []; return projectImages.length > 0 ? projectImages : filteredMetadataImages; diff --git a/lib/isProjectType.ts b/lib/isProjectType.ts index 1c69cb62..dc15a37a 100644 --- a/lib/isProjectType.ts +++ b/lib/isProjectType.ts @@ -6,5 +6,6 @@ export function isProjectType(name: string | ProjectType): Record; id?: InputMaybe>; name?: InputMaybe; + nearDistanceKm?: InputMaybe; + nearLat?: InputMaybe; + nearLong?: InputMaybe; notCustodian?: InputMaybe>; notPrimaryAccountable?: InputMaybe>; note?: InputMaybe; diff --git a/pages/_app.tsx b/pages/_app.tsx index 7f4f1772..5abd61b3 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -14,8 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import "@fontsource/ibm-plex-sans"; -import "@fontsource/space-grotesk"; +import "@fontsource/ibm-plex-sans/400"; +import "@fontsource/ibm-plex-sans/500"; +import "@fontsource/ibm-plex-sans/600"; +import "@fontsource/ibm-plex-sans/700"; +import "@fontsource/space-grotesk/400"; +import "@fontsource/space-grotesk/500"; +import "@fontsource/space-grotesk/700"; import Layout from "components/layout/Layout"; import { AuthProvider } from "contexts/AuthContext"; @@ -51,7 +56,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { {getLayout()} - + ); } diff --git a/pages/api/image/[hash].ts b/pages/api/image/[hash].ts new file mode 100644 index 00000000..323668b1 --- /dev/null +++ b/pages/api/image/[hash].ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const ZENFLOWS_FILE_URL = process.env.NEXT_PUBLIC_ZENFLOWS_FILE_URL; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { hash } = req.query; + if (!hash || typeof hash !== "string") { + res.status(400).end("Missing hash"); + return; + } + + try { + const response = await fetch(`${ZENFLOWS_FILE_URL}/${hash}`); + if (!response.ok) { + res.status(response.status).end(); + return; + } + + const base64 = await response.text(); + const buffer = Buffer.from(base64, "base64"); + + const mimeType = (req.query.type as string) || "image/png"; + res.setHeader("Content-Type", mimeType); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.send(buffer); + } catch { + res.status(502).end("Failed to fetch image"); + } +} diff --git a/pages/create/project/index.tsx b/pages/create/project/index.tsx index d5a17a1a..bb00b2fa 100644 --- a/pages/create/project/index.tsx +++ b/pages/create/project/index.tsx @@ -1,24 +1,7 @@ -import { useTranslation } from "next-i18next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { NextPageWithLayout } from "pages/_app"; -import { ReactElement, useState } from "react"; - -// Components -import { Banner, Card, Icon, Stack, Text } from "@bbtgnn/polaris-interfacer"; -import { ChevronRightMinor } from "@shopify/polaris-icons"; -import FullWidthBanner from "components/FullWidthBanner"; -import Layout from "components/layout/Layout"; -import PTitleSubtitle from "components/polaris/PTitleSubtitle"; -import ProjectTypeRoundIcon from "components/ProjectTypeRoundIcon"; - -// Icons -import { ProjectType } from "components/types"; import { useAuth } from "hooks/useAuth"; -import { useDrafts } from "hooks/useFormSaveDraft"; -import Link from "next/link"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { useRouter } from "next/router"; - -// +import { useEffect } from "react"; export async function getStaticProps({ locale }: any) { return { @@ -29,126 +12,19 @@ export async function getStaticProps({ locale }: any) { }; } -// - -const CreateProject: NextPageWithLayout = () => { - const { t } = useTranslation(["createProjectProps", "common"]); - const { hasDrafts } = useDrafts(); - const router = useRouter(); - const { draft_deleted, draft_saved } = router.query; +const CreateProject = () => { const { user } = useAuth(); - const [isOpenSavedBanner, setIsOpenSavedBanner] = useState(!!draft_saved); - const [isOpenDeletedBanner, setIsOpenDeletedBanner] = useState(!!draft_deleted); - - const sections: Array<{ title: string; description: string; url: string; projectType: ProjectType }> = [ - { - title: "Design", - description: t( - "Import your project repository. Share your open source hardware project documentation and collaborate on building it." - ), - url: "/create/project/design", - projectType: ProjectType.DESIGN, - }, - { - title: "Product", - description: t( - "Showcase your open source hardware product and connect with a global network of makers. Import your product details to our platform." - ), - url: "/create/project/product", - projectType: ProjectType.PRODUCT, - }, - { - title: "Service", - description: t( - "Offer your expertise, training courses, or equipment rentals on our platform, supporting the development and collaboration of open source hardware projects in the community." - ), - url: "/create/project/service", - projectType: ProjectType.SERVICE, - }, - { - title: "Machine", - description: t( - "Add a machine or equipment to the platform. Share details about fabrication tools, 3D printers, laser cutters, and other machines available in your maker space or lab." - ), - url: "/create/project/machine", - projectType: ProjectType.MACHINE, - }, - ]; - - return ( - <> - setIsOpenDeletedBanner(false)} status="info"> - - {t("Your draft project was deleted")} - - - setIsOpenSavedBanner(false)}> - - {t("Your project was saved as draft successfully")} - - -
- - - {t( - "Submit your new open source hardware project and ensure that all relevant information is included. This information will be used to identify your project and provide context to users who may be interested in it." - )} -
-
- {t("Need help? Read the User Guide to get started.")} - - {"[↗]"} - - - } - /> - - {hasDrafts && ( - to your drafts"), url: `/profile/${user?.ulid}?tab=2` }} - /> - )} - {sections.map(s => ( - - -
- -
- -
-
- -
-
- -
- ))} -
-
-
- - ); -}; + const router = useRouter(); -// + useEffect(() => { + if (user?.ulid) { + router.replace(`/profile/${user.ulid}`); + } else { + router.replace("/"); + } + }, [user, router]); -CreateProject.getLayout = function getLayout(page: ReactElement) { - return {page}; + return null; }; export default CreateProject; diff --git a/pages/designs.tsx b/pages/designs.tsx new file mode 100644 index 00000000..fe48388c --- /dev/null +++ b/pages/designs.tsx @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; +import { useTranslation } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import useFilters from "../hooks/useFilters"; +import { NextPageWithLayout } from "./_app"; + +export async function getStaticProps({ locale }: any) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common"])), + }, + }; +} + +const Designs: NextPageWithLayout = () => { + const { t } = useTranslation("common"); + const { designId, specsLoading } = useFilters(); + + const filter = { + conformsTo: designId ? [designId] : undefined, + notCustodian: [process.env.NEXT_PUBLIC_LOSH_ID!], + }; + + return ( + + + + + + ), + }} + searchPlaceholder={t("Search designs, makers, machines, materials...")} + filter={filter} + /> + ); +}; + +Designs.publicPage = true; + +export default Designs; diff --git a/pages/dpps/new.tsx b/pages/dpps/new.tsx new file mode 100644 index 00000000..e0bd6dcb --- /dev/null +++ b/pages/dpps/new.tsx @@ -0,0 +1,25 @@ +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { NextPageWithLayout } from "pages/_app"; +import { ReactElement } from "react"; + +import Layout from "components/layout/Layout"; +import CreateDppForm from "components/partials/create/dpp/CreateDppForm"; + +export async function getStaticProps({ locale }: any) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common", "createProjectProps"])), + }, + }; +} + +const CreateDpp: NextPageWithLayout = () => { + return ; +}; + +CreateDpp.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default CreateDpp; diff --git a/pages/products.tsx b/pages/products.tsx index 11f5aa76..0f9652bd 100644 --- a/pages/products.tsx +++ b/pages/products.tsx @@ -1,239 +1,49 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2022-2023 Dyne.org foundation . -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -import { gql, useQuery } from "@apollo/client"; +import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { useRouter } from "next/router"; -import React from "react"; - -// Components -import ProductsActiveFiltersBar from "components/ProductsActiveFiltersBar"; -import ProductsCategoriesBar from "components/ProductsCategoriesBar"; -import ProductsFilters from "components/ProductsFilters"; -import ProductsHeader from "components/ProductsHeader"; -import ProductsSearchBar from "components/ProductsSearchBar"; -import ProjectsCards from "components/ProjectsCards"; import useFilters from "../hooks/useFilters"; - -// - -const GET_PRODUCTS_STATS = gql` - query GetProductsStats { - economicResources(last: 1) { - pageInfo { - totalCount - } - } - } -`; - -// +import { NextPageWithLayout } from "./_app"; export async function getStaticProps({ locale }: any) { return { props: { + publicPage: true, ...(await serverSideTranslations(locale, ["common", "productsProps"])), }, }; } -// - -const Products = () => { - const { t } = useTranslation("productsProps"); - const router = useRouter(); - const { proposalFilter } = useFilters(); - const [resultsCount, setResultsCount] = React.useState(0); - const [resultsLoading, setResultsLoading] = React.useState(true); - const [showMobileFilters, setShowMobileFilters] = React.useState(false); - - // Fetch stats - const { data: statsData, loading: statsLoading } = useQuery(GET_PRODUCTS_STATS); - const totalProjects = statsData?.economicResources?.pageInfo?.totalCount || 0; - - // Sort and show controls - const sortBy = (router.query.sort as string) || "latest"; - const showFilter = (router.query.show as string) || "all"; - - const handleSortChange = (value: string) => { - const query = { ...router.query, sort: value }; - router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); - }; - - const handleShowChange = (value: string) => { - const query = { ...router.query }; - if (value === "all") { - delete query.show; - } else { - query.show = value; - } - router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); - }; +const Products: NextPageWithLayout = () => { + const { t } = useTranslation("common"); + const { productId, specsLoading } = useFilters(); - const clearFilters = () => { - router.push({ pathname: router.pathname }, undefined, { shallow: true }); + const filter = { + conformsTo: productId ? [productId] : undefined, + notCustodian: [process.env.NEXT_PUBLIC_LOSH_ID!], }; - const hasActiveFilters = Object.keys(router.query).length > 0; - - // Custom empty state for filtered results - const filteredEmptyState = ( -
- - - -

- {t("No projects match your filters")} -

-

- {hasActiveFilters - ? t("Try adjusting your search or removing some filters to see more results") - : t("No projects available at the moment")} -

- {hasActiveFilters && ( - - )} -
- ); - return ( -
- {/* Mobile Filter Button */} - - - {/* Desktop Sidebar - Filters */} - - - {/* Mobile Drawer - Filters */} - {showMobileFilters && ( - <> - {/* Backdrop */} -
setShowMobileFilters(false)} - /> - {/* Drawer */} - - - )} - - {/* Main Content Area */} -
-
- {/* Header Section */} - - - {/* Categories */} - - - {/* Active Filters */} - - - {/* Search and Sort Controls */} -
-
- -
-
- {t("Sort by")} - - {t("Show")} - -
-
- - {/* Results count */} -
- {resultsLoading ? ( -

{t("Loading...")}

- ) : ( -

{t("Showing {{count}} results", { count: resultsCount })}

- )} -
- - {/* Products Grid */} - { - setResultsCount(totalCount); - setResultsLoading(loading); - }} - /> -
-
-
+ + + + + + ), + }} + searchPlaceholder={t("Search products, manufacturers, materials...")} + filter={filter} + /> ); }; diff --git a/pages/profile/[id]/index.tsx b/pages/profile/[id]/index.tsx index bdfdbd77..845a30aa 100644 --- a/pages/profile/[id]/index.tsx +++ b/pages/profile/[id]/index.tsx @@ -17,8 +17,7 @@ import FetchUserLayout from "components/layout/FetchUserLayout"; import Layout from "components/layout/Layout"; import EditProfileBanner from "components/partials/profile/[id]/EditProfileBanner"; -import ProfileHeading from "components/partials/profile/[id]/ProfileHeading"; -import ProfileTabs from "components/partials/profile/[id]/ProfileTabs"; +import ProfilePageNew from "components/ProfilePageNew"; import type { GetStaticPaths } from "next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { NextPageWithLayout } from "pages/_app"; @@ -29,10 +28,7 @@ const Profile: NextPageWithLayout = () => { return ( <> -
- - -
+ ); }; diff --git a/pages/project/[id]/index.tsx b/pages/project/[id]/index.tsx index 593aa12c..67a6bb75 100644 --- a/pages/project/[id]/index.tsx +++ b/pages/project/[id]/index.tsx @@ -17,23 +17,16 @@ import { GetStaticPaths } from "next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { useRouter } from "next/router"; -import { createContext, Dispatch, ReactElement, SetStateAction, useContext, useEffect, useState } from "react"; +import { createContext, Dispatch, ReactElement, SetStateAction, useContext, useMemo, useState } from "react"; -// Components -import { Stack } from "@bbtgnn/polaris-interfacer"; - -// Icons -import BrThumbinailsGallery from "components/brickroom/BrThumbinailsGallery"; import FetchProjectLayout, { useProject } from "components/layout/FetchProjectLayout"; import Layout from "components/layout/Layout"; import EditBanner from "components/partials/project/[id]/EditBanner"; -import ProjectHeader from "components/partials/project/[id]/ProjectHeader"; -import ProjectSidebar from "components/partials/project/[id]/ProjectSidebar"; -import ProjectTabs from "components/partials/project/[id]/ProjectTabs"; import SuccessBanner from "components/partials/project/[id]/SuccessBanner"; +import ProjectDetailNew from "components/ProjectDetailNew"; +import findProjectImages from "lib/findProjectImages"; import { useTranslation } from "next-i18next"; import { NextPageWithLayout } from "pages/_app"; -import findProjectImages from "lib/findProjectImages"; //opengraph import { NextSeo } from "next-seo"; @@ -50,21 +43,16 @@ const Project: NextPageWithLayout = () => { const router = useRouter(); const { t } = useTranslation("common"); const { id } = router.query; - const [images, setImages] = useState([]); const [selected, setSelected] = useState(0); const { project } = useProject(); + const images = useMemo(() => findProjectImages(project), [project]); // (Temp) Redirect if project is LOSH owned if (process.env.NEXT_PUBLIC_LOSH_ID == project?.primaryAccountable?.id) { router.push(`/resource/${id}`); } - useEffect(() => { - const _images = findProjectImages(project); - setImages(_images); - }, [project]); - if (!project) return null; return ( @@ -95,21 +83,7 @@ const Project: NextPageWithLayout = () => { {t("Project succesfully created!")} -
-
- - - -
- -
- -
-
-
- -
-
+
); diff --git a/pages/services.tsx b/pages/services.tsx new file mode 100644 index 00000000..31d05fd6 --- /dev/null +++ b/pages/services.tsx @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; +import { useTranslation } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import useFilters from "../hooks/useFilters"; +import { NextPageWithLayout } from "./_app"; + +export async function getStaticProps({ locale }: any) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common"])), + }, + }; +} + +const Services: NextPageWithLayout = () => { + const { t } = useTranslation("common"); + const { serviceId, specsLoading } = useFilters(); + + const filter = { + conformsTo: serviceId ? [serviceId] : undefined, + notCustodian: [process.env.NEXT_PUBLIC_LOSH_ID!], + }; + + return ( + + + + + + ), + }} + searchPlaceholder={t("Search services, providers, locations...")} + filter={filter} + /> + ); +}; + +Services.publicPage = true; + +export default Services; diff --git a/styles/globals.scss b/styles/globals.scss index a6af1dc4..81cd8b8f 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@import "./theme.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -26,6 +28,17 @@ html, body { padding: 0; margin: 0; + font-family: var(--ifr-font-body); + font-size: var(--ifr-fs-base); + line-height: 1.5; + color: var(--ifr-text-primary); +} + +button, +input, +select, +textarea { + font: inherit; } a { @@ -59,7 +72,8 @@ h4, p, .paragraph { - @apply text-sm; + font-size: var(--ifr-fs-base); + line-height: 1.5; } .logo { @@ -138,7 +152,6 @@ table { scrollbar-width: none; /* Firefox */ } - // layout .drawer { @apply grid overflow-hidden w-full; @@ -245,7 +258,6 @@ table { } } - .drawer.drawer-end > .drawer-toggle:checked ~ .drawer-content { @apply -translate-x-2; } @@ -344,5 +356,3 @@ table { width: 50%; justify-content: flex-end; } - - diff --git a/styles/theme.css b/styles/theme.css new file mode 100644 index 00000000..20d0998e --- /dev/null +++ b/styles/theme.css @@ -0,0 +1,145 @@ +/* + * Interfacer Design Tokens (--ifr-* custom properties) + * + * These tokens define the visual language shared between the prototype + * (DTEC 03/2026) and the production GUI. Components reference them + * via var(--ifr-*) or through the Tailwind utilities configured in + * tailwind.config.js. + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * Copyright (C) 2022-2023 Dyne.org foundation . + */ + +:root { + /* --- Fonts --- */ + --ifr-font-body: "IBM Plex Sans", sans-serif; + --ifr-font-heading: "Space Grotesk", sans-serif; + + /* --- Font sizes --- */ + --ifr-fs-2xs: 8px; + --ifr-fs-xs: 10px; + --ifr-fs-sm: 12px; + --ifr-fs-base: 14px; + --ifr-fs-md: 16px; + --ifr-fs-lg: 20px; + --ifr-fs-xl: 24px; + --ifr-fs-2xl: 30px; + + /* --- Font weights --- */ + --ifr-fw-regular: 400; + --ifr-fw-medium: 500; + --ifr-fw-semibold: 600; + --ifr-fw-bold: 700; + + /* --- Brand / text colors --- */ + --ifr-text-primary: #0b1324; + --ifr-text-secondary: #6c707c; + --ifr-text-muted: #6b7280; + --ifr-green: #036a53; + --ifr-green-hover: #025a46; + --ifr-green-dark: #014837; + --ifr-red: #c5281d; + --ifr-red-hover-bg: #fef5f5; + + /* --- Entity type colors --- */ + --ifr-type-product: #143bb5; + --ifr-type-product-border: #0b1324; + --ifr-type-product-hover: #0f2f96; + --ifr-type-product-bg: rgba(20, 59, 181, 0.1); + --ifr-type-service: #8200db; + --ifr-type-service-hover: #6e00b8; + --ifr-type-service-border: #570093; + --ifr-type-service-bg: rgba(130, 0, 219, 0.1); + --ifr-type-dpp: #eb7b35; + --ifr-type-dpp-border: #9e3c00; + --ifr-type-dpp-hover: #d46a28; + --ifr-type-dpp-bg: rgba(235, 123, 53, 0.1); + --ifr-type-location: #036a53; + --ifr-type-location-hover: #025a46; + + /* --- Status colors --- */ + --ifr-yellow: #f1bd4d; + --ifr-yellow-hover: #e5af3a; + --ifr-yellow-bg: #fff5ea; + --ifr-yellow-text: #916a00; + --ifr-green-accent: #5da091; + --ifr-green-bg: #f1f8f5; + --ifr-green-status-text: #008060; + + /* --- Active / selected state --- */ + --ifr-bg-active: var(--ifr-green-bg); + --ifr-text-active: var(--ifr-green); + + /* --- Stat icon colors --- */ + --ifr-stat-green-bg: rgba(3, 106, 83, 0.1); + --ifr-stat-green-border: rgba(3, 106, 83, 0.2); + --ifr-stat-yellow-bg: rgba(240, 177, 0, 0.3); + --ifr-stat-yellow-icon: #a65f00; + --ifr-stat-blue-bg: rgba(43, 127, 255, 0.1); + --ifr-stat-blue-icon: #1447e6; + + /* --- Surface / Background colors --- */ + --ifr-bg-page: #e9e9e8; + --ifr-bg-profile: #fafafa; + --ifr-bg-surface: #ffffff; + --ifr-bg-elevated: #fdfdfd; + --ifr-bg-hover: #f5f5f5; + --ifr-bg-input: #f3f3f5; + --ifr-bg-search: rgba(200, 212, 229, 0.15); + --ifr-bg-tag: rgba(200, 212, 229, 0.25); + --ifr-bg-hover-light: rgba(200, 212, 229, 0.1); + --ifr-bg-results: rgba(255, 255, 255, 0.6); + --ifr-bg-bookmark: rgba(255, 255, 255, 0.9); + --ifr-bg-avatar: rgba(3, 106, 83, 0.2); + --ifr-bg-quote: #f6f6f7; + + /* --- Border colors --- */ + --ifr-border: #c9cccf; + --ifr-border-light: #c4c4c4; + --ifr-border-avatar: #aaa69d; + --ifr-border-env: rgba(200, 212, 229, 0.5); + + /* --- Overlay / gradient --- */ + --ifr-overlay-dark: rgba(0, 0, 0, 0.3); + --ifr-gradient-dark: rgba(0, 0, 0, 0.6); + --ifr-placeholder: rgba(11, 19, 36, 0.5); + + /* --- Shadows --- */ + --ifr-shadow-avatar: 0px 1px 6px 0px rgba(164, 167, 172, 0.15); + --ifr-shadow-sm: 0px 1px 2px 0px rgba(0, 0, 0, 0.05); + --ifr-shadow-dropdown: 0px 10px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px -4px rgba(0, 0, 0, 0.1); + --ifr-shadow-toggle: 0px 1px 2px 0px rgba(0, 0, 0, 0.1); + + /* --- Border radius --- */ + --ifr-radius-sm: 4px; + --ifr-radius-md: 6px; + --ifr-radius-lg: 8px; + --ifr-radius-full: 9999px; + + /* --- Layout sizes --- */ + --ifr-topbar-height: 64px; + --ifr-sidebar-width: 288px; + --ifr-dropdown-width: 240px; + --ifr-avatar-size: 44px; + --ifr-avatar-profile-size: 88px; + --ifr-control-height: 36px; + --ifr-card-image-height: 240px; + --ifr-card-min-width: 300px; + --ifr-card-max-width: 360px; + --ifr-grid-gap: 24px; + --ifr-form-sidebar-width: 304px; + --ifr-nav-menu-width: 280px; + --ifr-nav-menu-divider: #e5a100; + + /* --- Form-specific --- */ + --ifr-bg-form-input: #fafbfb; + --ifr-border-form-input: #cacccf; + --ifr-bg-upload-btn: #fff5dd; + --ifr-icon-muted: #a4a7ac; + --ifr-section-icon-size: 52px; + --ifr-section-icon-bg: var(--ifr-green-bg); + + /* --- Toggle switch --- */ + --ifr-switch-off: #cbd5e1; + --ifr-switch-on: var(--ifr-green); +} diff --git a/tailwind.config.js b/tailwind.config.js index d3badfc0..3619080e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -46,9 +46,98 @@ module.exports = { display: ['"Space Grotesk"', "sans-serif"], sans: ['"IBM Plex Sans"', "sans-serif"], }, - colors: {...tokens.colors, - primary: "#02604B"}, + colors: { + ...tokens.colors, + primary: "#02604B", + /* Interfacer design tokens */ + ifr: { + "text-primary": "var(--ifr-text-primary)", + "text-secondary": "var(--ifr-text-secondary)", + "text-muted": "var(--ifr-text-muted)", + green: { + DEFAULT: "var(--ifr-green)", + hover: "var(--ifr-green-hover)", + dark: "var(--ifr-green-dark)", + accent: "var(--ifr-green-accent)", + bg: "var(--ifr-green-bg)", + "status-text": "var(--ifr-green-status-text)", + }, + red: { + DEFAULT: "var(--ifr-red)", + "hover-bg": "var(--ifr-red-hover-bg)", + }, + yellow: { + DEFAULT: "var(--ifr-yellow)", + hover: "var(--ifr-yellow-hover)", + bg: "var(--ifr-yellow-bg)", + text: "var(--ifr-yellow-text)", + }, + product: { + DEFAULT: "var(--ifr-type-product)", + hover: "var(--ifr-type-product-hover)", + border: "var(--ifr-type-product-border)", + bg: "var(--ifr-type-product-bg)", + }, + service: { + DEFAULT: "var(--ifr-type-service)", + hover: "var(--ifr-type-service-hover)", + border: "var(--ifr-type-service-border)", + bg: "var(--ifr-type-service-bg)", + }, + dpp: { + DEFAULT: "var(--ifr-type-dpp)", + hover: "var(--ifr-type-dpp-hover)", + border: "var(--ifr-type-dpp-border)", + bg: "var(--ifr-type-dpp-bg)", + }, + location: { + DEFAULT: "var(--ifr-type-location)", + hover: "var(--ifr-type-location-hover)", + }, + }, + }, + backgroundColor: { + ifr: { + page: "var(--ifr-bg-page)", + profile: "var(--ifr-bg-profile)", + surface: "var(--ifr-bg-surface)", + elevated: "var(--ifr-bg-elevated)", + hover: "var(--ifr-bg-hover)", + input: "var(--ifr-bg-input)", + search: "var(--ifr-bg-search)", + tag: "var(--ifr-bg-tag)", + "hover-light": "var(--ifr-bg-hover-light)", + results: "var(--ifr-bg-results)", + bookmark: "var(--ifr-bg-bookmark)", + avatar: "var(--ifr-bg-avatar)", + quote: "var(--ifr-bg-quote)", + active: "var(--ifr-bg-active)", + "form-input": "var(--ifr-bg-form-input)", + "upload-btn": "var(--ifr-bg-upload-btn)", + }, + }, + borderColor: { + ifr: { + DEFAULT: "var(--ifr-border)", + light: "var(--ifr-border-light)", + avatar: "var(--ifr-border-avatar)", + env: "var(--ifr-border-env)", + "form-input": "var(--ifr-border-form-input)", + }, + }, + boxShadow: { + "ifr-avatar": "var(--ifr-shadow-avatar)", + "ifr-sm": "var(--ifr-shadow-sm)", + "ifr-dropdown": "var(--ifr-shadow-dropdown)", + "ifr-toggle": "var(--ifr-shadow-toggle)", + }, + borderRadius: { + "ifr-sm": "var(--ifr-radius-sm)", + "ifr-md": "var(--ifr-radius-md)", + "ifr-lg": "var(--ifr-radius-lg)", + "ifr-full": "var(--ifr-radius-full)", + }, }, }, - plugins: [require("@tailwindcss/typography"), require("@tailwindcss/line-clamp")], + plugins: [require("@tailwindcss/typography"), require("@tailwindcss/line-clamp")], }; From dcbd6a44ef089b029562004592a1d3b6064808c1 Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:23:28 +0200 Subject: [PATCH 09/18] =?UTF-8?q?fix:=20=F0=9F=90=9B=20migrate=20to=20momi?= =?UTF-8?q?natim=20for=20location=20autocomplete=20and=20lookup=20(#834)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/CatalogFilterSidebar.tsx | 8 +- components/SearchLocation.tsx | 64 ++++++++-- lib/fetchLocation.ts | 176 +++++++++++++++++++++++++++- 3 files changed, 229 insertions(+), 19 deletions(-) diff --git a/components/CatalogFilterSidebar.tsx b/components/CatalogFilterSidebar.tsx index d4fbf7ea..5942038b 100644 --- a/components/CatalogFilterSidebar.tsx +++ b/components/CatalogFilterSidebar.tsx @@ -141,7 +141,13 @@ export default function CatalogFilterSidebar({ variant, collapsed = false, onTog async (loc: FetchLocation.Location) => { setShowLocationDropdown(false); setLocationInput(""); - const detail = await lookupLocation(loc.id); + const detail = + loc.position && Number.isFinite(loc.position.lat) && Number.isFinite(loc.position.lng) + ? { + title: loc.title, + position: loc.position, + } + : await lookupLocation(loc.id); if (!detail) return; setLocationLabel(detail.title); const radius = router.query.nearDistanceKm ? String(router.query.nearDistanceKm) : "50"; diff --git a/components/SearchLocation.tsx b/components/SearchLocation.tsx index 65f75084..69e4f7f0 100644 --- a/components/SearchLocation.tsx +++ b/components/SearchLocation.tsx @@ -34,30 +34,70 @@ export default function SearchLocation(props: Props) { }, []); const [options, setOptions] = useState>([]); + const [searchResults, setSearchResults] = useState>([]); const [loading, setLoading] = useState(false); + const toCoordValue = useCallback((location: FetchLocation.Location): string => { + return location.position + ? `COORD:${location.position.lat},${location.position.lng}|${encodeURIComponent(location.title)}` + : location.id; + }, []); + useEffect(() => { const searchLocation = async () => { setLoading(true); - setOptions(createOptionsFromResult(await fetchLocation(inputValue))); + const results = await fetchLocation(inputValue); + setSearchResults(results); + setOptions( + results.map(location => ({ + value: toCoordValue(location), + label: location.title, + })) + ); setLoading(false); }; searchLocation(); - }, [inputValue]); - - function createOptionsFromResult(result: Array): Array { - return result.map(location => { - return { - value: location.id, - label: location.title, - }; - }); - } + }, [inputValue, toCoordValue]); /* Handling selection */ async function handleSelect(selected: string[]) { - const location = await lookupLocation(selected[0]); + const selectedValue = selected[0]?.trim(); + if (!selectedValue) { + onSelect(null); + return; + } + + const matchedResult = searchResults.find(location => toCoordValue(location) === selectedValue); + if (matchedResult?.position) { + onSelect({ + title: matchedResult.title, + id: matchedResult.id, + language: matchedResult.language, + resultType: matchedResult.resultType, + administrativeAreaType: matchedResult.administrativeAreaType, + address: { + label: matchedResult.address.label, + countryCode: matchedResult.address.countryCode, + countryName: matchedResult.address.countryName, + state: "", + }, + position: { + lat: matchedResult.position.lat, + lng: matchedResult.position.lng, + }, + mapView: { + west: matchedResult.position.lng, + south: matchedResult.position.lat, + east: matchedResult.position.lng, + north: matchedResult.position.lat, + }, + }); + setInputValue(""); + return; + } + + const location = await lookupLocation(selectedValue); if (!location) onSelect(null); else onSelect(location); setInputValue(""); diff --git a/lib/fetchLocation.ts b/lib/fetchLocation.ts index a265ed0f..d8324285 100644 --- a/lib/fetchLocation.ts +++ b/lib/fetchLocation.ts @@ -18,14 +18,134 @@ import { formatSelectOption, SelectOption } from "components/brickroom/utils/BrS // +interface NominatimAddress { + country?: string; + country_code?: string; + state?: string; +} + +interface NominatimItem { + place_id?: number; + osm_type?: "node" | "way" | "relation"; + osm_id?: number; + display_name?: string; + lat?: string; + lon?: string; + boundingbox?: [string, string, string, string]; + address?: NominatimAddress; +} + +function osmTypePrefix(osmType?: string): "N" | "W" | "R" | "" { + if (osmType === "node") return "N"; + if (osmType === "way") return "W"; + if (osmType === "relation") return "R"; + return ""; +} + +function toLocationId(item: NominatimItem): string { + const prefix = osmTypePrefix(item.osm_type); + if (prefix && item.osm_id) return `${prefix}${item.osm_id}`; + if (item.lat && item.lon) { + const title = encodeURIComponent(item.display_name || ""); + return `COORD:${item.lat},${item.lon}|${title}`; + } + return ""; +} + +function isValidOsmId(id: string): boolean { + return /^[NWR]\d+$/.test(id); +} + +function parseCoordId(id: string): { lat: number; lng: number; title: string } | null { + if (!id.startsWith("COORD:")) return null; + + const payload = id.slice(6); + const [coords, encodedTitle = ""] = payload.split("|"); + const [latStr, lngStr] = coords.split(","); + const lat = Number(latStr); + const lng = Number(lngStr); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + + return { + lat, + lng, + title: decodeURIComponent(encodedTitle || ""), + }; +} + +function mapSearchItem(item: NominatimItem): FetchLocation.Location { + const lat = Number(item.lat || 0); + const lng = Number(item.lon || 0); + + return { + title: item.display_name || "", + id: toLocationId(item), + language: "", + resultType: item.osm_type || "", + administrativeAreaType: "", + address: { + label: item.display_name || "", + countryCode: (item.address?.country_code || "").toUpperCase(), + countryName: item.address?.country || "", + }, + highlights: { + title: [], + address: { + label: [], + countryCode: [], + }, + }, + position: { + lat, + lng, + }, + }; +} + +function mapLookupItem(item: NominatimItem): LocationLookup.Location { + const lat = Number(item.lat || 0); + const lng = Number(item.lon || 0); + const bbox = item.boundingbox; + + return { + title: item.display_name || "", + id: toLocationId(item), + language: "", + resultType: item.osm_type || "", + administrativeAreaType: "", + address: { + label: item.display_name || "", + countryCode: (item.address?.country_code || "").toUpperCase(), + countryName: item.address?.country || "", + state: item.address?.state || "", + }, + position: { + lat, + lng, + }, + mapView: { + west: bbox ? Number(bbox[2]) : lng, + south: bbox ? Number(bbox[0]) : lat, + east: bbox ? Number(bbox[3]) : lng, + north: bbox ? Number(bbox[1]) : lat, + }, + }; +} + // Fetches the location from the API export async function fetchLocation(text: string): Promise> { if (!text) return []; try { - const result = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_AUTOCOMPLETE}?q=${encodeURI(text)}`); - const data = (await result.json()) as FetchLocation.Response; - return data?.items ? [...data.items] : []; + const params = new URLSearchParams({ + q: text, + format: "jsonv2", + addressdetails: "1", + limit: "10", + }); + const result = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_AUTOCOMPLETE}?${params.toString()}`); + const data = (await result.json()) as Array; + return Array.isArray(data) ? data.map(mapSearchItem).filter(item => !!item.id) : []; } catch { return []; } @@ -37,11 +157,51 @@ export async function getLocationOptions(text: string): Promise { +export async function lookupLocation(id: string): Promise { if (!id) throw new Error("NoLocationId"); - const response = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_LOOKUP}?id=${encodeURI(id)}`); - return await response.json(); + if (id.startsWith("here:")) return null; + const coord = parseCoordId(id); + if (coord) { + return { + title: coord.title, + id, + language: "", + resultType: "", + administrativeAreaType: "", + address: { + label: coord.title, + countryCode: "", + countryName: "", + state: "", + }, + position: { + lat: coord.lat, + lng: coord.lng, + }, + mapView: { + west: coord.lng, + south: coord.lat, + east: coord.lng, + north: coord.lat, + }, + }; + } + if (!isValidOsmId(id)) return null; + + try { + const params = new URLSearchParams({ + osm_ids: id, + format: "jsonv2", + addressdetails: "1", + }); + const response = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_LOOKUP}?${params.toString()}`); + const data = (await response.json()) as Array; + if (!Array.isArray(data) || data.length === 0) return null; + return mapLookupItem(data[0]); + } catch { + return null; + } } // @@ -81,6 +241,10 @@ export namespace FetchLocation { administrativeAreaType: string; address: Address; highlights: Highlights; + position?: { + lat: number; + lng: number; + }; } export interface Response { From 13e9ba8738265fc30d1a6b02b56340688d05d196 Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:48:46 +0200 Subject: [PATCH 10/18] =?UTF-8?q?feat(dpp):=20=E2=9C=A8=20dedicated=20DPP?= =?UTF-8?q?=20detail=20page=20with=20prototype-matching=20UI=20(#835)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /dpps/[id] route with SSG fallback and public access - Header card: orange DPP icon, inline status badge, batch code, Share/Download PDF/overflow action buttons - Collapsible 'What is a DPP?' explainer card with checkmarks - Sticky sidebar with colored section navigation (desktop) - Section cards with colored icon headers, title+subtitle, field grids - Product detail DPP cards navigate to /dpps/{id} - Profile DPP tab rows navigate to /dpps/{id} with keyboard support - Fix useDppApi infinite re-render: bypass unstable useStorage refs, read localStorage directly in useCallback bodies Closes: interfacer-gui-7yb.1, .2, .3, .4, .6, .7, .8, .9 --- .beads/last-touched | 2 +- components/ProfilePageNew.tsx | 40 +- components/ProjectDetailNew.tsx | 36 +- lib/dpp-pdf.ts | 284 ++++++++++++++ lib/dpp.ts | 346 +++++++++-------- package.json | 2 + pages/dpps/[id].tsx | 645 ++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 182 +++++++++ 8 files changed, 1362 insertions(+), 175 deletions(-) create mode 100644 lib/dpp-pdf.ts create mode 100644 pages/dpps/[id].tsx diff --git a/.beads/last-touched b/.beads/last-touched index 5bbacc32..200a5a4e 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -interfacer-gui-ve1.1 +interfacer-gui-7yb.10.4 diff --git a/components/ProfilePageNew.tsx b/components/ProfilePageNew.tsx index 6a4441ec..aaddffe5 100644 --- a/components/ProfilePageNew.tsx +++ b/components/ProfilePageNew.tsx @@ -335,6 +335,7 @@ function ProfileTabContent({ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwner: boolean; ctaConfig: TabCtaConfig }) { const { t } = useTranslation("common"); + const router = useRouter(); const dppApi = useDppApi(); const [dpps, setDpps] = useState([]); const [total, setTotal] = useState(0); @@ -551,7 +552,7 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne
{t("Type")} {t("Status")} {t("Created")} + {t("Action")}
{/* Table rows */} @@ -570,20 +572,35 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne dpp.productOverview?.productName?.value || dpp.productOverview?.brandName?.value || t("Untitled DPP"); const status = dpp.status || "draft"; const colors = statusColors[status] || statusColors.draft; + const dppUrl = `/dpps/${encodeURIComponent(dpp.id)}`; return (
router.push(dppUrl)} + onKeyDown={event => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + router.push(dppUrl); + } + }} style={{ - gridTemplateColumns: "2fr 1fr 100px 100px 140px", + gridTemplateColumns: "2fr 1fr 100px 100px 140px 90px", fontFamily: "var(--ifr-font-body)", fontSize: "var(--ifr-fs-base)", }} > - - {productName} - + + + {productName} + + {dpp.batchId || "—"} {dpp.createdAt ? new Date(dpp.createdAt).toLocaleDateString() : "—"} + + + {t("View")} + +
); })} diff --git a/components/ProjectDetailNew.tsx b/components/ProjectDetailNew.tsx index da97704e..f9d761e2 100644 --- a/components/ProjectDetailNew.tsx +++ b/components/ProjectDetailNew.tsx @@ -909,7 +909,6 @@ function DppDisplay({ dpp }: { dpp: Record }) { /** Card for a single DPP in the Digital Product Passports list. */ function DppListCard({ dpp, index, color }: { dpp: DppDocument; index: number; color: string }) { const { t } = useTranslation("common"); - const [expanded, setExpanded] = useState(false); const label = dpp.productOverview?.productName?.value || `DPP-${String(index + 1).padStart(3, "0")}`; const batchLabel = dpp.batchType === "unit" ? t("Unit") : t("Batch"); @@ -979,28 +978,21 @@ function DppListCard({ dpp, index, color }: { dpp: DppDocument; index: number; c
{/* View DPP button */} - + + + {t("View DPP")} + +
- - {/* Expanded detail */} - {expanded && ( -
- -
- )}
); } diff --git a/lib/dpp-pdf.ts b/lib/dpp-pdf.ts new file mode 100644 index 00000000..73274c1c --- /dev/null +++ b/lib/dpp-pdf.ts @@ -0,0 +1,284 @@ +import { jsPDF } from "jspdf"; +import autoTable from "jspdf-autotable"; +import type { DppDocument } from "./dpp-types"; + +// --- Helpers (mirrored from the page) --- + +function prettifyKey(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .replace(/^./, first => first.toUpperCase()); +} + +function getFieldValue(value: unknown): string | null { + if (value == null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + if (value.length === 0) return null; + return value.map(getFieldValue).filter(Boolean).join(", ") || null; + } + if (typeof value === "object") { + const tv = value as { value?: unknown; units?: unknown }; + if (Object.prototype.hasOwnProperty.call(tv, "value")) { + const scalar = getFieldValue(tv.value); + if (!scalar) return null; + const units = typeof tv.units === "string" ? tv.units : null; + return units ? `${scalar} ${units}` : scalar; + } + } + return null; +} + +function sectionFields(section: unknown): Array<{ label: string; value: string }> { + if (!section || typeof section !== "object" || Array.isArray(section)) return []; + return Object.entries(section) + .map(([key, raw]) => { + const value = getFieldValue(raw); + if (!value) return null; + return { label: prettifyKey(key), value }; + }) + .filter((f): f is { label: string; value: string } => f !== null); +} + +// --- Section configuration (same order & colors as UI) --- + +const sectionConfig: Array<{ key: keyof DppDocument; title: string; subtitle: string; color: string }> = [ + { + key: "productOverview", + title: "DPP Overview", + subtitle: "Basic product information and identification", + color: "#E87C1E", + }, + { + key: "complianceAndStandards", + title: "Compliance & Standards", + subtitle: "Regulatory compliance information", + color: "#2E7D32", + }, + { + key: "reparability", + title: "Reparability", + subtitle: "Repair instructions and spare parts availability", + color: "#1565C0", + }, + { + key: "environmentalImpact", + title: "Environmental Impact", + subtitle: "Resource consumption and emissions data", + color: "#558B2F", + }, + { + key: "certificates", + title: "Certificates", + subtitle: "Environmental and quality certifications", + color: "#6A1B9A", + }, + { + key: "recyclability", + title: "Recyclability", + subtitle: "Material composition and recycling information", + color: "#00838F", + }, + { + key: "energyUseAndEfficiency", + title: "Energy Use & Efficiency", + subtitle: "Battery and power specifications", + color: "#EF6C00", + }, + { + key: "economicOperator", + title: "Economic Operator", + subtitle: "Manufacturer and company information", + color: "#4E342E", + }, + { + key: "repairInformation", + title: "Information about the Repair", + subtitle: "Repair events and documentation", + color: "#1565C0", + }, + { + key: "refurbishmentInformation", + title: "Information about the Refurbishment", + subtitle: "Refurbishment events and processes", + color: "#00695C", + }, + { + key: "recyclingInformation", + title: "Information on the Recycling", + subtitle: "End-of-life recycling data", + color: "#37474F", + }, + { + key: "consumerInformation", + title: "Consumer Information", + subtitle: "Product usage and safety details", + color: "#AD1457", + }, + { + key: "dosageInstructions", + title: "Dosage Instructions", + subtitle: "Application and dosage guidance", + color: "#C62828", + }, + { key: "ingredients", title: "Ingredients", subtitle: "Ingredient and substance listing", color: "#283593" }, + { key: "packaging", title: "Packaging", subtitle: "Packaging materials and specifications", color: "#795548" }, +]; + +// --- Color helpers --- + +function hexToRgb(hex: string): [number, number, number] { + const n = parseInt(hex.replace("#", ""), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +function rgbAlpha(hex: string, alpha: number): [number, number, number] { + const [r, g, b] = hexToRgb(hex); + return [ + Math.round(r * alpha + 255 * (1 - alpha)), + Math.round(g * alpha + 255 * (1 - alpha)), + Math.round(b * alpha + 255 * (1 - alpha)), + ]; +} + +// --- PDF generation --- + +const BRAND = "#E87C1E"; +const PAGE_LEFT = 20; +const PAGE_RIGHT = 190; +const CONTENT_WIDTH = PAGE_RIGHT - PAGE_LEFT; + +export function generateDppPdf(dpp: DppDocument): void { + const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + + const productName = dpp.productOverview?.productName?.value || dpp.batchId || dpp.id; + const status = (dpp.status || "draft").charAt(0).toUpperCase() + (dpp.status || "draft").slice(1); + const createdAt = dpp.createdAt ? new Date(dpp.createdAt).toLocaleDateString() : "-"; + + let y = 20; + + // ── Header band ── + doc.setFillColor(...hexToRgb(BRAND)); + doc.rect(0, 0, 210, 38, "F"); + + doc.setTextColor(255, 255, 255); + doc.setFontSize(10); + doc.setFont("helvetica", "normal"); + doc.text("Digital Product Passport", PAGE_LEFT, 14); + + doc.setFontSize(20); + doc.setFont("helvetica", "bold"); + doc.text(String(productName), PAGE_LEFT, 26); + + doc.setFontSize(9); + doc.setFont("helvetica", "normal"); + const statusText = `Status: ${status}`; + const dateText = `Published: ${createdAt}`; + const idText = `ID: ${dpp.id}`; + doc.text([statusText, dateText, idText].join(" • "), PAGE_LEFT, 34); + + y = 46; + + // Batch row + if (dpp.batchId) { + doc.setTextColor(100, 100, 100); + doc.setFontSize(9); + doc.text(`Batch: ${dpp.batchId}`, PAGE_LEFT, y); + y += 6; + } + + // Product link + if (dpp.productId) { + doc.setTextColor(100, 100, 100); + doc.setFontSize(9); + doc.text(`Product: ${dpp.productId}`, PAGE_LEFT, y); + y += 6; + } + + y += 4; + + // ── Sections ── + const populatedSections = sectionConfig + .map(cfg => { + const fields = sectionFields(dpp[cfg.key]); + if (fields.length === 0) return null; + return { ...cfg, fields }; + }) + .filter(Boolean) as Array<(typeof sectionConfig)[number] & { fields: Array<{ label: string; value: string }> }>; + + for (const section of populatedSections) { + // Check if we have room for at least the section header + a few rows + if (y > 255) { + doc.addPage(); + y = 20; + } + + // Section header bar + const [bgR, bgG, bgB] = rgbAlpha(section.color, 0.1); + doc.setFillColor(bgR, bgG, bgB); + doc.roundedRect(PAGE_LEFT, y, CONTENT_WIDTH, 12, 2, 2, "F"); + + // Colored dot + doc.setFillColor(...hexToRgb(section.color)); + doc.circle(PAGE_LEFT + 6, y + 6, 2.5, "F"); + + // Section title + doc.setTextColor(...hexToRgb(section.color)); + doc.setFontSize(11); + doc.setFont("helvetica", "bold"); + doc.text(section.title, PAGE_LEFT + 12, y + 5.5); + + // Section subtitle + doc.setTextColor(120, 120, 120); + doc.setFontSize(7.5); + doc.setFont("helvetica", "normal"); + doc.text(section.subtitle, PAGE_LEFT + 12, y + 10); + + y += 16; + + // Field table + autoTable(doc, { + startY: y, + margin: { left: PAGE_LEFT, right: 210 - PAGE_RIGHT }, + head: [["Field", "Value"]], + body: section.fields.map(f => [f.label, f.value]), + theme: "grid", + styles: { + fontSize: 8.5, + cellPadding: 3, + lineColor: [220, 220, 220], + lineWidth: 0.3, + textColor: [50, 50, 50], + }, + headStyles: { + fillColor: hexToRgb(section.color), + textColor: [255, 255, 255], + fontStyle: "bold", + fontSize: 8.5, + }, + alternateRowStyles: { + fillColor: [248, 248, 248], + }, + columnStyles: { + 0: { cellWidth: 60, fontStyle: "bold", textColor: [80, 80, 80] }, + }, + }); + + // Advance y past the table + y = (doc as any).lastAutoTable.finalY + 10; + } + + // ── Footer on every page ── + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(7); + doc.setTextColor(160, 160, 160); + doc.text(`Digital Product Passport — ${String(productName)}`, PAGE_LEFT, 290); + doc.text(`Page ${i} of ${pageCount}`, PAGE_RIGHT, 290, { align: "right" }); + } + + // Trigger download + doc.save(`DPP-${dpp.id}.pdf`); +} diff --git a/lib/dpp.ts b/lib/dpp.ts index 21b2895d..b5b2889c 100644 --- a/lib/dpp.ts +++ b/lib/dpp.ts @@ -10,7 +10,7 @@ // @ts-ignore import { useAuth } from "hooks/useAuth"; -import useStorage from "hooks/useStorage"; +import { useCallback, useMemo } from "react"; // @ts-ignore import sign from "zenflows-crypto/src/sign_graphql.zen"; import type { @@ -42,186 +42,238 @@ class DppRequestError extends Error { export { DppRequestError }; const useDppApi = () => { - const { getItem } = useStorage(); const { user } = useAuth(); - async function signBody(body: string): Promise<{ "did-sign": string; "did-pk": string }> { - const zencode_exec = (await import("zenroom")).zencode_exec; - const data = `{"gql": "${Buffer.from(body, "utf8").toString("base64")}"}`; - const keys = `{"keyring": {"eddsa": "${getItem("eddsaPrivateKey")}"}}`; - const { result } = await zencode_exec(sign, { data, keys }); - return { - "did-sign": JSON.parse(result).eddsa_signature, - "did-pk": String(user?.publicKey), - }; - } + const signBody = useCallback( + async (body: string): Promise<{ "did-sign": string; "did-pk": string }> => { + const zencode_exec = (await import("zenroom")).zencode_exec; + const eddsaKey = typeof window !== "undefined" ? window.localStorage.getItem("eddsaPrivateKey") || "" : ""; + const data = `{"gql": "${Buffer.from(body, "utf8").toString("base64")}"}`; + const keys = `{"keyring": {"eddsa": "${eddsaKey}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + return { + "did-sign": JSON.parse(result).eddsa_signature, + "did-pk": String(user?.publicKey), + }; + }, + [user?.publicKey] + ); - async function request(method: string, path: string, body?: any): Promise { - const url = `${DPP_BASE_URL}${path}`; - const jsonBody = body != null ? JSON.stringify(body) : undefined; + const request = useCallback( + async (method: string, path: string, body?: any): Promise => { + const url = `${DPP_BASE_URL}${path}`; + const jsonBody = body != null ? JSON.stringify(body) : undefined; - const headers: Record = {}; + const headers: Record = {}; - // Always send user ULID so backend can store/filter by it - if (user?.ulid) { - headers["x-user-id"] = user.ulid; - } + // Always send user ULID so backend can store/filter by it + if (user?.ulid) { + headers["x-user-id"] = user.ulid; + } - if (jsonBody) { - const authHeaders = await signBody(jsonBody); - Object.assign(headers, authHeaders); - headers["Content-Type"] = "application/json"; - } + if (jsonBody) { + const authHeaders = await signBody(jsonBody); + Object.assign(headers, authHeaders); + headers["Content-Type"] = "application/json"; + } - const res = await fetch(url, { method, headers, body: jsonBody }); + const res = await fetch(url, { method, headers, body: jsonBody }); - if (!res.ok) { - let apiError: DppApiError = { error: res.statusText }; - try { - apiError = await res.json(); - } catch { - // response body may not be JSON + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch { + // response body may not be JSON + } + throw new DppRequestError(apiError.error, res.status, apiError.details); } - throw new DppRequestError(apiError.error, res.status, apiError.details); - } - if (res.status === 204) return undefined as T; - return res.json(); - } + if (res.status === 204) return undefined as T; + return res.json(); + }, + [signBody, user?.ulid] + ); // --- CRUD --- - async function createDpp(data: Omit): Promise { - return request("POST", "/dpp", data); - } - - async function getDpp(id: string): Promise { - return request("GET", `/dpp/${encodeURIComponent(id)}`); - } - - async function updateDpp(id: string, data: Partial): Promise { - return request("PUT", `/dpp/${encodeURIComponent(id)}`, data); - } - - async function deleteDpp(id: string): Promise { - return request("DELETE", `/dpp/${encodeURIComponent(id)}`); - } + const createDpp = useCallback( + async (data: Omit): Promise => { + return request("POST", "/dpp", data); + }, + [request] + ); + + const getDpp = useCallback( + async (id: string): Promise => { + return request("GET", `/dpp/${encodeURIComponent(id)}`); + }, + [request] + ); + + const updateDpp = useCallback( + async (id: string, data: Partial): Promise => { + return request("PUT", `/dpp/${encodeURIComponent(id)}`, data); + }, + [request] + ); + + const deleteDpp = useCallback( + async (id: string): Promise => { + return request("DELETE", `/dpp/${encodeURIComponent(id)}`); + }, + [request] + ); // --- List / Query --- - async function listDpps(filters?: ListDppsFilters): Promise { - const params = new URLSearchParams(); - if (filters?.productId) params.set("productId", filters.productId); - if (filters?.createdBy) params.set("createdBy", filters.createdBy); - if (filters?.status) params.set("status", filters.status); - if (filters?.limit != null) params.set("limit", String(filters.limit)); - if (filters?.offset != null) params.set("offset", String(filters.offset)); + const listDpps = useCallback( + async (filters?: ListDppsFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.productId) params.set("productId", filters.productId); + if (filters?.createdBy) params.set("createdBy", filters.createdBy); + if (filters?.status) params.set("status", filters.status); + if (filters?.limit != null) params.set("limit", String(filters.limit)); + if (filters?.offset != null) params.set("offset", String(filters.offset)); - const qs = params.toString(); - const path = qs ? `/dpps?${qs}` : "/dpps"; + const qs = params.toString(); + const path = qs ? `/dpps?${qs}` : "/dpps"; - return request("GET", path); - } + return request("GET", path); + }, + [request] + ); // --- File operations --- - async function uploadFile(file: File): Promise { - const arrayBuffer = await file.arrayBuffer(); - const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const checksum = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + const uploadFile = useCallback( + async (file: File): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const checksum = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); - const zencode_exec = (await import("zenroom")).zencode_exec; - const data = `{"gql": "${checksum}"}`; - const keys = `{"keyring": {"eddsa": "${getItem("eddsaPrivateKey")}"}}`; - const { result } = await zencode_exec(sign, { data, keys }); - const signature = JSON.parse(result).eddsa_signature; + const zencode_exec = (await import("zenroom")).zencode_exec; + const eddsaKey = typeof window !== "undefined" ? window.localStorage.getItem("eddsaPrivateKey") || "" : ""; + const data = `{"gql": "${checksum}"}`; + const keys = `{"keyring": {"eddsa": "${eddsaKey}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + const signature = JSON.parse(result).eddsa_signature; - const formData = new FormData(); - formData.append("file", file); + const formData = new FormData(); + formData.append("file", file); - const res = await fetch(`${DPP_BASE_URL}/upload`, { - method: "POST", - headers: { - "did-pk": String(user?.publicKey), - "did-sign": signature, - }, - body: formData, - }); - - if (!res.ok) { - let apiError: DppApiError = { error: res.statusText }; - try { - apiError = await res.json(); - } catch {} - throw new DppRequestError(apiError.error, res.status, apiError.details); - } - - return res.json(); - } - - function getFileUrl(id: string): string { - return `${DPP_BASE_URL}/file/${encodeURIComponent(id)}`; - } - - async function updateDppStatus(id: string, status: DppStatus): Promise { - return request("PUT", `/dpp/${encodeURIComponent(id)}/status`, { status }); - } - - async function addAttachment(dppId: string, section: string, file: File): Promise { - const arrayBuffer = await file.arrayBuffer(); - const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const checksum = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); - - const zencode_exec = (await import("zenroom")).zencode_exec; - const data = `{"gql": "${checksum}"}`; - const keys = `{"keyring": {"eddsa": "${getItem("eddsaPrivateKey")}"}}`; - const { result } = await zencode_exec(sign, { data, keys }); - const signature = JSON.parse(result).eddsa_signature; - - const formData = new FormData(); - formData.append("file", file); - - const res = await fetch( - `${DPP_BASE_URL}/dpp/${encodeURIComponent(dppId)}/attachments?section=${encodeURIComponent(section)}`, - { + const res = await fetch(`${DPP_BASE_URL}/upload`, { method: "POST", headers: { "did-pk": String(user?.publicKey), "did-sign": signature, }, body: formData, + }); + + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch {} + throw new DppRequestError(apiError.error, res.status, apiError.details); } - ); - if (!res.ok) { - let apiError: DppApiError = { error: res.statusText }; - try { - apiError = await res.json(); - } catch {} - throw new DppRequestError(apiError.error, res.status, apiError.details); - } - - return res.json(); - } + return res.json(); + }, + [user?.publicKey] + ); - async function deleteAttachment(dppId: string, attachmentId: string): Promise { - return request("DELETE", `/dpp/${encodeURIComponent(dppId)}/attachments/${encodeURIComponent(attachmentId)}`); - } + const getFileUrl = useCallback((id: string): string => { + return `${DPP_BASE_URL}/file/${encodeURIComponent(id)}`; + }, []); + + const updateDppStatus = useCallback( + async (id: string, status: DppStatus): Promise => { + return request("PUT", `/dpp/${encodeURIComponent(id)}/status`, { status }); + }, + [request] + ); + + const addAttachment = useCallback( + async (dppId: string, section: string, file: File): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const checksum = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + + const zencode_exec = (await import("zenroom")).zencode_exec; + const eddsaKey = typeof window !== "undefined" ? window.localStorage.getItem("eddsaPrivateKey") || "" : ""; + const data = `{"gql": "${checksum}"}`; + const keys = `{"keyring": {"eddsa": "${eddsaKey}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + const signature = JSON.parse(result).eddsa_signature; + + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch( + `${DPP_BASE_URL}/dpp/${encodeURIComponent(dppId)}/attachments?section=${encodeURIComponent(section)}`, + { + method: "POST", + headers: { + "did-pk": String(user?.publicKey), + "did-sign": signature, + }, + body: formData, + } + ); + + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch {} + throw new DppRequestError(apiError.error, res.status, apiError.details); + } - return { - createDpp, - getDpp, - updateDpp, - deleteDpp, - listDpps, - uploadFile, - getFileUrl, - updateDppStatus, - addAttachment, - deleteAttachment, - }; + return res.json(); + }, + [user?.publicKey] + ); + + const deleteAttachment = useCallback( + async (dppId: string, attachmentId: string): Promise => { + return request( + "DELETE", + `/dpp/${encodeURIComponent(dppId)}/attachments/${encodeURIComponent(attachmentId)}` + ); + }, + [request] + ); + + return useMemo( + () => ({ + createDpp, + getDpp, + updateDpp, + deleteDpp, + listDpps, + uploadFile, + getFileUrl, + updateDppStatus, + addAttachment, + deleteAttachment, + }), + [ + createDpp, + getDpp, + updateDpp, + deleteDpp, + listDpps, + uploadFile, + getFileUrl, + updateDppStatus, + addAttachment, + deleteAttachment, + ] + ); }; export default useDppApi; diff --git a/package.json b/package.json index 38339d46..a3ffd53d 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "focus-trap-react": "^10.3.1", "graphql": "^15.10.1", "husky": "^8.0.3", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.7", "lint-staged": "^13.3.0", "mapbox-gl": "^2.15.0", "markdown-it": "^13.0.2", diff --git a/pages/dpps/[id].tsx b/pages/dpps/[id].tsx new file mode 100644 index 00000000..ae683c0d --- /dev/null +++ b/pages/dpps/[id].tsx @@ -0,0 +1,645 @@ +import { + ArrowLeft, + Checkmark, + ChevronDown, + ChevronUp, + Download, + Launch, + OverflowMenuVertical, + Share, +} from "@carbon/icons-react"; +import Layout from "components/layout/Layout"; +import useDppApi, { DppRequestError } from "lib/dpp"; +import { generateDppPdf } from "lib/dpp-pdf"; +import type { DppDocument } from "lib/dpp-types"; +import type { GetStaticPaths } from "next"; +import { useTranslation } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { NextPageWithLayout } from "pages/_app"; +import { ReactElement, useEffect, useMemo, useRef, useState } from "react"; + +function prettifyKey(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .replace(/^./, first => first.toUpperCase()); +} + +function getFieldValue(value: unknown): string | null { + if (value == null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (Array.isArray(value)) { + if (value.length === 0) return null; + return value.map(getFieldValue).filter(Boolean).join(", ") || null; + } + + if (typeof value === "object") { + const maybeTransformed = value as { value?: unknown; units?: unknown }; + if (Object.prototype.hasOwnProperty.call(maybeTransformed, "value")) { + const scalar = getFieldValue(maybeTransformed.value); + if (!scalar) return null; + const units = typeof maybeTransformed.units === "string" ? maybeTransformed.units : null; + return units ? `${scalar} ${units}` : scalar; + } + } + + return null; +} + +function sectionFields(section: unknown): Array<{ label: string; value: string }> { + if (!section || typeof section !== "object" || Array.isArray(section)) return []; + + return Object.entries(section) + .map(([key, raw]) => { + const value = getFieldValue(raw); + if (!value) return null; + return { label: prettifyKey(key), value }; + }) + .filter((field): field is { label: string; value: string } => field !== null); +} + +const sectionConfig: Array<{ key: keyof DppDocument; title: string; subtitle: string; color: string }> = [ + { + key: "productOverview", + title: "DPP Overview", + subtitle: "Basic product information and identification", + color: "#E87C1E", + }, + { + key: "complianceAndStandards", + title: "Compliance & Standards", + subtitle: "Regulatory compliance information", + color: "#2E7D32", + }, + { + key: "reparability", + title: "Reparability", + subtitle: "Repair instructions and spare parts availability", + color: "#1565C0", + }, + { + key: "environmentalImpact", + title: "Environmental Impact", + subtitle: "Resource consumption and emissions data", + color: "#558B2F", + }, + { + key: "certificates", + title: "Certificates", + subtitle: "Environmental and quality certifications", + color: "#6A1B9A", + }, + { + key: "recyclability", + title: "Recyclability", + subtitle: "Material composition and recycling information", + color: "#00838F", + }, + { + key: "energyUseAndEfficiency", + title: "Energy Use & Efficiency", + subtitle: "Battery and power specifications", + color: "#EF6C00", + }, + { + key: "economicOperator", + title: "Economic Operator", + subtitle: "Manufacturer and company information", + color: "#4E342E", + }, + { + key: "repairInformation", + title: "Information about the Repair", + subtitle: "Repair events and documentation", + color: "#1565C0", + }, + { + key: "refurbishmentInformation", + title: "Information about the Refurbishment", + subtitle: "Refurbishment events and processes", + color: "#00695C", + }, + { + key: "recyclingInformation", + title: "Information on the Recycling", + subtitle: "End-of-life recycling data", + color: "#37474F", + }, + { + key: "consumerInformation", + title: "Consumer Information", + subtitle: "Product usage and safety details", + color: "#AD1457", + }, + { + key: "dosageInstructions", + title: "Dosage Instructions", + subtitle: "Application and dosage guidance", + color: "#C62828", + }, + { key: "ingredients", title: "Ingredients", subtitle: "Ingredient and substance listing", color: "#283593" }, + { key: "packaging", title: "Packaging", subtitle: "Packaging materials and specifications", color: "#795548" }, +]; + +const DppDetailPage: NextPageWithLayout = () => { + const { t } = useTranslation("common"); + const router = useRouter(); + const dppApi = useDppApi(); + const [dpp, setDpp] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const dppId = typeof router.query.id === "string" ? router.query.id : ""; + + useEffect(() => { + if (!router.isReady || !dppId) return; + + let cancelled = false; + setLoading(true); + setError(null); + + dppApi + .getDpp(dppId) + .then(doc => { + if (!cancelled) setDpp(doc); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof DppRequestError && err.status === 404) { + setDpp(null); + setError("not-found"); + return; + } + setError("generic"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [dppApi, dppId, router.isReady]); + + const dppTitle = dpp?.productOverview?.productName?.value || dpp?.batchId || dpp?.id || dppId; + const createdAt = dpp?.createdAt ? new Date(dpp.createdAt).toLocaleDateString() : "-"; + const parentProductLabel = dpp?.productId || t("Unknown product"); + const breadcrumbSeparator = "/"; + + const onShare = async () => { + const url = typeof window !== "undefined" ? window.location.href : ""; + if (typeof navigator !== "undefined" && navigator.share) { + await navigator.share({ title: dppTitle, text: t("Digital Product Passport"), url }); + return; + } + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(url); + } + }; + + const onDownloadJson = () => { + if (!dpp || typeof window === "undefined") return; + const blob = new Blob([JSON.stringify(dpp, null, 2)], { type: "application/json" }); + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = `${dpp.id}.json`; + anchor.click(); + URL.revokeObjectURL(href); + }; + + const [pdfGenerating, setPdfGenerating] = useState(false); + + const onDownloadPdf = async () => { + if (!dpp || typeof window === "undefined" || pdfGenerating) return; + setPdfGenerating(true); + try { + generateDppPdf(dpp); + } finally { + setPdfGenerating(false); + } + }; + + const sections = useMemo(() => { + if (!dpp) return []; + return sectionConfig + .map(config => { + const fields = sectionFields(dpp[config.key]); + if (fields.length === 0) return null; + return { key: String(config.key), title: config.title, subtitle: config.subtitle, color: config.color, fields }; + }) + .filter(Boolean) as Array<{ + key: string; + title: string; + subtitle: string; + color: string; + fields: Array<{ label: string; value: string }>; + }>; + }, [dpp]); + + const [dppInfoOpen, setDppInfoOpen] = useState(true); + const sectionRefs = useRef>({}); + + const scrollToSection = (key: string) => { + sectionRefs.current[key]?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + const dppIcon = ( + + + + ); + + return ( +
+
+ + + + {t("Back")} + + + + {loading && ( +
+ {t("Loading DPP details...")} +
+ )} + + {!loading && error === "not-found" && ( +
+

+ {t("DPP not found")} +

+

+ {t("This Digital Product Passport does not exist or is no longer available.")} +

+
+ )} + + {!loading && error === "generic" && ( +
+

+ {t("Unable to load DPP")} +

+

{t("An error occurred while loading the DPP details.")}

+ +
+ )} + + {!loading && !error && dpp && ( + <> + {/* Breadcrumbs */} + + + {/* Header card */} +
+ {/* Top row: label + actions */} +
+
+
+ {dppIcon} + + {t("Digital Product Passport")} + +
+
+

+ {dppTitle} +

+ + {dpp.status === "active" && ( + + )} + {(dpp.status || "draft").charAt(0).toUpperCase() + (dpp.status || "draft").slice(1)} + +
+ {dpp.batchId && ( +
+ {t("Batch") + ":"} + + {dpp.batchId} + +
+ )} +
+ + {/* Action buttons */} +
+ + + +
+
+ + {/* Product link row */} +
+
+ + {t("Product") + ":"} + + {dpp.productId ? ( + + + + {parentProductLabel} + + + ) : ( + {t("Not linked")} + )} +
+ + {t("Published")} {createdAt} + +
+
+ + {/* "What is a DPP?" explainer card */} +
+ + {dppInfoOpen && ( +
+

+ {t( + "A Digital Product Passport (DPP) is a structured digital record that travels with a product throughout its entire lifecycle — from manufacturing to end-of-life. It provides transparent, verifiable information about sustainability, compliance, repairability, and recyclability." + )} +

+
    + {[ + t("Traceability — records who made the product, where, and with what materials"), + t("Circularity support — enables repair, refurbishment, and responsible recycling"), + t("Regulatory compliance — documents CE marking, RoHS, and other certifications"), + t("Consumer transparency — gives buyers verified data on the product they purchased"), + ].map(item => ( +
  • + + {item} +
  • + ))} +
+
+ )} +
+ + {/* Two-column: sections + sidebar */} +
+ {/* Sections column */} +
+ {sections.length === 0 ? ( +
+ {t("No section data available for this DPP.")} +
+ ) : ( + sections.map(section => ( +
{ + sectionRefs.current[section.key] = el; + }} + className="bg-ifr-surface border border-ifr rounded-ifr-md" + > + {/* Section header with colored icon */} +
+
+ + + +
+
+

+ {t(section.title)} +

+

+ {t(section.subtitle)} +

+
+
+ {/* Section fields */} +
+ {section.fields.map(field => ( +
+

+ {t(field.label)} +

+

+ {field.value} +

+
+ ))} +
+
+ )) + )} +
+ + {/* Sidebar navigation (desktop only) */} + {sections.length > 0 && ( +
+
+ {sections.map(section => ( + + ))} +
+
+ )} +
+ + )} +
+
+ ); +}; + +export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => { + return { + paths: [], + fallback: "blocking", + }; +}; + +export async function getStaticProps({ locale }: { locale: string }) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common"])), + }, + }; +} + +DppDetailPage.publicPage = true; + +DppDetailPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default DppDetailPage; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddb52a3c..f87a94ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,12 @@ importers: husky: specifier: ^8.0.3 version: 8.0.3 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 + jspdf-autotable: + specifier: ^5.0.7 + version: 5.0.7(jspdf@4.2.1) lint-staged: specifier: ^13.3.0 version: 13.3.0 @@ -573,6 +579,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1455,6 +1465,9 @@ packages: '@types/node@18.7.15': resolution: {integrity: sha512-XnjpaI8Bgc3eBag2Aw4t2Uj/49lLBSStHWfqKvIuXD7FIrZyMLWp8KuAFHAqxMZYTF9l08N1ctUn9YNybZJVmQ==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -1467,6 +1480,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@18.0.6': resolution: {integrity: sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==} @@ -1484,6 +1500,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1822,6 +1841,10 @@ packages: base45@2.0.1: resolution: {integrity: sha512-Cr2mmczMfOQSkt9OyzpUpUNWKCurLpYhCjkN+yPXicjEc47gkN9LbklR3c7dUrpcPonjoAyZp7bZQChRRg4G1Q==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1925,6 +1948,10 @@ packages: caniuse-lite@1.0.30001749: resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -2107,6 +2134,9 @@ packages: peerDependencies: postcss: ^8.4 + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-prefers-color-scheme@6.0.3: resolution: {integrity: sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==} engines: {node: ^12 || ^14 || >=16} @@ -2335,6 +2365,9 @@ packages: dom7@4.0.6: resolution: {integrity: sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==} + dompurify@3.4.0: + resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -2617,6 +2650,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} @@ -2638,6 +2674,9 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -2926,6 +2965,10 @@ packages: html-void-elements@2.0.1: resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + http-proxy-agent@6.1.1: resolution: {integrity: sha512-JRCz+4Whs6yrrIoIlrH+ZTmhrRwtMnmOHsHn8GFEn9O2sVfSE+DAZ3oyyGIKF8tjJEeSJmP89j7aTjVsSqsU0g==} engines: {node: '>= 14'} @@ -3008,6 +3051,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + is-absolute@1.0.0: resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} engines: {node: '>=0.10.0'} @@ -3291,6 +3337,14 @@ packages: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} + jspdf-autotable@5.0.7: + resolution: {integrity: sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==} + peerDependencies: + jspdf: ^2 || ^3 || ^4 + + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3891,6 +3945,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -3963,6 +4020,9 @@ packages: resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} hasBin: true + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4297,6 +4357,9 @@ packages: quickselect@2.0.0: resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + react-base16-styling@0.6.0: resolution: {integrity: sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==} @@ -4439,6 +4502,9 @@ packages: refractor@4.9.0: resolution: {integrity: sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -4548,6 +4614,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -4731,6 +4801,10 @@ packages: ssr-window@4.0.2: resolution: {integrity: sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + start-server-and-test@1.15.5: resolution: {integrity: sha512-o3EmkX0++GV+qsvIJ/OKWm3w91fD8uS/bPQVPrh/7loaxkpXSuAIHdnmN/P/regQK9eNAK76aBJcHt+OSTk+nA==} engines: {node: '>=6'} @@ -4848,6 +4922,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + swap-case@2.0.2: resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} @@ -4876,6 +4954,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -5120,6 +5201,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -5668,6 +5752,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -6817,6 +6903,8 @@ snapshots: '@types/node@18.7.15': {} + '@types/pako@2.0.4': {} + '@types/parse-json@4.0.2': {} '@types/parse5@6.0.3': {} @@ -6825,6 +6913,9 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/raf@3.4.3': + optional: true + '@types/react-dom@18.0.6': dependencies: '@types/react': 18.0.18 @@ -6843,6 +6934,9 @@ snapshots: '@types/semver@7.7.1': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/ws@8.18.1': @@ -7289,6 +7383,9 @@ snapshots: base45@2.0.1: {} + base64-arraybuffer@1.0.2: + optional: true + base64-js@1.5.1: {} base64url@3.0.1: {} @@ -7392,6 +7489,18 @@ snapshots: caniuse-lite@1.0.30001749: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.2 + '@types/raf': 3.4.3 + core-js: 3.45.1 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + capital-case@1.0.4: dependencies: no-case: 3.0.4 @@ -7587,6 +7696,11 @@ snapshots: postcss: 8.5.6 postcss-selector-parser: 6.1.2 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + css-prefers-color-scheme@6.0.3(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -7773,6 +7887,11 @@ snapshots: dependencies: ssr-window: 4.0.2 + dompurify@3.4.0: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -8209,6 +8328,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 @@ -8245,6 +8370,8 @@ snapshots: transitivePeerDependencies: - encoding + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -8604,6 +8731,12 @@ snapshots: html-void-elements@2.0.1: {} + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + http-proxy-agent@6.1.1: dependencies: agent-base: 7.1.4 @@ -8692,6 +8825,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@5.4.0: {} + is-absolute@1.0.0: dependencies: is-relative: 1.0.0 @@ -8971,6 +9106,21 @@ snapshots: ms: 2.1.3 semver: 7.7.3 + jspdf-autotable@5.0.7(jspdf@4.2.1): + dependencies: + jspdf: 4.2.1 + + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.2 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.45.1 + dompurify: 3.4.0 + html2canvas: 1.4.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -9780,6 +9930,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.1.0: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -9858,6 +10010,9 @@ snapshots: ieee754: 1.2.1 resolve-protobuf-schema: 2.1.0 + performance-now@2.1.0: + optional: true + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -10170,6 +10325,11 @@ snapshots: quickselect@2.0.0: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + react-base16-styling@0.6.0: dependencies: base16: 1.0.0 @@ -10371,6 +10531,9 @@ snapshots: hastscript: 7.2.0 parse-entities: 4.0.2 + regenerator-runtime@0.13.11: + optional: true + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -10522,6 +10685,9 @@ snapshots: rfdc@1.4.1: {} + rgbcolor@1.0.1: + optional: true + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -10725,6 +10891,9 @@ snapshots: ssr-window@4.0.2: {} + stackblur-canvas@2.7.0: + optional: true + start-server-and-test@1.15.5: dependencies: arg: 5.0.2 @@ -10867,6 +11036,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-pathdata@6.0.3: + optional: true + swap-case@2.0.2: dependencies: tslib: 2.8.1 @@ -10916,6 +11088,11 @@ snapshots: - tsx - yaml + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + text-table@0.2.0: {} thenify-all@1.6.0: @@ -11174,6 +11351,11 @@ snapshots: util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + uuid@8.3.2: {} uvu@0.5.6: From 2bba881b19d7ef779a06d10bff68c5ab4bd49e65 Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:21:19 +0200 Subject: [PATCH 11/18] =?UTF-8?q?feat(layout):=20=E2=9C=A8=20show=20top=20?= =?UTF-8?q?bar=20and=20sidebar=20for=20unauthenticated=20users=20(#836)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layout.tsx: always render Topbar, remove authenticated gate - Topbar: show nav links for all users, add sign-in/sign-up for guests - NavigationMenu: add sign-in/sign-up CTA for unauthenticated users - sign_in/sign_up: use standard Layout instead of NRULayout --- .beads/last-touched | 2 +- components/NavigationMenu.tsx | 36 +++++++ components/layout/Layout.tsx | 24 ++--- components/partials/topbar/Topbar.tsx | 140 ++++++++++++++++---------- pages/sign_in.tsx | 4 +- pages/sign_up.tsx | 4 +- 6 files changed, 134 insertions(+), 76 deletions(-) diff --git a/.beads/last-touched b/.beads/last-touched index 200a5a4e..f7bfa6d0 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -interfacer-gui-7yb.10.4 +interfacer-gui-24l.7 diff --git a/components/NavigationMenu.tsx b/components/NavigationMenu.tsx index e3b307a3..9352e45f 100644 --- a/components/NavigationMenu.tsx +++ b/components/NavigationMenu.tsx @@ -490,6 +490,42 @@ export default function NavigationMenu({ open, onClose }: NavigationMenuProps) { activeTextColor="var(--ifr-text-primary)" /> + {/* Sign-in CTA for unauthenticated users */} + {!user && ( + <> + +
+ + +
+ + )} +
diff --git a/components/layout/Layout.tsx b/components/layout/Layout.tsx index d4bb2189..53859dc5 100644 --- a/components/layout/Layout.tsx +++ b/components/layout/Layout.tsx @@ -34,24 +34,16 @@ const Layout: React.FunctionComponent = (layoutProps: layoutProps) "pb-20": bottomPadding === "lg", }); - if (!authenticated) - return ( -
-
{layoutProps?.children}
-
-
- ); + // Show layout shell immediately for unauthenticated users; + // for authenticated users, wait until loading finishes. + if (authenticated && loading) return null; return ( - <> - {!loading && ( -
- -
{layoutProps?.children}
-
-
- )} - +
+ +
{layoutProps?.children}
+
+
); }; diff --git a/components/partials/topbar/Topbar.tsx b/components/partials/topbar/Topbar.tsx index 4ed91a12..fc19f524 100644 --- a/components/partials/topbar/Topbar.tsx +++ b/components/partials/topbar/Topbar.tsx @@ -114,61 +114,59 @@ function Topbar({ search = true, userMenu = true, cta, burger = true }: topbarPr {/* Navigation links */} - {user && ( - - )} +
{/* Center: Search bar */} @@ -197,6 +195,38 @@ function Topbar({ search = true, userMenu = true, cta, burger = true }: topbarPr {/* Right section */}
{cta} + {/* Sign-in / Sign-up buttons for unauthenticated users */} + {!user && !isSignin && !isSignup && ( +
+ + +
+ )} {(isSignup || isSignin) && (
- {/* Sort & Show */} + {/* Sort */}
-
diff --git a/lib/QueryAndMutation.ts b/lib/QueryAndMutation.ts index 48dcc22e..c09e6efb 100644 --- a/lib/QueryAndMutation.ts +++ b/lib/QueryAndMutation.ts @@ -470,8 +470,15 @@ export const FETCH_USER = gql` `; export const FETCH_RESOURCES = gql` - query FetchInventory($first: Int, $after: ID, $last: Int, $before: ID, $filter: EconomicResourceFilterParams) { - economicResources(first: $first, after: $after, before: $before, last: $last, filter: $filter) { + query FetchInventory( + $first: Int + $after: ID + $last: Int + $before: ID + $filter: EconomicResourceFilterParams + $orderBy: EconomicResourceSortInput + ) { + economicResources(first: $first, after: $after, before: $before, last: $last, filter: $filter, orderBy: $orderBy) { pageInfo { startCursor endCursor @@ -479,6 +486,7 @@ export const FETCH_RESOURCES = gql` hasNextPage totalCount pageLimit + distinctPrimaryAccountableCount } edges { cursor diff --git a/lib/types/index.ts b/lib/types/index.ts index 95b5b9bc..27d9b499 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -650,6 +650,21 @@ export type EconomicResourceFilterParams = { repo?: InputMaybe; }; +export enum EconomicResourceSortField { + CreatedAt = "CREATED_AT", + Name = "NAME", +} + +export enum SortDirection { + Asc = "ASC", + Desc = "DESC", +} + +export type EconomicResourceSortInput = { + field: EconomicResourceSortField; + direction: SortDirection; +}; + export type EconomicResourceResponse = { __typename?: "EconomicResourceResponse"; economicResource: EconomicResource; @@ -4236,6 +4251,7 @@ export type FetchInventoryQueryVariables = Exact<{ last?: InputMaybe; before?: InputMaybe; filter?: InputMaybe; + orderBy?: InputMaybe; }>; export type FetchInventoryQuery = { @@ -4250,6 +4266,7 @@ export type FetchInventoryQuery = { hasNextPage: boolean; totalCount?: number | null; pageLimit?: number | null; + distinctPrimaryAccountableCount?: number | null; }; edges: Array<{ __typename?: "EconomicResourceEdge"; diff --git a/pages/designs.tsx b/pages/designs.tsx index fe48388c..440c44f3 100644 --- a/pages/designs.tsx +++ b/pages/designs.tsx @@ -4,6 +4,7 @@ import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useCallback, useState } from "react"; import useFilters from "../hooks/useFilters"; import { NextPageWithLayout } from "./_app"; @@ -19,6 +20,23 @@ export async function getStaticProps({ locale }: any) { const Designs: NextPageWithLayout = () => { const { t } = useTranslation("common"); const { designId, specsLoading } = useFilters(); + const [totalCount, setTotalCount] = useState(null); + const [manufacturerCount, setManufacturerCount] = useState(null); + + const handleDataLoaded = useCallback( + ({ + totalCount, + distinctPrimaryAccountableCount, + }: { + totalCount: number; + distinctPrimaryAccountableCount: number; + loading: boolean; + }) => { + setTotalCount(totalCount); + setManufacturerCount(distinctPrimaryAccountableCount); + }, + [] + ); const filter = { conformsTo: designId ? [designId] : undefined, @@ -35,14 +53,14 @@ const Designs: NextPageWithLayout = () => { ), stats: ( <> - - - + + ), }} searchPlaceholder={t("Search designs, makers, machines, materials...")} filter={filter} + onDataLoaded={handleDataLoaded} /> ); }; diff --git a/pages/products.tsx b/pages/products.tsx index 0f9652bd..f2495faa 100644 --- a/pages/products.tsx +++ b/pages/products.tsx @@ -4,6 +4,7 @@ import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useCallback, useState } from "react"; import useFilters from "../hooks/useFilters"; import { NextPageWithLayout } from "./_app"; @@ -19,6 +20,23 @@ export async function getStaticProps({ locale }: any) { const Products: NextPageWithLayout = () => { const { t } = useTranslation("common"); const { productId, specsLoading } = useFilters(); + const [totalCount, setTotalCount] = useState(null); + const [manufacturerCount, setManufacturerCount] = useState(null); + + const handleDataLoaded = useCallback( + ({ + totalCount, + distinctPrimaryAccountableCount, + }: { + totalCount: number; + distinctPrimaryAccountableCount: number; + loading: boolean; + }) => { + setTotalCount(totalCount); + setManufacturerCount(distinctPrimaryAccountableCount); + }, + [] + ); const filter = { conformsTo: productId ? [productId] : undefined, @@ -35,14 +53,14 @@ const Products: NextPageWithLayout = () => { ), stats: ( <> - - - + + ), }} searchPlaceholder={t("Search products, manufacturers, materials...")} filter={filter} + onDataLoaded={handleDataLoaded} /> ); }; diff --git a/pages/services.tsx b/pages/services.tsx index 31d05fd6..c000f915 100644 --- a/pages/services.tsx +++ b/pages/services.tsx @@ -4,6 +4,7 @@ import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useCallback, useState } from "react"; import useFilters from "../hooks/useFilters"; import { NextPageWithLayout } from "./_app"; @@ -19,6 +20,23 @@ export async function getStaticProps({ locale }: any) { const Services: NextPageWithLayout = () => { const { t } = useTranslation("common"); const { serviceId, specsLoading } = useFilters(); + const [totalCount, setTotalCount] = useState(null); + const [providerCount, setProviderCount] = useState(null); + + const handleDataLoaded = useCallback( + ({ + totalCount, + distinctPrimaryAccountableCount, + }: { + totalCount: number; + distinctPrimaryAccountableCount: number; + loading: boolean; + }) => { + setTotalCount(totalCount); + setProviderCount(distinctPrimaryAccountableCount); + }, + [] + ); const filter = { conformsTo: serviceId ? [serviceId] : undefined, @@ -35,14 +53,14 @@ const Services: NextPageWithLayout = () => { ), stats: ( <> - - - + + ), }} searchPlaceholder={t("Search services, providers, locations...")} filter={filter} + onDataLoaded={handleDataLoaded} /> ); }; From 54c1c8838b35c1eeb16a02db760a6d12ad0a0b7e Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:34:14 +0200 Subject: [PATCH 14/18] =?UTF-8?q?feat(dpp):=20=E2=9C=A8=20redesign=20DPP?= =?UTF-8?q?=20tab=20to=20match=20Figma=20prototype=20(#839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add KPI counter cards (total/active/drafts/archived) with backend facets - Add status filter dropdown and expanded sort options - Redesign table with Product ID, DPP ID, Batch/Unit, Status, Created columns - Add QR code download button and More Actions overflow menu - Add pagination footer showing count of displayed DPPs - Update API types for facets, search, sort params - Wire frontend to new backend search/sort/facets/QR endpoints --- components/ProfilePageNew.tsx | 478 +++++++++++++++++++++++++++++----- lib/dpp-types.ts | 10 + lib/dpp.ts | 10 + 3 files changed, 431 insertions(+), 67 deletions(-) diff --git a/components/ProfilePageNew.tsx b/components/ProfilePageNew.tsx index aaddffe5..db13d9bf 100644 --- a/components/ProfilePageNew.tsx +++ b/components/ProfilePageNew.tsx @@ -2,7 +2,19 @@ // Copyright (C) 2022-2023 Dyne.org foundation . import { useQuery } from "@apollo/client"; -import { Add, ArrowRight, Search, SortAscending } from "@carbon/icons-react"; +import { + Add, + ArrowRight, + Calendar, + ChevronDown, + ChevronLeft, + ChevronRight, + Download, + Information, + OverflowMenuVertical, + Search, + SortAscending, +} from "@carbon/icons-react"; import { ExternalLinkIcon, LocationMarkerIcon } from "@heroicons/react/outline"; import BrUserAvatar from "components/brickroom/BrUserAvatar"; import EntityTypeIcon from "components/EntityTypeIcon"; @@ -13,7 +25,7 @@ import { useAuth } from "hooks/useAuth"; import useFilters from "hooks/useFilters"; import useLoadMore from "hooks/useLoadMore"; import useDppApi from "lib/dpp"; -import type { DppDocument, ListDppsResponse } from "lib/dpp-types"; +import type { DppDocument, DppStatus, ListDppsResponse, StatusFacets } from "lib/dpp-types"; import { FETCH_RESOURCES } from "lib/QueryAndMutation"; import { FetchInventoryQuery } from "lib/types"; import { useTranslation } from "next-i18next"; @@ -74,10 +86,10 @@ const tabCtaConfig: Record, TabCtaConfig> = { searchPlaceholder: "Search services by name, tags...", }, dpps: { - ctaTitle: "Create Digital Product Passports", + ctaTitle: "Publish DPPs for your products", ctaDescription: - "Document the lifecycle, materials, and sustainability information of your products. Help consumers and regulators access transparent product data.", - createLabel: "Create a new DPP", + "With a Digital Product Passport (DPP) each product gets a unique QR code for easy tracking and verification.", + createLabel: "Publish a New DPP", createUrl: "/dpps/new", searchPlaceholder: "Search DPPs by batch ID, status...", }, @@ -335,14 +347,17 @@ function ProfileTabContent({ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwner: boolean; ctaConfig: TabCtaConfig }) { const { t } = useTranslation("common"); - const router = useRouter(); const dppApi = useDppApi(); const [dpps, setDpps] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState("latest"); + const [sortBy, setSortBy] = useState<"latest" | "oldest" | "az" | "za">("latest"); + const [statusFilter, setStatusFilter] = useState<"all" | "active" | "draft" | "archived">("all"); const [showSortMenu, setShowSortMenu] = useState(false); + const [showFilterMenu, setShowFilterMenu] = useState(false); + const [showActionsMenu, setShowActionsMenu] = useState(null); + const [facets, setFacets] = useState({ active: 0, draft: 0, archived: 0 }); useEffect(() => { let cancelled = false; @@ -353,6 +368,7 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne if (!cancelled) { setDpps(res.dpps || []); setTotal(res.total || 0); + if (res.facets) setFacets(res.facets); } }) .catch((err: Error) => { @@ -370,12 +386,49 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne }; }, [userId]); // eslint-disable-line react-hooks/exhaustive-deps + // Close menus on outside click + useEffect(() => { + const handler = () => { + setShowSortMenu(false); + setShowFilterMenu(false); + setShowActionsMenu(null); + }; + document.addEventListener("click", handler); + return () => document.removeEventListener("click", handler); + }, []); + + // KPI counters: use backend facets when available, client-side fallback + const statusCounts = useMemo(() => { + if (facets.active + facets.draft + facets.archived > 0) { + return { + total: facets.active + facets.draft + facets.archived, + ...facets, + }; + } + const counts = { total: dpps.length, active: 0, draft: 0, archived: 0 }; + for (const d of dpps) { + if (d.status === "active") counts.active++; + else if (d.status === "draft") counts.draft++; + else if (d.status === "archived") counts.archived++; + } + return counts; + }, [dpps, facets]); + const filteredDpps = useMemo(() => { let items = dpps; + + // Status filter + if (statusFilter !== "all") { + items = items.filter(d => d.status === statusFilter); + } + + // Text search if (searchQuery) { const q = searchQuery.toLowerCase(); items = items.filter( d => + d.id?.toLowerCase().includes(q) || + d.productId?.toLowerCase().includes(q) || d.batchId?.toLowerCase().includes(q) || d.status?.toLowerCase().includes(q) || d.batchType?.toLowerCase().includes(q) || @@ -383,9 +436,30 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne d.productOverview?.brandName?.value?.toLowerCase().includes(q) ); } - if (sortBy === "oldest") items = [...items].reverse(); + + // Sort + items = [...items]; + switch (sortBy) { + case "oldest": + items.reverse(); + break; + case "az": + items.sort((a, b) => { + const na = a.productOverview?.productName?.value || ""; + const nb = b.productOverview?.productName?.value || ""; + return na.localeCompare(nb); + }); + break; + case "za": + items.sort((a, b) => { + const na = a.productOverview?.productName?.value || ""; + const nb = b.productOverview?.productName?.value || ""; + return nb.localeCompare(na); + }); + break; + } return items; - }, [dpps, searchQuery, sortBy]); + }, [dpps, searchQuery, sortBy, statusFilter]); const statusColors: Record = { active: { bg: "var(--ifr-green)", text: "#fff" }, @@ -393,11 +467,36 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne archived: { bg: "var(--ifr-yellow)", text: "var(--ifr-text-primary)" }, }; + const sortLabels: Record = { + latest: "Latest", + oldest: "Oldest", + az: "A–Z", + za: "Z–A", + }; + + const filterLabels: Record = { + all: "All", + active: "Active", + draft: "Drafts", + archived: "Archived", + }; + + const handleStatusChange = async (dppId: string, newStatus: DppStatus) => { + try { + await dppApi.updateDppStatus(dppId, newStatus); + setDpps(prev => prev.map(d => (d.id === dppId ? { ...d, status: newStatus } : d))); + } catch (err) { + console.error("Failed to update DPP status:", err); + } + setShowActionsMenu(null); + }; + return (
- {/* CTA + Stats row (owner only) */} + {/* CTA + KPI Stats row (owner only) */} {isOwner && (
+ {/* CTA left */}

+ {t(ctaConfig.createLabel)} - +

+ + {/* KPI Stats right */} +
+ {( + [ + { label: "Total DPPs", value: statusCounts.total }, + { label: "Active", value: statusCounts.active }, + { label: "Drafts", value: statusCounts.draft }, + { label: "Archived", value: statusCounts.archived }, + ] as const + ).map(kpi => ( +
+ + {t(kpi.label)} + + + {kpi.value} + +
+ ))} +
)} - {/* Search & Sort toolbar */} + {/* Search & Sort & Filter toolbar */}
setSearchQuery(e.target.value)} - placeholder={t(ctaConfig.searchPlaceholder)} + placeholder={t("Search by product name, DPP ID, or project ID...")} className="flex-1 bg-transparent border-none outline-none text-ifr-text-primary" style={{ fontFamily: "var(--ifr-font-body)", fontSize: "var(--ifr-fs-base)" }} />
+ {/* Sort dropdown */}
{showSortMenu && (
- {["latest", "oldest"].map(opt => ( + {(["latest", "oldest", "az", "za"] as const).map(opt => ( + ))} +
+ )} +
+ + {/* Status filter dropdown */} +
+ + {showFilterMenu && ( +
+ {(["all", "active", "draft", "archived"] as const).map(opt => ( + ))}
@@ -523,7 +738,7 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne }} > - {t(ctaConfig.createLabel)} + {t("Create New DPP")} )} @@ -552,68 +767,65 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne
- {t("Product")} - {t("Batch / Serial")} - {t("Type")} + {t("Product ID")} + {t("DPP ID")} + {t("Batch / Unit")} {t("Status")} {t("Created")} - {t("Action")} + {t("QR code")} +
{/* Table rows */} {filteredDpps.map(dpp => { const productName = dpp.productOverview?.productName?.value || dpp.productOverview?.brandName?.value || t("Untitled DPP"); + const productId = dpp.productId || "—"; const status = dpp.status || "draft"; const colors = statusColors[status] || statusColors.draft; const dppUrl = `/dpps/${encodeURIComponent(dpp.id)}`; + const dppDisplayId = dpp.id.length > 12 ? `${dpp.id.slice(0, 12)}…` : dpp.id; return (
router.push(dppUrl)} - onKeyDown={event => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - router.push(dppUrl); - } - }} style={{ - gridTemplateColumns: "2fr 1fr 100px 100px 140px 90px", + gridTemplateColumns: "2fr 110px 1fr 90px 140px 70px 50px", fontFamily: "var(--ifr-font-body)", fontSize: "var(--ifr-fs-base)", }} > - - - {productName} - - - {dpp.batchId || "—"} - - - {dpp.batchType === "unit" ? t("Unit") : t("Batch")} + {/* Product ID + name */} +
+ + {productId} + + + {productName} + + +
+ + {/* DPP ID */} + + {dppDisplayId} + + {/* Batch / Unit */} + {dpp.batchId || "—"} + + {/* Status */} - - {dpp.createdAt ? new Date(dpp.createdAt).toLocaleDateString() : "—"} + + {/* Created */} + + + {dpp.createdAt + ? new Date(dpp.createdAt).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }) + : "—"} - - e.stopPropagation()} + className="inline-flex items-center justify-center bg-transparent border-none cursor-pointer hover:opacity-70 transition-opacity no-underline" + style={{ width: 32, height: 32, color: "var(--ifr-text-secondary)" }} + > + + + + {/* More actions */} +
+ + {showActionsMenu === dpp.id && ( +
+ + + {t("View")} + + + {isOwner && status === "draft" && ( + + )} + {isOwner && (status === "draft" || status === "active") && ( + + )} + {isOwner && status === "archived" && ( + + )} +
+ )} +
); })} + + {/* Pagination footer */} +
+ + {t("Showing {{count}} of {{total}} DPPs", { + count: filteredDpps.length, + total: total, + })} + +
+ + +
+
)}
diff --git a/lib/dpp-types.ts b/lib/dpp-types.ts index 3aea8ae2..005c2de2 100644 --- a/lib/dpp-types.ts +++ b/lib/dpp-types.ts @@ -207,13 +207,23 @@ export type ListDppsFilters = { productId?: string; createdBy?: string; status?: DppStatus; + q?: string; + sortBy?: "createdAt" | "name"; + sortOrder?: "asc" | "desc"; limit?: number; offset?: number; }; +export type StatusFacets = { + active: number; + draft: number; + archived: number; +}; + export type ListDppsResponse = { dpps: DppDocument[]; total: number; + facets?: StatusFacets; }; export type DppApiError = { diff --git a/lib/dpp.ts b/lib/dpp.ts index b5b2889c..577acf7e 100644 --- a/lib/dpp.ts +++ b/lib/dpp.ts @@ -133,6 +133,9 @@ const useDppApi = () => { if (filters?.productId) params.set("productId", filters.productId); if (filters?.createdBy) params.set("createdBy", filters.createdBy); if (filters?.status) params.set("status", filters.status); + if (filters?.q) params.set("q", filters.q); + if (filters?.sortBy) params.set("sortBy", filters.sortBy); + if (filters?.sortOrder) params.set("sortOrder", filters.sortOrder); if (filters?.limit != null) params.set("limit", String(filters.limit)); if (filters?.offset != null) params.set("offset", String(filters.offset)); @@ -189,6 +192,11 @@ const useDppApi = () => { return `${DPP_BASE_URL}/file/${encodeURIComponent(id)}`; }, []); + const getQrCodeUrl = useCallback((dppId: string, size?: number): string => { + const params = size ? `?size=${size}` : ""; + return `${DPP_BASE_URL}/dpp/${encodeURIComponent(dppId)}/qr${params}`; + }, []); + const updateDppStatus = useCallback( async (id: string, status: DppStatus): Promise => { return request("PUT", `/dpp/${encodeURIComponent(id)}/status`, { status }); @@ -257,6 +265,7 @@ const useDppApi = () => { listDpps, uploadFile, getFileUrl, + getQrCodeUrl, updateDppStatus, addAttachment, deleteAttachment, @@ -269,6 +278,7 @@ const useDppApi = () => { listDpps, uploadFile, getFileUrl, + getQrCodeUrl, updateDppStatus, addAttachment, deleteAttachment, From 4bf1d59878909d44ed77c001eedb9e91c6daaf1e Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:42:18 +0200 Subject: [PATCH 15/18] refactor(ui): remove redundant owner banners and fix search filters (#840) ## Summary - remove owner-only edit banners from profile and project detail pages while keeping inline edit actions - fix search page filter composition to avoid sending mutually exclusive fields name and orName - fix profile tab search by wiring search input into the projects query filter with debounce ## Commits - refactor(ui): remove owner edit banners from profile and project detail pages - fix(search): avoid sending both name and orName filter params - fix(profile): wire search input to GraphQL filter in profile tabs ## Testing - manual verification of search page error path and profile tab search behavior --- components/ProfilePageNew.tsx | 10 +++++++++- pages/profile/[id]/index.tsx | 8 +------- pages/project/[id]/index.tsx | 2 -- pages/search.tsx | 12 +++++------- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/components/ProfilePageNew.tsx b/components/ProfilePageNew.tsx index db13d9bf..150faa6c 100644 --- a/components/ProfilePageNew.tsx +++ b/components/ProfilePageNew.tsx @@ -112,15 +112,23 @@ function ProfileTabContent({ }) { const { t } = useTranslation("common"); const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); const [sortBy, setSortBy] = useState("latest"); const [showSortMenu, setShowSortMenu] = useState(false); + // Debounce search input to avoid firing a query on every keystroke + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchQuery), 300); + return () => clearTimeout(timer); + }, [searchQuery]); + const filter = useMemo( () => ({ primaryAccountable: [userId], conformsTo: specId ? [specId] : undefined, + ...(debouncedSearch && { name: debouncedSearch }), }), - [userId, specId] + [userId, specId, debouncedSearch] ); const dataQueryIdentifier = "economicResources"; diff --git a/pages/profile/[id]/index.tsx b/pages/profile/[id]/index.tsx index 845a30aa..5fbb00b2 100644 --- a/pages/profile/[id]/index.tsx +++ b/pages/profile/[id]/index.tsx @@ -16,7 +16,6 @@ import FetchUserLayout from "components/layout/FetchUserLayout"; import Layout from "components/layout/Layout"; -import EditProfileBanner from "components/partials/profile/[id]/EditProfileBanner"; import ProfilePageNew from "components/ProfilePageNew"; import type { GetStaticPaths } from "next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; @@ -25,12 +24,7 @@ import { NextPageWithLayout } from "pages/_app"; // const Profile: NextPageWithLayout = () => { - return ( - <> - - - - ); + return ; }; export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => { diff --git a/pages/project/[id]/index.tsx b/pages/project/[id]/index.tsx index 67a6bb75..77dc107f 100644 --- a/pages/project/[id]/index.tsx +++ b/pages/project/[id]/index.tsx @@ -21,7 +21,6 @@ import { createContext, Dispatch, ReactElement, SetStateAction, useContext, useM import FetchProjectLayout, { useProject } from "components/layout/FetchProjectLayout"; import Layout from "components/layout/Layout"; -import EditBanner from "components/partials/project/[id]/EditBanner"; import SuccessBanner from "components/partials/project/[id]/SuccessBanner"; import ProjectDetailNew from "components/ProjectDetailNew"; import findProjectImages from "lib/findProjectImages"; @@ -82,7 +81,6 @@ const Project: NextPageWithLayout = () => { /> {t("Project succesfully created!")} - diff --git a/pages/search.tsx b/pages/search.tsx index fd9b00ca..b35c6a9c 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { Checkbox, Stack, Tabs, Button } from "@bbtgnn/polaris-interfacer"; +import { Button, Checkbox, Stack, Tabs } from "@bbtgnn/polaris-interfacer"; import { Cube, Events } from "@carbon/icons-react"; import ProjectsCards from "components/ProjectsCards"; // import ProjectsMaps from "components/ProjectsMaps"; @@ -22,10 +22,10 @@ import { useTranslation } from "next-i18next"; import { useRouter } from "next/router"; import { ReactElement, useState } from "react"; // import AgentsTable from "../components/AgentsTable"; +import dynamic from "next/dynamic"; import SearchBar from "../components/SearchBar"; import Layout from "../components/layout/SearchLayout"; import useFilters from "../hooks/useFilters"; -import dynamic from "next/dynamic"; import { NextPageWithLayout } from "./_app"; const ProjectsMaps = dynamic(() => import("../components/ProjectsMaps"), { ssr: false }); @@ -46,11 +46,9 @@ const Search: NextPageWithLayout = () => { orName: q?.toString(), ...(!checkedNotDescription && { orNote: q?.toString() }), }; - const projectsFilter = { - name: q?.toString(), - ...(!checkedNotDescription && { orNote: q?.toString() }), - }; - const filters = isNotEmptyObj(proposalFilter) ? { ...proposalFilter, ...projectsFilter } : projectsOrFilter; + const filters = isNotEmptyObj(proposalFilter) + ? { ...proposalFilter, ...(!checkedNotDescription && { orNote: q?.toString() }) } + : projectsOrFilter; // From f8cd61ca97eee7403779d827490140655765c72c Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:44:07 +0200 Subject: [PATCH 16/18] =?UTF-8?q?refactor(tags):=20=E2=99=BB=EF=B8=8F=20?= =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20prefix=20user=20tags=20with=20tag-=20to?= =?UTF-8?q?=20disambiguate=20from=20system=20classifiedAs=20(#841)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifiedAs stores both user free-form tags and system-derived metadata (machine-*, material-*, category-*, powercompat-*, env-*, etc.), forcing display code to maintain drifting per-component blocklists to hide system tags from users. Introduce a dedicated TAG_PREFIX.USER = "tag" prefix applied symmetrically on save and read: - lib/tagging.ts: add userTag, isUserTag, stripUserTagPrefix, isSystemTag, extractUserTagValues, normalizeUserTagsForSave helpers plus SYSTEM_TAG_PREFIXES and LEGACY_SYSTEM_TAG_PATTERNS constants. - Save sites (useProjectCRUD handleProjectCreation + handleMachineCreation, resource claim) normalize user-entered tags to tag-. - URL filter producers (ProductsFilters, ProjectsFilters) prefix on apply and strip on load so chips show "laser cut" instead of "tag-laser-cut". - Display sites (ProjectCardNew, ProjectDetailNew, GeneralCard, ProjectsTableRow, ProductsActiveFiltersBar) route through extractUserTagValues, removing bespoke blocklists (including stale mat:, c:, pc:, env:, pwr:, rep:, m: entries). - Legacy un-prefixed tags remain visible via backwards-compat fallback in extractUserTagValues; no data migration required. Closes interfacer-gui-i46. --- .beads/last-touched | 2 +- components/GeneralCard.tsx | 6 +- components/ProductsActiveFiltersBar.tsx | 25 ++---- components/ProductsFilters.tsx | 30 +++---- components/ProjectCardNew.tsx | 17 +--- components/ProjectDetailNew.tsx | 27 +----- components/ProjectsFilters.tsx | 11 ++- components/ProjectsTableRow.tsx | 3 +- hooks/useProjectCRUD.ts | 22 ++--- lib/tagging.ts | 109 ++++++++++++++++++++++++ pages/resource/[id]/claim.tsx | 3 +- 11 files changed, 159 insertions(+), 96 deletions(-) diff --git a/.beads/last-touched b/.beads/last-touched index 0cbf44fb..c2319bd7 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -interfacer-gui-vom.5 +interfacer-gui-i46 diff --git a/components/GeneralCard.tsx b/components/GeneralCard.tsx index 1c371890..238511f4 100644 --- a/components/GeneralCard.tsx +++ b/components/GeneralCard.tsx @@ -7,6 +7,7 @@ import useWallet from "hooks/useWallet"; import { IdeaPoints } from "lib/PointsDistribution"; import findProjectImages from "lib/findProjectImages"; import { isProjectType } from "lib/isProjectType"; +import { extractUserTagValues } from "lib/tagging"; import { EconomicResource } from "lib/types"; import { useTranslation } from "next-i18next"; import Link from "next/link"; @@ -67,10 +68,11 @@ const GeneralCard = (props: GeneralCardProps) => { const Tags = () => { const { project } = useCardProject(); - if (!project.classifiedAs?.length) return null; + const tags = extractUserTagValues(project.classifiedAs); + if (!tags.length) return null; return (
- +
); }; diff --git a/components/ProductsActiveFiltersBar.tsx b/components/ProductsActiveFiltersBar.tsx index 11494642..ba4d418d 100644 --- a/components/ProductsActiveFiltersBar.tsx +++ b/components/ProductsActiveFiltersBar.tsx @@ -18,7 +18,7 @@ import { useQuery } from "@apollo/client"; import { Tag } from "@bbtgnn/polaris-interfacer"; import { useResourceSpecs } from "hooks/useResourceSpecs"; import { QUERY_MACHINES } from "lib/QueryAndMutation"; -import { isPrefixedTag, prefixedTag, REPAIRABILITY_AVAILABLE_TAG, TAG_PREFIX } from "lib/tagging"; +import { isSystemTag, prefixedTag, REPAIRABILITY_AVAILABLE_TAG, stripUserTagPrefix, TAG_PREFIX } from "lib/tagging"; import { useTranslation } from "next-i18next"; import { useRouter } from "next/router"; @@ -90,21 +90,10 @@ export default function ProductsActiveFiltersBar() { const categoryTags = rawTags.filter(tag => tag.startsWith(`${TAG_PREFIX.CATEGORY}-`)); - const userTags = rawTags.filter( - tag => - !isPrefixedTag(tag, [ - TAG_PREFIX.MACHINE, - TAG_PREFIX.MATERIAL, - TAG_PREFIX.CATEGORY, - TAG_PREFIX.POWER_COMPAT, - TAG_PREFIX.POWER_REQ, - TAG_PREFIX.REPLICABILITY, - TAG_PREFIX.RECYCLABILITY, - TAG_PREFIX.REPAIRABILITY, - TAG_PREFIX.ENV_ENERGY, - TAG_PREFIX.ENV_CO2, - ]) - ); + // User-facing tags in the active filter bar: anything that isn't a known + // system-prefixed tag (includes both canonical `tag-*` entries and legacy + // un-prefixed values still present in older URLs). + const userTags = rawTags.filter(tag => !isSystemTag(tag)); const derivedManufacturability = (() => { const fromParam = asCsvArray(router.query.manufacturability); @@ -247,9 +236,9 @@ export default function ProductsActiveFiltersBar() { for (const tag of userTags) { const decoded = (() => { try { - return decodeURIComponent(tag); + return decodeURIComponent(stripUserTagPrefix(tag)); } catch { - return tag; + return stripUserTagPrefix(tag); } })(); diff --git a/components/ProductsFilters.tsx b/components/ProductsFilters.tsx index a3518920..6ba9539c 100644 --- a/components/ProductsFilters.tsx +++ b/components/ProductsFilters.tsx @@ -21,13 +21,14 @@ import { MACHINE_TYPES } from "lib/resourceSpecs"; import { CO2_THRESHOLDS_KG, ENERGY_THRESHOLDS_KWH, - isPrefixedTag, + extractUserTagValues, mergeTags, + normalizeUserTagsForSave, POWER_COMPATIBILITY_OPTIONS, POWER_REQUIREMENT_THRESHOLDS_W, prefixedTag, - RECYCLABILITY_THRESHOLDS_PCT, rangeFilterTags, + RECYCLABILITY_THRESHOLDS_PCT, REPAIRABILITY_AVAILABLE_TAG, REPLICABILITY_OPTIONS, TAG_PREFIX, @@ -177,22 +178,9 @@ export default function ProductsFilters() { const query = router.query; const rawTags = query.tags ? (query.tags as string).split(",") : []; - // Keep derived tags out of the user tags selector. - const userTags = rawTags.filter( - tag => - !isPrefixedTag(tag, [ - TAG_PREFIX.MACHINE, - TAG_PREFIX.MATERIAL, - TAG_PREFIX.CATEGORY, - TAG_PREFIX.POWER_COMPAT, - TAG_PREFIX.POWER_REQ, - TAG_PREFIX.REPLICABILITY, - TAG_PREFIX.RECYCLABILITY, - TAG_PREFIX.REPAIRABILITY, - TAG_PREFIX.ENV_ENERGY, - TAG_PREFIX.ENV_CO2, - ]) - ); + // Show user-facing tag values (prefix stripped) in the tags selector so + // the chips read e.g. "laser cut" instead of "tag-laser-cut". + const userTags = extractUserTagValues(rawTags); const manufacturability = query.manufacturability ? (query.manufacturability as string).split(",") @@ -295,8 +283,12 @@ export default function ProductsFilters() { const existingCategoryTags = rawTags.filter(tag => tag.startsWith(`${TAG_PREFIX.CATEGORY}-`)); + // User-entered free-form tags get the canonical `tag-` prefix so they + // match how they are persisted on save. + const userTags = normalizeUserTagsForSave(filters.tags); + const combinedTags = mergeTags( - filters.tags, + userTags, existingCategoryTags, machineTags, materialTags, diff --git a/components/ProjectCardNew.tsx b/components/ProjectCardNew.tsx index 67759384..1f964999 100644 --- a/components/ProjectCardNew.tsx +++ b/components/ProjectCardNew.tsx @@ -9,6 +9,7 @@ import useWallet from "hooks/useWallet"; import findProjectImages from "lib/findProjectImages"; import { isProjectType } from "lib/isProjectType"; import { IdeaPoints } from "lib/PointsDistribution"; +import { extractUserTagValues } from "lib/tagging"; import { EconomicResource } from "lib/types"; import { useTranslation } from "next-i18next"; import Link from "next/link"; @@ -93,20 +94,8 @@ export default function ProjectCardNew({ project }: ProjectCardNewProps) { const hasStarred = project.id ? isLiked(project.id) : false; const displayCount = formatCount(erFollowerLength); - // Extract tags (filter out encoded machine/material tags) - const tags = (project.classifiedAs || []) - .filter( - tag => - !tag.startsWith("machine-") && - !tag.startsWith("material-") && - !tag.startsWith("power_") && - !tag.startsWith("replicability-") && - !tag.startsWith("recyclability-") && - !tag.startsWith("repairability") && - !tag.startsWith("env_") - ) - .map(tag => (tag.startsWith("category-") ? humanizeSlug(tag) : decodeURIComponent(tag))) - .slice(0, 4); + // Extract user-facing tags (strips `tag-` prefix and filters out system tags). + const tags = extractUserTagValues(project.classifiedAs).slice(0, 4); // Design-specific: requirements const machineTags = (project.classifiedAs || []).filter(tag => tag.startsWith("machine-")).map(humanizeSlug); diff --git a/components/ProjectDetailNew.tsx b/components/ProjectDetailNew.tsx index f9d761e2..a84b6434 100644 --- a/components/ProjectDetailNew.tsx +++ b/components/ProjectDetailNew.tsx @@ -20,6 +20,7 @@ import type { DppDocument } from "lib/dpp-types"; import findProjectImages from "lib/findProjectImages"; import { isProjectType } from "lib/isProjectType"; import MdParser from "lib/MdParser"; +import { extractUserTagValues } from "lib/tagging"; import { EconomicResource } from "lib/types"; import { useTranslation } from "next-i18next"; @@ -1212,29 +1213,9 @@ export default function ProjectDetailNew() { const color = typeColors[projectType] || "var(--ifr-green)"; const images = useMemo(() => findProjectImages(project), [project]); - // Internal tag prefixes to filter out - const internalPrefixes = [ - "machine-", - "material-", - "category-", - "power_compat-", - "mat:", - "c:", - "pc:", - "env:", - "pwr:", - "rep:", - "m:", - ]; - - // Decode tags - const tags = useMemo( - () => - (project.classifiedAs || []) - .filter((c: string) => !internalPrefixes.some(p => c.startsWith(p))) - .map((c: string) => decodeURIComponent(c)), - [project.classifiedAs] - ); + // User-facing tags: filtered to `tag-*` entries (prefix stripped) with legacy + // un-prefixed values kept visible for backwards compatibility. + const tags = useMemo(() => extractUserTagValues(project.classifiedAs), [project.classifiedAs]); const machines = useMemo( () => diff --git a/components/ProjectsFilters.tsx b/components/ProjectsFilters.tsx index e3d7f2db..79a366f0 100644 --- a/components/ProjectsFilters.tsx +++ b/components/ProjectsFilters.tsx @@ -21,6 +21,7 @@ import { useState } from "react"; // Select components import { Button, Card, Stack, Text } from "@bbtgnn/polaris-interfacer"; import { getOptionValue } from "components/brickroom/utils/BrSelectUtils"; +import { extractUserTagValues, normalizeUserTagsForSave } from "lib/tagging"; import SearchUsers from "./SearchUsers"; import SelectProjectType from "./SelectProjectType"; import SelectTags from "./SelectTags"; @@ -56,7 +57,10 @@ export default function ProjectsFilters(props: ProjectsFiltersProps) { // Converts query value in string array function getFilterValues(filter: ProjectFilter): Array { if (!query[filter]) return []; - else return query[filter].split(","); + const values = query[filter].split(","); + // Present user tags to the user without the `tag-` prefix. + if (filter === "tags") return extractUserTagValues(values); + return values; } // Creating state and loading it them with existing values @@ -72,7 +76,10 @@ export default function ProjectsFilters(props: ProjectsFiltersProps) { function applyFilters() { for (let f of ProjectFilters) { - if (queryFilters[f].length > 0) query[f] = queryFilters[f].join(","); + // User tags are prefixed when placed in the URL so they match the stored + // canonical `tag-` form. + const values = f === "tags" ? normalizeUserTagsForSave(queryFilters[f]) : queryFilters[f]; + if (values.length > 0) query[f] = values.join(","); else delete query[f]; } diff --git a/components/ProjectsTableRow.tsx b/components/ProjectsTableRow.tsx index 665162df..5798cdd5 100644 --- a/components/ProjectsTableRow.tsx +++ b/components/ProjectsTableRow.tsx @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { extractUserTagValues } from "lib/tagging"; import { EconomicResource } from "lib/types"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -74,7 +75,7 @@ const ProjectsTableRow = (props: { project: { node: EconomicResource } }) => { - + diff --git a/hooks/useProjectCRUD.ts b/hooks/useProjectCRUD.ts index 7e8df858..7ff4f475 100644 --- a/hooks/useProjectCRUD.ts +++ b/hooks/useProjectCRUD.ts @@ -22,7 +22,7 @@ import { import { arrayEquals, getNewElements } from "lib/arrayOperations"; import { errorFormatter } from "lib/errorFormatter"; import { prepFilesForZenflows, uploadFiles } from "lib/fileUpload"; -import { derivedProductFilterTags, mergeTags, prefixedTag, removeTagsWithPrefixes, TAG_PREFIX } from "lib/tagging"; +import { derivedProductFilterTags, mergeTags, normalizeUserTagsForSave, prefixedTag, TAG_PREFIX } from "lib/tagging"; import { CreateLocationMutation, CreateLocationMutationVariables, @@ -224,7 +224,8 @@ export const useProjectCRUD = () => { const images: IFile[] = await prepFilesForZenflows(formData.images); devLog("info: images prepared", images); - const tags = formData.main.tags.length > 0 ? formData.main.tags : undefined; + const normalizedTags = normalizeUserTagsForSave(formData.main.tags); + const tags = normalizedTags.length > 0 ? normalizedTags : undefined; devLog("info: tags prepared", tags); const metadata = JSON.stringify({ @@ -354,19 +355,10 @@ export const useProjectCRUD = () => { .map(l => prefixedTag(TAG_PREFIX.LICENSE, l.licenseId)) .filter((t): t is string => Boolean(t)); - const baseTags = removeTagsWithPrefixes(formData.main.tags, [ - TAG_PREFIX.CATEGORY, - TAG_PREFIX.POWER_COMPAT, - TAG_PREFIX.POWER_REQ, - TAG_PREFIX.REPLICABILITY, - TAG_PREFIX.RECYCLABILITY, - TAG_PREFIX.REPAIRABILITY, - TAG_PREFIX.ENV_ENERGY, - TAG_PREFIX.ENV_CO2, - TAG_PREFIX.SERVICE_TYPE, - TAG_PREFIX.AVAILABILITY, - TAG_PREFIX.LICENSE, - ]); + // User-entered free-form tags from the form are normalized into the + // canonical `tag-` shape. System-derived tags (machines, materials, + // categories, ...) are appended separately below. + const baseTags = normalizeUserTagsForSave(formData.main.tags); const merged = mergeTags( baseTags, diff --git a/lib/tagging.ts b/lib/tagging.ts index fddf9e6a..e30037c8 100644 --- a/lib/tagging.ts +++ b/lib/tagging.ts @@ -11,6 +11,9 @@ export function slugifyTagValue(value: string): string { } export const TAG_PREFIX = { + // Dedicated prefix for free-form, user-entered tags. All other prefixes are + // system-derived metadata that happens to share the classifiedAs field. + USER: "tag", CATEGORY: "category", MACHINE: "machine", MATERIAL: "material", @@ -28,6 +31,38 @@ export const TAG_PREFIX = { export type TagPrefix = (typeof TAG_PREFIX)[keyof typeof TAG_PREFIX]; +// All known system prefixes (everything except USER). +export const SYSTEM_TAG_PREFIXES: ReadonlyArray = [ + TAG_PREFIX.CATEGORY, + TAG_PREFIX.MACHINE, + TAG_PREFIX.MATERIAL, + TAG_PREFIX.POWER_COMPAT, + TAG_PREFIX.POWER_REQ, + TAG_PREFIX.REPLICABILITY, + TAG_PREFIX.RECYCLABILITY, + TAG_PREFIX.REPAIRABILITY, + TAG_PREFIX.ENV_ENERGY, + TAG_PREFIX.ENV_CO2, + TAG_PREFIX.SERVICE_TYPE, + TAG_PREFIX.AVAILABILITY, + TAG_PREFIX.LICENSE, +]; + +// Legacy/stale system prefixes that still appear in historical classifiedAs data. +// These must not leak into user-facing tag displays. +export const LEGACY_SYSTEM_TAG_PATTERNS: ReadonlyArray = [ + "power_compat-", + "power_", + "env_", + "mat:", + "c:", + "pc:", + "env:", + "pwr:", + "rep:", + "m:", +]; + // Shared option lists used across create flow + products filters. export const PRODUCT_CATEGORY_OPTIONS = [ "Electronics", @@ -199,3 +234,77 @@ export function mergeTags(...tagLists: Array | undefined>) return merged; } + +// ---------- User tag helpers ---------- +// +// User-entered free-form tags are stored in classifiedAs alongside system-derived +// tags (machine-*, category-*, etc.). To disambiguate them we prefix every new +// user tag with `tag-`. Display and filter code should go through these helpers +// so there is a single source of truth and no drifting per-component blocklists. + +// Build a canonical user tag from raw input. Returns undefined for empty values. +export function userTag(raw: string): string | undefined { + const slug = slugifyTagValue(raw); + if (!slug) return undefined; + return `${TAG_PREFIX.USER}-${slug}`; +} + +export function isUserTag(tag: string): boolean { + return tag.startsWith(`${TAG_PREFIX.USER}-`); +} + +export function stripUserTagPrefix(tag: string): string { + return isUserTag(tag) ? tag.substring(TAG_PREFIX.USER.length + 1) : tag; +} + +// A tag is "system" if it uses one of the known system prefixes (current or +// legacy). USER tags and legacy un-prefixed free-form tags are NOT system. +export function isSystemTag(tag: string): boolean { + if (isUserTag(tag)) return false; + if (SYSTEM_TAG_PREFIXES.some(p => tag.startsWith(`${p}-`))) return true; + if (LEGACY_SYSTEM_TAG_PATTERNS.some(p => tag.startsWith(p))) return true; + return false; +} + +// Extract the values a user should see as "tags" from a classifiedAs list: +// - entries that match TAG_PREFIX.USER (stripped of the prefix) +// - legacy un-prefixed free-form entries (kept visible for backwards compat) +// System-prefixed entries are filtered out. +export function extractUserTagValues(tags: ReadonlyArray | null | undefined): string[] { + if (!tags || tags.length === 0) return []; + const out: string[] = []; + const seen = new Set(); + for (const tag of tags) { + if (!tag) continue; + let value: string | undefined; + if (isUserTag(tag)) { + value = decodeURIComponent(stripUserTagPrefix(tag)); + } else if (!isSystemTag(tag)) { + value = decodeURIComponent(tag); + } + if (!value) continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +// Normalize raw user tag inputs (free-form strings, optionally already prefixed) +// into canonical `tag-` form. System-prefixed entries are dropped so they +// cannot accidentally ride in via the user-tag pipeline. +export function normalizeUserTagsForSave(tags: ReadonlyArray): string[] { + const out: string[] = []; + const seen = new Set(); + for (const raw of tags) { + const trimmed = raw?.trim(); + if (!trimmed) continue; + if (isSystemTag(trimmed)) continue; + const canonical = isUserTag(trimmed) ? trimmed : userTag(trimmed); + if (!canonical) continue; + if (seen.has(canonical)) continue; + seen.add(canonical); + out.push(canonical); + } + return out; +} diff --git a/pages/resource/[id]/claim.tsx b/pages/resource/[id]/claim.tsx index 9aa63d19..97a9cd96 100644 --- a/pages/resource/[id]/claim.tsx +++ b/pages/resource/[id]/claim.tsx @@ -35,6 +35,7 @@ import devLog from "lib/devLog"; import { errorFormatter } from "lib/errorFormatter"; import { formSetValueOptions } from "lib/formSetValueOptions"; import { isRequired } from "lib/isFieldRequired"; +import { normalizeUserTagsForSave } from "lib/tagging"; import { TransferProjectMutationVariables } from "lib/types"; import { GetStaticPaths } from "next"; import { useTranslation } from "next-i18next"; @@ -77,7 +78,7 @@ const ClaimProject: NextPageWithLayout = () => { async function handleClaim(formData: ClaimProjectNS.FormValues) { try { - const tags = formData.tags; + const tags = normalizeUserTagsForSave(formData.tags); devLog("info: tags prepared", tags); const contributors = formData.contributors; devLog("info: contributors prepared", contributors); From f470fa2903da5b2531f83c522d9cafdec810f09f Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Wed, 6 May 2026 12:13:31 +0200 Subject: [PATCH 17/18] =?UTF-8?q?feat(project):=20=E2=9C=A8=20add=20DPP-ba?= =?UTF-8?q?cked=203D=20model=20viewer=20(#848)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/last-touched | 2 +- .github/workflows/test-deploy.yml | 6 +- components/ProjectDetailNew.tsx | 96 ++++++++ components/StepModelViewer.tsx | 172 ++++++++++++++ .../create/project/CreateProjectForm.tsx | 5 + .../create/project/steps/ModelFilesStep.tsx | 61 +++++ .../partials/project/projectSections.tsx | 8 + hooks/useProjectCRUD.ts | 8 +- lib/findProjectModels.ts | 214 ++++++++++++++++++ lib/projectModelFiles.ts | 56 +++++ package.json | 1 + pages/api/dpp-file/[id]/[filename].ts | 35 +++ pages/api/file/[hash].ts | 29 +++ pages/project/[id]/edit/model.tsx | 118 ++++++++++ pnpm-lock.yaml | 35 +++ 15 files changed, 843 insertions(+), 3 deletions(-) create mode 100644 components/StepModelViewer.tsx create mode 100644 components/partials/create/project/steps/ModelFilesStep.tsx create mode 100644 lib/findProjectModels.ts create mode 100644 lib/projectModelFiles.ts create mode 100644 pages/api/dpp-file/[id]/[filename].ts create mode 100644 pages/api/file/[hash].ts create mode 100644 pages/project/[id]/edit/model.tsx diff --git a/.beads/last-touched b/.beads/last-touched index c2319bd7..6e0fe565 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -interfacer-gui-i46 +interfacer-gui-4c2.3 diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml index fee6b94d..f31e4168 100644 --- a/.github/workflows/test-deploy.yml +++ b/.github/workflows/test-deploy.yml @@ -44,7 +44,11 @@ jobs: run: cp .env.example .env.local - run: npm run build - name: Install Playwright Browsers - run: npx playwright install --with-deps + timeout-minutes: 10 + run: | + sudo sed -i 's/azure\.archive\.ubuntu\.com/archive.ubuntu.com/g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true + sudo apt-get update -qq + npx playwright install --with-deps - name: Run Playwright tests run: npx playwright test - uses: actions/upload-artifact@v4.6.0 diff --git a/components/ProjectDetailNew.tsx b/components/ProjectDetailNew.tsx index a84b6434..539bc747 100644 --- a/components/ProjectDetailNew.tsx +++ b/components/ProjectDetailNew.tsx @@ -18,6 +18,7 @@ import { SEARCH_PROJECT } from "components/ProjectDisplay"; import useDppApi from "lib/dpp"; import type { DppDocument } from "lib/dpp-types"; import findProjectImages from "lib/findProjectImages"; +import findProjectModels from "lib/findProjectModels"; import { isProjectType } from "lib/isProjectType"; import MdParser from "lib/MdParser"; import { extractUserTagValues } from "lib/tagging"; @@ -27,6 +28,7 @@ import { useTranslation } from "next-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { ReactNode, useEffect, useMemo, useState } from "react"; +import StepModelViewer from "./StepModelViewer"; function getProjectType(project: Partial): ProjectType { const name = project.conformsTo?.name; @@ -1212,6 +1214,12 @@ export default function ProjectDetailNew() { const projectType = getProjectType(project); const color = typeColors[projectType] || "var(--ifr-green)"; const images = useMemo(() => findProjectImages(project), [project]); + const models = useMemo(() => findProjectModels(project), [project]); + const primaryModel = useMemo(() => models.find(model => model.isViewable) || models[0], [models]); + const additionalModels = useMemo( + () => models.filter(model => model.url !== primaryModel?.url), + [models, primaryModel] + ); // User-facing tags: filtered to `tag-*` entries (prefix stripped) with legacy // un-prefixed values kept visible for backwards compatibility. @@ -1376,6 +1384,94 @@ export default function ProjectDetailNew() { {/* Image gallery */} + {/* 3D model viewer for designs */} + {projectType === ProjectType.DESIGN && primaryModel && ( + + + + + } + iconBg="bg-ifr-hover" + title={t("3D Model")} + subtitle={ + primaryModel.isViewable + ? t("Inspect the fabrication model directly in the browser") + : t("3D source file attached for download") + } + sectionId="3d-model" + > +
+

+ {primaryModel.isViewable + ? t( + "Use the mouse or trackpad to rotate, pan, and zoom the model. If loading fails, open the source file directly." + ) + : t( + "This design includes a 3D source file, but browser preview is only available for STEP and STL right now." + )} +

+ + {primaryModel.isViewable ? ( + + ) : ( +
+ + {primaryModel.name} + + + {t("Open source file")} + +
+ )} + + {additionalModels.length > 0 && ( +
+

+ {t("Additional model files")} +

+
+ {additionalModels.map(model => ( + + {model.name} + + ))} +
+
+ )} +
+
+ )} + {/* Manufactured from open source design banner */} {projectType === ProjectType.PRODUCT && (() => { diff --git a/components/StepModelViewer.tsx b/components/StepModelViewer.tsx new file mode 100644 index 00000000..76faac5e --- /dev/null +++ b/components/StepModelViewer.tsx @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { useEffect, useRef, useState } from "react"; + +type StepModelViewerProps = { + downloadUrl?: string; + fileName?: string; + modelUrl: string; + height?: string; +}; + +const StepModelViewer = ({ downloadUrl, fileName, modelUrl, height = "min(72vh, 760px)" }: StepModelViewerProps) => { + const viewerContainerRef = useRef(null); + const viewerRef = useRef<{ Destroy: () => void; Resize: () => void } | null>(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + useEffect(() => { + let isCancelled = false; + const loadingStart = Date.now(); + + setIsLoading(true); + setError(null); + setElapsedSeconds(0); + + const loadingTimer = window.setInterval(() => { + if (!isCancelled) { + setElapsedSeconds(Math.floor((Date.now() - loadingStart) / 1000)); + } + }, 1000); + + const setupViewer = async () => { + if (!viewerContainerRef.current) { + return; + } + + try { + const OV = await import("online-3d-viewer"); + if (isCancelled || !viewerContainerRef.current) { + return; + } + + const viewer = new OV.EmbeddedViewer(viewerContainerRef.current, { + backgroundColor: new OV.RGBAColor(247, 247, 245, 255), + defaultColor: new OV.RGBColor(186, 186, 186), + // Edge extraction can add a noticeable CPU cost on large STEP files. + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(44, 44, 44), 15), + onModelLoaded: () => { + if (!isCancelled) { + window.clearInterval(loadingTimer); + setIsLoading(false); + } + }, + onModelLoadFailed: () => { + if (!isCancelled) { + window.clearInterval(loadingTimer); + setError(`Model loading failed for ${modelUrl}.`); + setIsLoading(false); + } + }, + }); + + viewerRef.current = viewer; + viewer.LoadModelFromUrlList([modelUrl]); + + const handleResize = () => { + viewerRef.current?.Resize(); + }; + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + } catch { + if (!isCancelled) { + window.clearInterval(loadingTimer); + setError("Unable to initialize Online3DViewer."); + setIsLoading(false); + } + } + }; + + let cleanupResizeListener: (() => void) | undefined; + setupViewer().then(cleanup => { + cleanupResizeListener = cleanup; + }); + + return () => { + isCancelled = true; + window.clearInterval(loadingTimer); + cleanupResizeListener?.(); + viewerRef.current?.Destroy(); + viewerRef.current = null; + }; + }, [modelUrl]); + + return ( + <> + {(fileName || downloadUrl) && ( +
+ {fileName ?

{fileName}

: } + {downloadUrl && ( + + {"Open source file"} + + )} +
+ )} + + {error ? ( +
+

{error}

+ {downloadUrl && ( + + {"Download file"} + + )} +
+ ) : ( +

+ {isLoading ? `Importing model... ${elapsedSeconds}s` : "Model loaded."} +

+ )} + + {isLoading && elapsedSeconds > 8 && ( +

+ {"Large STEP files can take 20-90 seconds to parse in the browser, especially on first load."} +

+ )} + +
+ + ); +}; + +export default StepModelViewer; diff --git a/components/partials/create/project/CreateProjectForm.tsx b/components/partials/create/project/CreateProjectForm.tsx index 22a1f38f..be9d5747 100644 --- a/components/partials/create/project/CreateProjectForm.tsx +++ b/components/partials/create/project/CreateProjectForm.tsx @@ -19,6 +19,7 @@ import { licenseStepDefaultValues, licenseStepSchema, LicenseStepValues } from " import { linkDesignStepDefaultValues, linkDesignStepSchema, LinkDesignStepValues } from "./steps/LinkDesignStep"; import { locationStepDefaultValues, LocationStepSchemaContext, LocationStepValues } from "./steps/LocationStep"; import { mainStepDefaultValues, mainStepSchema, MainStepValues } from "./steps/MainStep"; +import { modelFilesStepDefaultValues, modelFilesStepSchema, ModelFilesStepValues } from "./steps/ModelFilesStep"; import { relationsStepDefaultValues, relationsStepSchema, RelationsStepValues } from "./steps/RelationsStep"; // Partials @@ -64,6 +65,7 @@ export interface CreateProjectValues { linkedDesign: LinkDesignStepValues; location: LocationStepValues; images: ImagesStepValues; + modelFiles: ModelFilesStepValues; declarations: DeclarationsStepValues; contributors: ContributorsStepValues; relations: RelationsStepValues; @@ -79,6 +81,7 @@ export const createProjectDefaultValues: CreateProjectValues = { linkedDesign: linkDesignStepDefaultValues, location: locationStepDefaultValues, images: imagesStepDefaultValues, + modelFiles: modelFilesStepDefaultValues, declarations: declarationsStepDefaultValues, contributors: contributorsStepDefaultValues, relations: relationsStepDefaultValues, @@ -100,6 +103,7 @@ export const createProjectSchema = () => // projectType == ProjectType.DESIGN ? schema : locationStepSchema // ), images: imagesStepSchema(), + modelFiles: modelFilesStepSchema(), declarations: yup .object() .when("$projectType", (projectType: ProjectType, schema) => @@ -148,6 +152,7 @@ export default function CreateProjectForm(props: Props) { linkedDesign: linkDesignStepDefaultValues, location: locationStepDefaultUserValues, images: imagesStepDefaultValues, + modelFiles: modelFilesStepDefaultValues, declarations: declarationsStepDefaultValues, contributors: contributorsStepDefaultValues, relations: relationsStepDefaultValues, diff --git a/components/partials/create/project/steps/ModelFilesStep.tsx b/components/partials/create/project/steps/ModelFilesStep.tsx new file mode 100644 index 00000000..fa87cf96 --- /dev/null +++ b/components/partials/create/project/steps/ModelFilesStep.tsx @@ -0,0 +1,61 @@ +import { Stack } from "@bbtgnn/polaris-interfacer"; +import PError from "components/polaris/PError"; +import PFileUpload from "components/polaris/PFileUpload"; +import PTitleSubtitle from "components/polaris/PTitleSubtitle"; +import { formSetValueOptions } from "lib/formSetValueOptions"; +import { useTranslation } from "next-i18next"; +import { useFormContext } from "react-hook-form"; +import * as yup from "yup"; +import { CreateProjectValues } from "../CreateProjectForm"; + +export type ModelFilesStepValues = Array; +export const modelFilesStepSchema = () => yup.array().default([]); +export const modelFilesStepDefaultValues: ModelFilesStepValues = []; + +const allowedExtensions = new Set(["step", "stp", "stl"]); +const maxFileSize = 50000000; + +function getExtension(fileName: string): string { + return fileName.split(".").pop()?.toLowerCase() || ""; +} + +export default function ModelFilesStep() { + const { t } = useTranslation("createProjectProps"); + const { setValue, watch, formState } = useFormContext(); + const { errors } = formState; + const modelFiles = watch().modelFiles; + const modelFilesError = errors.modelFiles?.message; + + function handleUpdate(files: Array) { + setValue("modelFiles", files, formSetValueOptions); + } + + return ( + + +
+ ({ + valid: allowedExtensions.has(getExtension(file.name)), + message: t("Only STEP, STP, and STL files are supported"), + }), + ]} + /> + {modelFilesError && } +
+
+ ); +} diff --git a/components/partials/project/projectSections.tsx b/components/partials/project/projectSections.tsx index f8f3f9c1..b9a98077 100644 --- a/components/partials/project/projectSections.tsx +++ b/components/partials/project/projectSections.tsx @@ -10,6 +10,7 @@ import LocationStep from "../create/project/steps/LocationStep"; import MachinesStep from "../create/project/steps/MachinesStep"; import MainStep from "../create/project/steps/MainStep"; import MaterialsStep from "../create/project/steps/MaterialsStep"; +import ModelFilesStep from "../create/project/steps/ModelFilesStep"; import ProductFiltersStep from "../create/project/steps/ProductFiltersStep"; import RelationsStep from "../create/project/steps/RelationsStep"; import ServiceFiltersStep from "../create/project/steps/ServiceFiltersStep"; @@ -62,6 +63,13 @@ export const projectSections: Array = [ required: [ProjectType.PRODUCT, ProjectType.SERVICE, ProjectType.DESIGN, ProjectType.MACHINE], editPage: "edit/images", }, + { + navLabel: "3D files", + id: "modelFiles", + component: , + for: [ProjectType.DESIGN], + editPage: "edit/model", + }, { navLabel: "Service details", id: "serviceFilters", diff --git a/hooks/useProjectCRUD.ts b/hooks/useProjectCRUD.ts index 7ff4f475..824b4bee 100644 --- a/hooks/useProjectCRUD.ts +++ b/hooks/useProjectCRUD.ts @@ -20,8 +20,10 @@ import { UPDATE_METADATA, } from "lib/QueryAndMutation"; import { arrayEquals, getNewElements } from "lib/arrayOperations"; +import useDppApi from "lib/dpp"; import { errorFormatter } from "lib/errorFormatter"; import { prepFilesForZenflows, uploadFiles } from "lib/fileUpload"; +import { uploadModelFilesToDpp } from "lib/projectModelFiles"; import { derivedProductFilterTags, mergeTags, normalizeUserTagsForSave, prefixedTag, TAG_PREFIX } from "lib/tagging"; import { CreateLocationMutation, @@ -50,6 +52,7 @@ import { useResourceSpecs } from "./useResourceSpecs"; export const useProjectCRUD = () => { const { user } = useAuth(); + const { uploadFile: uploadDppFile } = useDppApi(); const { sendMessage } = useInBox(); const { addIdeaPoints, addStrengthsPoints } = useWallet({}); const { t } = useTranslation(); @@ -311,6 +314,8 @@ export const useProjectCRUD = () => { //todo: This should be uncommented, seems broken with last zenroom version see lib/fileUpload.ts const images: IFile[] = await prepFilesForZenflows(formData.images); devLog("info: images prepared", images); + const models = await uploadModelFilesToDpp(formData.modelFiles || [], uploadDppFile); + devLog("info: models prepared", models); const machineTags = (formData.machines?.machineDetails || []) .map(m => prefixedTag(TAG_PREFIX.MACHINE, m.name)) @@ -422,6 +427,7 @@ export const useProjectCRUD = () => { declarations: formData.declarations, remote: location?.remote, design: design, + models, machines: formData.machines?.machineDetails || [], materials: formData.materials?.materialDetails || [], productFilters: normalizedProductFilters, @@ -536,7 +542,7 @@ export const useProjectCRUD = () => { processId ); - await uploadImages(formData.images); + await uploadFiles(formData.images); } catch (e) { devLog(e); let err = errorFormatter(e); diff --git a/lib/findProjectModels.ts b/lib/findProjectModels.ts new file mode 100644 index 00000000..bdef72c8 --- /dev/null +++ b/lib/findProjectModels.ts @@ -0,0 +1,214 @@ +import { EconomicResource } from "./types"; + +type RawModelEntry = + | string + | { + url?: string; + href?: string; + src?: string; + downloadUrl?: string; + id?: string; + hash?: string; + name?: string; + fileName?: string; + mimeType?: string; + contentType?: string; + extension?: string; + size?: number; + storage?: string; + }; + +export type ProjectModelDescriptor = { + downloadUrl: string; + extension: string; + format: "step" | "stl" | "unknown"; + hash?: string; + id?: string; + isViewable: boolean; + mimeType?: string; + name: string; + size?: number; + url: string; +}; + +const supportedExtensions = new Set(["step", "stp", "stl"]); +const DPP_BASE_URL = process.env.NEXT_PUBLIC_DPP_URL; + +function isResolvableUrl(value: string): boolean { + if (!value) { + return false; + } + + if (value.startsWith("/")) { + return true; + } + + try { + new URL(value); + return true; + } catch { + return false; + } +} + +function getExtensionFromMimeType(mimeType?: string): string { + if (!mimeType) { + return ""; + } + + const normalizedMimeType = mimeType.toLowerCase(); + if (normalizedMimeType.includes("stl") || normalizedMimeType.includes("sla")) { + return "stl"; + } + if (normalizedMimeType.includes("step") || normalizedMimeType.includes("stp")) { + return "step"; + } + return ""; +} + +function getExtensionFromValue(value?: string): string { + if (!value) { + return ""; + } + + const match = value.toLowerCase().match(/\.([a-z0-9]+)(?:$|\?)/); + return match?.[1] || ""; +} + +function normalizeExtension(extension?: string, name?: string, url?: string, mimeType?: string): string { + const normalizedExtension = (extension || "").toLowerCase().replace(/^\./, ""); + return ( + normalizedExtension || + getExtensionFromValue(name) || + getExtensionFromValue(url) || + getExtensionFromMimeType(mimeType) + ); +} + +function formatFromExtension(extension: string): ProjectModelDescriptor["format"] { + if (extension === "stl") { + return "stl"; + } + if (extension === "step" || extension === "stp") { + return "step"; + } + return "unknown"; +} + +function inferName(url: string, fallbackExtension: string): string { + const fileName = url.split("/").pop()?.split("?")[0]; + if (fileName) { + return decodeURIComponent(fileName); + } + if (fallbackExtension) { + return `3d-model.${fallbackExtension}`; + } + return "3d-model"; +} + +function buildFileUrl(hash: string, mimeType?: string): string { + const mimeTypeQuery = mimeType ? `?type=${encodeURIComponent(mimeType)}` : ""; + return `/api/file/${encodeURIComponent(hash)}${mimeTypeQuery}`; +} + +function buildDppDownloadUrl(id: string, fallbackUrl: string): string { + if (!DPP_BASE_URL) { + return fallbackUrl; + } + + return `${DPP_BASE_URL}/file/${encodeURIComponent(id)}`; +} + +function buildDppPreviewUrl(id: string, fileName: string, mimeType?: string): string { + const mimeTypeQuery = mimeType ? `?type=${encodeURIComponent(mimeType)}` : ""; + return `/api/dpp-file/${encodeURIComponent(id)}/${encodeURIComponent(fileName)}${mimeTypeQuery}`; +} + +function normalizeEntry(entry: RawModelEntry): ProjectModelDescriptor | null { + if (typeof entry === "string") { + if (!isResolvableUrl(entry)) { + return null; + } + + const extension = normalizeExtension(undefined, undefined, entry, undefined); + return { + downloadUrl: entry, + extension, + format: formatFromExtension(extension), + isViewable: supportedExtensions.has(extension), + name: inferName(entry, extension), + url: entry, + }; + } + + const mimeType = entry.mimeType || entry.contentType; + const explicitUrl = entry.url || entry.href || entry.src || entry.downloadUrl; + const url = + explicitUrl && isResolvableUrl(explicitUrl) ? explicitUrl : entry.hash ? buildFileUrl(entry.hash, mimeType) : ""; + + if (!url) { + return null; + } + + const fileName = entry.name || entry.fileName; + const extension = normalizeExtension(entry.extension, fileName, explicitUrl || url, mimeType); + const name = fileName || inferName(explicitUrl || url, extension); + const isDppFile = entry.storage === "dpp" && Boolean(entry.id); + const resolvedUrl = isDppFile ? buildDppPreviewUrl(entry.id!, name, mimeType) : url; + const resolvedDownloadUrl = isDppFile + ? buildDppDownloadUrl(entry.id!, entry.downloadUrl || explicitUrl || url) + : entry.downloadUrl && isResolvableUrl(entry.downloadUrl) + ? entry.downloadUrl + : url; + + return { + downloadUrl: resolvedDownloadUrl, + extension, + format: formatFromExtension(extension), + hash: entry.hash, + id: entry.id, + isViewable: supportedExtensions.has(extension), + mimeType, + name, + size: entry.size, + url: resolvedUrl, + }; +} + +export function getRawMetadataModelEntries(metadata: Record): RawModelEntry[] { + const entries: RawModelEntry[] = []; + + if (typeof metadata.model === "string") { + entries.push(metadata.model); + } else if (metadata.model && typeof metadata.model === "object") { + entries.push(metadata.model as RawModelEntry); + } + + if (typeof metadata.modelUrl === "string") { + entries.push(metadata.modelUrl); + } + + if (Array.isArray(metadata.models)) { + entries.push(...(metadata.models as RawModelEntry[])); + } + + return entries; +} + +const findProjectModels = (project: Partial): ProjectModelDescriptor[] => { + const metadata = ((project.metadata || {}) as Record) || {}; + const resolvedEntries = getRawMetadataModelEntries(metadata) + .map(normalizeEntry) + .filter((entry): entry is ProjectModelDescriptor => Boolean(entry)); + + const seen = new Set(); + return resolvedEntries.filter(entry => { + if (seen.has(entry.url)) { + return false; + } + seen.add(entry.url); + return true; + }); +}; + +export default findProjectModels; diff --git a/lib/projectModelFiles.ts b/lib/projectModelFiles.ts new file mode 100644 index 00000000..2efa3f91 --- /dev/null +++ b/lib/projectModelFiles.ts @@ -0,0 +1,56 @@ +import type { Attachment } from "./dpp-types"; + +export type ProjectModelMetadata = { + contentType?: string; + downloadUrl: string; + extension: string; + fileName?: string; + id?: string; + mimeType?: string; + name: string; + size?: number; + storage: "dpp"; + uploadedAt?: string; + url: string; + checksum?: string; +}; + +type UploadModelFile = (file: File) => Promise; +const DPP_BASE_URL = process.env.NEXT_PUBLIC_DPP_URL; + +function getExtension(fileName: string): string { + return fileName.split(".").pop()?.toLowerCase() || ""; +} + +export function dppAttachmentToProjectModel(attachment: Attachment): ProjectModelMetadata { + const url = DPP_BASE_URL ? `${DPP_BASE_URL}/file/${encodeURIComponent(attachment.id)}` : attachment.url; + + return { + contentType: attachment.contentType, + checksum: attachment.checksum, + downloadUrl: url, + extension: getExtension(attachment.fileName), + fileName: attachment.fileName, + id: attachment.id, + mimeType: attachment.contentType, + name: attachment.fileName, + size: attachment.size, + storage: "dpp", + uploadedAt: attachment.uploadedAt, + url, + }; +} + +export async function uploadModelFilesToDpp( + files: Array, + uploadModelFile: UploadModelFile +): Promise> { + const models: Array = []; + + for (const file of files) { + const attachment = await uploadModelFile(file); + models.push(dppAttachmentToProjectModel(attachment)); + } + + return models; +} diff --git a/package.json b/package.json index a3ffd53d..7d192112 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "next-i18next": "^12.1.0", "next-seo": "^6.8.0", "octokit": "^2.1.0", + "online-3d-viewer": "^0.18.0", "playwright": "1.34.0-alpha-may-11-2023", "postcss": "^8.5.6", "postcss-preset-env": "^7.8.3", diff --git a/pages/api/dpp-file/[id]/[filename].ts b/pages/api/dpp-file/[id]/[filename].ts new file mode 100644 index 00000000..f3855395 --- /dev/null +++ b/pages/api/dpp-file/[id]/[filename].ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const DPP_BASE_URL = process.env.NEXT_PUBLIC_DPP_URL; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { id, type } = req.query; + + if (!id || typeof id !== "string") { + res.status(400).end("Missing file id"); + return; + } + + if (!DPP_BASE_URL) { + res.status(500).end("Missing DPP base URL"); + return; + } + + try { + const response = await fetch(`${DPP_BASE_URL}/file/${encodeURIComponent(id)}`); + if (!response.ok) { + res.status(response.status).end(); + return; + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const mimeType = + typeof type === "string" ? type : response.headers.get("content-type") || "application/octet-stream"; + + res.setHeader("Content-Type", mimeType); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.send(buffer); + } catch { + res.status(502).end("Failed to fetch DPP file"); + } +} diff --git a/pages/api/file/[hash].ts b/pages/api/file/[hash].ts new file mode 100644 index 00000000..5420f89e --- /dev/null +++ b/pages/api/file/[hash].ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const ZENFLOWS_FILE_URL = process.env.NEXT_PUBLIC_ZENFLOWS_FILE_URL; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { hash } = req.query; + if (!hash || typeof hash !== "string") { + res.status(400).end("Missing hash"); + return; + } + + try { + const response = await fetch(`${ZENFLOWS_FILE_URL}/${hash}`); + if (!response.ok) { + res.status(response.status).end(); + return; + } + + const base64 = await response.text(); + const buffer = Buffer.from(base64, "base64"); + + const mimeType = (req.query.type as string) || "application/octet-stream"; + res.setHeader("Content-Type", mimeType); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.send(buffer); + } catch { + res.status(502).end("Failed to fetch file"); + } +} diff --git a/pages/project/[id]/edit/model.tsx b/pages/project/[id]/edit/model.tsx new file mode 100644 index 00000000..8e5b9d80 --- /dev/null +++ b/pages/project/[id]/edit/model.tsx @@ -0,0 +1,118 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { GetStaticPaths } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { NextPageWithLayout } from "pages/_app"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; + +import EditProjectLayout from "components/layout/EditProjectLayout"; +import FetchProjectLayout, { useProject } from "components/layout/FetchProjectLayout"; +import Layout from "components/layout/Layout"; +import ModelFilesStep, { + modelFilesStepSchema, + ModelFilesStepValues, +} from "components/partials/create/project/steps/ModelFilesStep"; +import EditFormLayout from "components/partials/project/edit/EditFormLayout"; +import { useProjectCRUD } from "hooks/useProjectCRUD"; +import useDppApi from "lib/dpp"; +import findProjectModels, { getRawMetadataModelEntries } from "lib/findProjectModels"; +import { uploadModelFilesToDpp } from "lib/projectModelFiles"; + +type ModelMetadataEntry = string | Record; + +interface EditModelFilesValues { + modelFiles: ModelFilesStepValues; +} + +const SEPARATOR = " @ "; + +function createToken(entry: ReturnType[number], index: number): string { + return entry.hash || entry.url || `${entry.name}-${index}`; +} + +const EditModelFiles: NextPageWithLayout = () => { + const { project } = useProject(); + const { updateMetadata } = useProjectCRUD(); + const { uploadFile: uploadDppFile } = useDppApi(); + + const metadata = ((project.metadata || {}) as Record) || {}; + const existingEntries = getRawMetadataModelEntries(metadata); + const resolvedModels = findProjectModels(project); + + const existingModels = resolvedModels.map((model, index) => { + const token = createToken(model, index); + + return { + entry: existingEntries[index] as ModelMetadataEntry, + file: new File([], `${model.name}${SEPARATOR}${token}`), + token, + }; + }); + + const defaultValues: EditModelFilesValues = { + modelFiles: existingModels.map(model => model.file), + }; + + const schema = yup.object({ + modelFiles: modelFilesStepSchema(), + }); + + const formMethods = useForm({ + mode: "all", + resolver: yupResolver(schema), + defaultValues, + }); + + function getExistingEntry(fileName: string): ModelMetadataEntry | undefined { + const fileNameParts = fileName.split(SEPARATOR); + const token = fileNameParts[fileNameParts.length - 1]; + return existingModels.find(model => model.token === token)?.entry; + } + + function isExistingEntry(fileName: string): boolean { + return Boolean(getExistingEntry(fileName)); + } + + async function onSubmit(values: EditModelFilesValues) { + const preservedEntries = values.modelFiles + .map(file => getExistingEntry(file.name)) + .filter((entry): entry is ModelMetadataEntry => Boolean(entry)); + + const newFiles = values.modelFiles.filter(file => !isExistingEntry(file.name)); + const newEntries = await uploadModelFilesToDpp(newFiles, uploadDppFile); + + await updateMetadata(project, { models: [...preservedEntries, ...newEntries] }); + } + + return ( + + + + ); +}; + +export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => { + return { + paths: [], + fallback: "blocking", + }; +}; + +export async function getStaticProps({ locale }: any) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common", "createProjectProps"])), + }, + }; +} + +EditModelFiles.getLayout = page => ( + + + {page} + + +); + +export default EditModelFiles; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f87a94ec..d268297f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: octokit: specifier: ^2.1.0 version: 2.1.0 + online-3d-viewer: + specifier: ^0.18.0 + version: 0.18.0 playwright: specifier: 1.34.0-alpha-may-11-2023 version: 1.34.0-alpha-may-11-2023 @@ -1371,6 +1374,9 @@ packages: '@sideway/pinpoint@2.0.0': resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + '@simonwep/pickr@1.9.0': + resolution: {integrity: sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==} + '@swc/helpers@0.4.3': resolution: {integrity: sha512-6JrF+fdUK2zbGpJIlN7G3v966PQjyx/dPt1T9km2wj+EUBqgrxCk3uX4Kct16MIm9gGxfKRcfax2hVf5jvlTzA==} @@ -2090,6 +2096,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.32.2: + resolution: {integrity: sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==} + core-js@3.45.1: resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} @@ -3764,6 +3773,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanopop@2.3.0: + resolution: {integrity: sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==} + natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -3903,6 +3915,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + online-3d-viewer@0.18.0: + resolution: {integrity: sha512-y7ZlV/zkakNUyjqcXz6XecA7vXgLEUnaAey9tyx8o6/wcdV64RfjXAQOjGXGY2JOZoDi4Cg1ic9icSWMWAvRQA==} + optimism@0.18.1: resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} @@ -4967,6 +4982,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.176.0: + resolution: {integrity: sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -6817,6 +6835,11 @@ snapshots: '@sideway/pinpoint@2.0.0': {} + '@simonwep/pickr@1.9.0': + dependencies: + core-js: 3.32.2 + nanopop: 2.3.0 + '@swc/helpers@0.4.3': dependencies: tslib: 2.8.1 @@ -7648,6 +7671,8 @@ snapshots: convert-source-map@2.0.0: {} + core-js@3.32.2: {} + core-js@3.45.1: {} cosmiconfig-typescript-loader@4.4.0(@types/node@18.7.15)(cosmiconfig@7.1.0)(ts-node@10.9.2(@types/node@18.7.15)(typescript@5.9.3))(typescript@5.9.3): @@ -9707,6 +9732,8 @@ snapshots: nanoid@3.3.11: {} + nanopop@2.3.0: {} + natural-compare-lite@1.4.0: {} natural-compare@1.4.0: {} @@ -9872,6 +9899,12 @@ snapshots: dependencies: mimic-fn: 4.0.0 + online-3d-viewer@0.18.0: + dependencies: + '@simonwep/pickr': 1.9.0 + fflate: 0.8.2 + three: 0.176.0 + optimism@0.18.1: dependencies: '@wry/caches': 1.0.1 @@ -11103,6 +11136,8 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.176.0: {} + through@2.3.8: {} tinyqueue@2.0.3: {} From 49a731cecc8c427b719de4a48731120130ab0cbd Mon Sep 17 00:00:00 2001 From: phoebus-84 <83974413+phoebus-84@users.noreply.github.com> Date: Wed, 6 May 2026 12:38:31 +0200 Subject: [PATCH 18/18] =?UTF-8?q?fix(docker):=20=F0=9F=90=9B=20replace=20b?= =?UTF-8?q?roken=20pnpm=20download=20URL=20with=20corepack=20(#849)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm changed their release artifact naming, causing https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64 to return 404. Use Node.js built-in corepack instead, which is the officially recommended way to install pnpm in Docker images. --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8bfbbf82..5e8811a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,8 +27,7 @@ ARG NODE_ENV=production ENV NODE_ENV=$NODE_ENV RUN apk add --no-cache libc6-compat -RUN wget "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -O /bin/pnpm && \ - chmod +x /bin/pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /build