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/.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/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/.beads/issues.jsonl b/.beads/issues.jsonl index f1d801a4..2043c3dc 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -50,12 +50,20 @@ {"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"}]} {"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"}]} @@ -68,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..5bbacc32 --- /dev/null +++ b/.beads/last-touched @@ -0,0 +1 @@ +interfacer-gui-ve1.1 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 }} diff --git a/components/CatalogFilterSidebar.tsx b/components/CatalogFilterSidebar.tsx new file mode 100644 index 00000000..5942038b --- /dev/null +++ b/components/CatalogFilterSidebar.tsx @@ -0,0 +1,844 @@ +// 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 = + 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"; + 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/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/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/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/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 */}
+ {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 ec4f0e35..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; @@ -28,7 +20,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]; @@ -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/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/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/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/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/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 (
-