diff --git a/.beads/.gitignore b/.beads/.gitignore index f438450f..d27a1db5 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -10,11 +10,20 @@ daemon.lock daemon.log daemon.pid bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version # Legacy database files db.sqlite bd.db +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + # Merge artifacts (temporary files from 3-way merge) beads.base.jsonl beads.base.meta.json @@ -23,7 +32,13 @@ beads.left.meta.json beads.right.jsonl beads.right.meta.json -# Keep JSONL exports and config (source of truth for git) -!issues.jsonl -!metadata.json -!config.json +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/last-touched b/.beads/last-touched index 8e4e5f58..6e0fe565 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -interfacer-gui-zsv +interfacer-gui-4c2.3 diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml index fee6b94d..f31e4168 100644 --- a/.github/workflows/test-deploy.yml +++ b/.github/workflows/test-deploy.yml @@ -44,7 +44,11 @@ jobs: run: cp .env.example .env.local - run: npm run build - name: Install Playwright Browsers - run: npx playwright install --with-deps + timeout-minutes: 10 + run: | + sudo sed -i 's/azure\.archive\.ubuntu\.com/archive.ubuntu.com/g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true + sudo apt-get update -qq + npx playwright install --with-deps - name: Run Playwright tests run: npx playwright test - uses: actions/upload-artifact@v4.6.0 diff --git a/Dockerfile b/Dockerfile index 8bfbbf82..5e8811a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,8 +27,7 @@ ARG NODE_ENV=production ENV NODE_ENV=$NODE_ENV RUN apk add --no-cache libc6-compat -RUN wget "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -O /bin/pnpm && \ - chmod +x /bin/pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /build 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..1c9d014d --- /dev/null +++ b/components/CatalogLayout.tsx @@ -0,0 +1,381 @@ +// 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, + EconomicResourceSortField, + EconomicResourceSortInput, + FetchInventoryQuery, + SortDirection, +} 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; distinctPrimaryAccountableCount: number; loading: boolean }) => void; +} + +const SORT_OPTIONS_DEFAULT = ["Latest", "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 && { name: 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; + + // Map UI sort label to GraphQL orderBy input + const SORT_MAP: Record = { + Latest: { field: EconomicResourceSortField.CreatedAt, direction: SortDirection.Desc }, + Oldest: { field: EconomicResourceSortField.CreatedAt, direction: SortDirection.Asc }, + "A\u2013Z": { field: EconomicResourceSortField.Name, direction: SortDirection.Asc }, + "Z\u2013A": { field: EconomicResourceSortField.Name, direction: SortDirection.Desc }, + }; + const orderBy = SORT_MAP[sortBy] || undefined; + + const { loading, data, fetchMore, refetch, variables, error } = useQuery(FETCH_RESOURCES, { + variables: { last: 12, filter: effectiveFilter, orderBy }, + 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 distinctPrimaryAccountableCount = data?.economicResources?.pageInfo?.distinctPrimaryAccountableCount || 0; + const hasNext = !!getHasNextPage; + + // Notify parent of data changes + React.useEffect(() => { + if (onDataLoaded && data?.economicResources?.pageInfo) { + onDataLoaded({ totalCount, distinctPrimaryAccountableCount, loading: isLoading }); + } + }, [data, isLoading, onDataLoaded, totalCount, distinctPrimaryAccountableCount]); + + 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 */} +
+ +
+
+
+ + {/* 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/GeneralCard.tsx b/components/GeneralCard.tsx index 1c371890..238511f4 100644 --- a/components/GeneralCard.tsx +++ b/components/GeneralCard.tsx @@ -7,6 +7,7 @@ import useWallet from "hooks/useWallet"; import { IdeaPoints } from "lib/PointsDistribution"; import findProjectImages from "lib/findProjectImages"; import { isProjectType } from "lib/isProjectType"; +import { extractUserTagValues } from "lib/tagging"; import { EconomicResource } from "lib/types"; import { useTranslation } from "next-i18next"; import Link from "next/link"; @@ -67,10 +68,11 @@ const GeneralCard = (props: GeneralCardProps) => { const Tags = () => { const { project } = useCardProject(); - if (!project.classifiedAs?.length) return null; + const tags = extractUserTagValues(project.classifiedAs); + if (!tags.length) return null; return (
- +
); }; diff --git a/components/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..3fa5051a --- /dev/null +++ b/components/NavigationMenu.tsx @@ -0,0 +1,538 @@ +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 11494642..ba4d418d 100644 --- a/components/ProductsActiveFiltersBar.tsx +++ b/components/ProductsActiveFiltersBar.tsx @@ -18,7 +18,7 @@ import { useQuery } from "@apollo/client"; import { Tag } from "@bbtgnn/polaris-interfacer"; import { useResourceSpecs } from "hooks/useResourceSpecs"; import { QUERY_MACHINES } from "lib/QueryAndMutation"; -import { isPrefixedTag, prefixedTag, REPAIRABILITY_AVAILABLE_TAG, TAG_PREFIX } from "lib/tagging"; +import { isSystemTag, prefixedTag, REPAIRABILITY_AVAILABLE_TAG, stripUserTagPrefix, TAG_PREFIX } from "lib/tagging"; import { useTranslation } from "next-i18next"; import { useRouter } from "next/router"; @@ -90,21 +90,10 @@ export default function ProductsActiveFiltersBar() { const categoryTags = rawTags.filter(tag => tag.startsWith(`${TAG_PREFIX.CATEGORY}-`)); - const userTags = rawTags.filter( - tag => - !isPrefixedTag(tag, [ - TAG_PREFIX.MACHINE, - TAG_PREFIX.MATERIAL, - TAG_PREFIX.CATEGORY, - TAG_PREFIX.POWER_COMPAT, - TAG_PREFIX.POWER_REQ, - TAG_PREFIX.REPLICABILITY, - TAG_PREFIX.RECYCLABILITY, - TAG_PREFIX.REPAIRABILITY, - TAG_PREFIX.ENV_ENERGY, - TAG_PREFIX.ENV_CO2, - ]) - ); + // User-facing tags in the active filter bar: anything that isn't a known + // system-prefixed tag (includes both canonical `tag-*` entries and legacy + // un-prefixed values still present in older URLs). + const userTags = rawTags.filter(tag => !isSystemTag(tag)); const derivedManufacturability = (() => { const fromParam = asCsvArray(router.query.manufacturability); @@ -247,9 +236,9 @@ export default function ProductsActiveFiltersBar() { for (const tag of userTags) { const decoded = (() => { try { - return decodeURIComponent(tag); + return decodeURIComponent(stripUserTagPrefix(tag)); } catch { - return tag; + return stripUserTagPrefix(tag); } })(); diff --git a/components/ProductsFilters.tsx b/components/ProductsFilters.tsx index a3518920..6ba9539c 100644 --- a/components/ProductsFilters.tsx +++ b/components/ProductsFilters.tsx @@ -21,13 +21,14 @@ import { MACHINE_TYPES } from "lib/resourceSpecs"; import { CO2_THRESHOLDS_KG, ENERGY_THRESHOLDS_KWH, - isPrefixedTag, + extractUserTagValues, mergeTags, + normalizeUserTagsForSave, POWER_COMPATIBILITY_OPTIONS, POWER_REQUIREMENT_THRESHOLDS_W, prefixedTag, - RECYCLABILITY_THRESHOLDS_PCT, rangeFilterTags, + RECYCLABILITY_THRESHOLDS_PCT, REPAIRABILITY_AVAILABLE_TAG, REPLICABILITY_OPTIONS, TAG_PREFIX, @@ -177,22 +178,9 @@ export default function ProductsFilters() { const query = router.query; const rawTags = query.tags ? (query.tags as string).split(",") : []; - // Keep derived tags out of the user tags selector. - const userTags = rawTags.filter( - tag => - !isPrefixedTag(tag, [ - TAG_PREFIX.MACHINE, - TAG_PREFIX.MATERIAL, - TAG_PREFIX.CATEGORY, - TAG_PREFIX.POWER_COMPAT, - TAG_PREFIX.POWER_REQ, - TAG_PREFIX.REPLICABILITY, - TAG_PREFIX.RECYCLABILITY, - TAG_PREFIX.REPAIRABILITY, - TAG_PREFIX.ENV_ENERGY, - TAG_PREFIX.ENV_CO2, - ]) - ); + // Show user-facing tag values (prefix stripped) in the tags selector so + // the chips read e.g. "laser cut" instead of "tag-laser-cut". + const userTags = extractUserTagValues(rawTags); const manufacturability = query.manufacturability ? (query.manufacturability as string).split(",") @@ -295,8 +283,12 @@ export default function ProductsFilters() { const existingCategoryTags = rawTags.filter(tag => tag.startsWith(`${TAG_PREFIX.CATEGORY}-`)); + // User-entered free-form tags get the canonical `tag-` prefix so they + // match how they are persisted on save. + const userTags = normalizeUserTagsForSave(filters.tags); + const combinedTags = mergeTags( - filters.tags, + userTags, existingCategoryTags, machineTags, materialTags, diff --git a/components/ProfilePageNew.tsx b/components/ProfilePageNew.tsx new file mode 100644 index 00000000..150faa6c --- /dev/null +++ b/components/ProfilePageNew.tsx @@ -0,0 +1,1277 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import { useQuery } from "@apollo/client"; +import { + Add, + ArrowRight, + Calendar, + ChevronDown, + ChevronLeft, + ChevronRight, + Download, + Information, + OverflowMenuVertical, + Search, + SortAscending, +} from "@carbon/icons-react"; +import { ExternalLinkIcon, LocationMarkerIcon } from "@heroicons/react/outline"; +import BrUserAvatar from "components/brickroom/BrUserAvatar"; +import EntityTypeIcon from "components/EntityTypeIcon"; +import { useUser } from "components/layout/FetchUserLayout"; +import ProjectCardNew from "components/ProjectCardNew"; +import { ProjectType } from "components/types"; +import { useAuth } from "hooks/useAuth"; +import useFilters from "hooks/useFilters"; +import useLoadMore from "hooks/useLoadMore"; +import useDppApi from "lib/dpp"; +import type { DppDocument, DppStatus, ListDppsResponse, StatusFacets } from "lib/dpp-types"; +import { FETCH_RESOURCES } from "lib/QueryAndMutation"; +import { FetchInventoryQuery } from "lib/types"; +import { useTranslation } from "next-i18next"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +// ─── Tab definitions ──────────────────────────────────────────────────────── + +type ProfileTabId = "designs" | "products" | "services" | "dpps" | "community"; + +interface TabDef { + id: ProfileTabId; + labelKey: string; + type: ProjectType; +} + +const tabs: TabDef[] = [ + { id: "designs", labelKey: "Designs", type: ProjectType.DESIGN }, + { id: "products", labelKey: "Products", type: ProjectType.PRODUCT }, + { id: "services", labelKey: "Services", type: ProjectType.SERVICE }, + { id: "dpps", labelKey: "DPPs", type: ProjectType.DPP }, +]; + +// ─── Per-tab CTA & toolbar config ─────────────────────────────────────────── + +interface TabCtaConfig { + ctaTitle: string; + ctaDescription: string; + createLabel: string; + createUrl: string; + searchPlaceholder: string; +} + +const tabCtaConfig: Record, TabCtaConfig> = { + designs: { + ctaTitle: "Publish your design documentation", + ctaDescription: + "Allow the world to see, build and replicate your design. Add all the relevant info to help other users discover your work.", + createLabel: "Create a new Design", + createUrl: "/create/project/design", + searchPlaceholder: "Search designs by name, tags...", + }, + products: { + ctaTitle: "Turn designs into manufacturable products", + ctaDescription: + "Create a product listing on Interfacer. Products are displayed inside the design page and in the Products Catalog.", + createLabel: "Create a new Product", + createUrl: "/create/project/product", + searchPlaceholder: "Search products by name, tags...", + }, + services: { + ctaTitle: "Offer your skills and services", + ctaDescription: + "List your workshop, lab, or service. Help makers find the equipment and expertise they need to build their projects.", + createLabel: "Create a new Service", + createUrl: "/create/project/service", + searchPlaceholder: "Search services by name, tags...", + }, + dpps: { + ctaTitle: "Publish DPPs for your products", + ctaDescription: + "With a Digital Product Passport (DPP) each product gets a unique QR code for easy tracking and verification.", + createLabel: "Publish a New DPP", + createUrl: "/dpps/new", + searchPlaceholder: "Search DPPs by batch ID, status...", + }, +}; + +// ─── Profile Tab Content ──────────────────────────────────────────────────── + +function ProfileTabContent({ + userId, + specId, + tabType, + isOwner, + ctaConfig, +}: { + userId: string; + specId?: string; + tabType: ProjectType; + isOwner: boolean; + ctaConfig: TabCtaConfig; +}) { + const { t } = useTranslation("common"); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [sortBy, setSortBy] = useState("latest"); + const [showSortMenu, setShowSortMenu] = useState(false); + + // Debounce search input to avoid firing a query on every keystroke + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchQuery), 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + const filter = useMemo( + () => ({ + primaryAccountable: [userId], + conformsTo: specId ? [specId] : undefined, + ...(debouncedSearch && { name: debouncedSearch }), + }), + [userId, specId, debouncedSearch] + ); + + const dataQueryIdentifier = "economicResources"; + + const { loading, data, fetchMore, refetch, variables } = useQuery(FETCH_RESOURCES, { + variables: { last: 12, filter }, + }); + + const { loadMore, showEmptyState, items, getHasNextPage } = useLoadMore({ + fetchMore, + refetch, + variables, + data, + dataQueryIdentifier, + }); + + const projects = items; + const hasNext = !!getHasNextPage; + + return ( +
+ {/* CTA + Stats row (owner only) */} + {isOwner && ( +
+ {/* Left: CTA */} +
+
+

+ {t(ctaConfig.ctaTitle)} +

+

+ {t(ctaConfig.ctaDescription)} +

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

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

+
+ ) : ( +
+ {projects?.map((edge: any) => ( + + ))} +
+ )} + + {/* Load more */} + {hasNext && ( +
+ +
+ )} +
+ ); +} + +// ─── DPPs Tab ─────────────────────────────────────────────────────────── + +function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwner: boolean; ctaConfig: TabCtaConfig }) { + const { t } = useTranslation("common"); + const dppApi = useDppApi(); + const [dpps, setDpps] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState<"latest" | "oldest" | "az" | "za">("latest"); + const [statusFilter, setStatusFilter] = useState<"all" | "active" | "draft" | "archived">("all"); + const [showSortMenu, setShowSortMenu] = useState(false); + const [showFilterMenu, setShowFilterMenu] = useState(false); + const [showActionsMenu, setShowActionsMenu] = useState(null); + const [facets, setFacets] = useState({ active: 0, draft: 0, archived: 0 }); + + useEffect(() => { + let cancelled = false; + setLoading(true); + dppApi + .listDpps({ createdBy: userId, limit: 50 }) + .then((res: ListDppsResponse) => { + if (!cancelled) { + setDpps(res.dpps || []); + setTotal(res.total || 0); + if (res.facets) setFacets(res.facets); + } + }) + .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 + + // Close menus on outside click + useEffect(() => { + const handler = () => { + setShowSortMenu(false); + setShowFilterMenu(false); + setShowActionsMenu(null); + }; + document.addEventListener("click", handler); + return () => document.removeEventListener("click", handler); + }, []); + + // KPI counters: use backend facets when available, client-side fallback + const statusCounts = useMemo(() => { + if (facets.active + facets.draft + facets.archived > 0) { + return { + total: facets.active + facets.draft + facets.archived, + ...facets, + }; + } + const counts = { total: dpps.length, active: 0, draft: 0, archived: 0 }; + for (const d of dpps) { + if (d.status === "active") counts.active++; + else if (d.status === "draft") counts.draft++; + else if (d.status === "archived") counts.archived++; + } + return counts; + }, [dpps, facets]); + + const filteredDpps = useMemo(() => { + let items = dpps; + + // Status filter + if (statusFilter !== "all") { + items = items.filter(d => d.status === statusFilter); + } + + // Text search + if (searchQuery) { + const q = searchQuery.toLowerCase(); + items = items.filter( + d => + d.id?.toLowerCase().includes(q) || + d.productId?.toLowerCase().includes(q) || + d.batchId?.toLowerCase().includes(q) || + d.status?.toLowerCase().includes(q) || + d.batchType?.toLowerCase().includes(q) || + d.productOverview?.productName?.value?.toLowerCase().includes(q) || + d.productOverview?.brandName?.value?.toLowerCase().includes(q) + ); + } + + // Sort + items = [...items]; + switch (sortBy) { + case "oldest": + items.reverse(); + break; + case "az": + items.sort((a, b) => { + const na = a.productOverview?.productName?.value || ""; + const nb = b.productOverview?.productName?.value || ""; + return na.localeCompare(nb); + }); + break; + case "za": + items.sort((a, b) => { + const na = a.productOverview?.productName?.value || ""; + const nb = b.productOverview?.productName?.value || ""; + return nb.localeCompare(na); + }); + break; + } + return items; + }, [dpps, searchQuery, sortBy, statusFilter]); + + 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)" }, + }; + + const sortLabels: Record = { + latest: "Latest", + oldest: "Oldest", + az: "A–Z", + za: "Z–A", + }; + + const filterLabels: Record = { + all: "All", + active: "Active", + draft: "Drafts", + archived: "Archived", + }; + + const handleStatusChange = async (dppId: string, newStatus: DppStatus) => { + try { + await dppApi.updateDppStatus(dppId, newStatus); + setDpps(prev => prev.map(d => (d.id === dppId ? { ...d, status: newStatus } : d))); + } catch (err) { + console.error("Failed to update DPP status:", err); + } + setShowActionsMenu(null); + }; + + return ( +
+ {/* CTA + KPI Stats row (owner only) */} + {isOwner && ( +
+ {/* CTA left */} +
+
+

+ {t(ctaConfig.ctaTitle)} +

+

+ {t(ctaConfig.ctaDescription)} +

+
+
+ + + + {t(ctaConfig.createLabel)} + + + +
+
+ + {/* KPI Stats right */} +
+ {( + [ + { label: "Total DPPs", value: statusCounts.total }, + { label: "Active", value: statusCounts.active }, + { label: "Drafts", value: statusCounts.draft }, + { label: "Archived", value: statusCounts.archived }, + ] as const + ).map(kpi => ( +
+ + {t(kpi.label)} + + + {kpi.value} + +
+ ))} +
+
+ )} + + {/* Search & Sort & Filter toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t("Search by product name, DPP ID, or project ID...")} + className="flex-1 bg-transparent border-none outline-none text-ifr-text-primary" + style={{ fontFamily: "var(--ifr-font-body)", fontSize: "var(--ifr-fs-base)" }} + /> +
+ + {/* Sort dropdown */} +
+ + {showSortMenu && ( +
+ {(["latest", "oldest", "az", "za"] as const).map(opt => ( + + ))} +
+ )} +
+ + {/* Status filter dropdown */} +
+ + {showFilterMenu && ( +
+ {(["all", "active", "draft", "archived"] as const).map(opt => ( + + ))} +
+ )} +
+ + {isOwner && ( + + + + {t("Create New DPP")} + + + )} +
+ + {/* Results */} + {loading ? ( +
+
+
+ ) : filteredDpps.length === 0 ? ( +
+

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

+
+ ) : ( +
+ {/* Table header */} +
+ {t("Product ID")} + {t("DPP ID")} + {t("Batch / Unit")} + {t("Status")} + {t("Created")} + {t("QR code")} + +
+ + {/* Table rows */} + {filteredDpps.map(dpp => { + const productName = + dpp.productOverview?.productName?.value || dpp.productOverview?.brandName?.value || t("Untitled DPP"); + const productId = dpp.productId || "—"; + const status = dpp.status || "draft"; + const colors = statusColors[status] || statusColors.draft; + const dppUrl = `/dpps/${encodeURIComponent(dpp.id)}`; + const dppDisplayId = dpp.id.length > 12 ? `${dpp.id.slice(0, 12)}…` : dpp.id; + + return ( +
+ {/* Product ID + name */} +
+ + {productId} + + + + {productName} + + +
+ + {/* DPP ID */} + + {dppDisplayId} + + + {/* Batch / Unit */} + {dpp.batchId || "—"} + + {/* Status */} + + + {t(status.charAt(0).toUpperCase() + status.slice(1))} + + + + {/* Created */} + + + {dpp.createdAt + ? new Date(dpp.createdAt).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }) + : "—"} + + + {/* QR code download */} + e.stopPropagation()} + className="inline-flex items-center justify-center bg-transparent border-none cursor-pointer hover:opacity-70 transition-opacity no-underline" + style={{ width: 32, height: 32, color: "var(--ifr-text-secondary)" }} + > + + + + {/* More actions */} +
+ + {showActionsMenu === dpp.id && ( +
+ + + {t("View")} + + + {isOwner && status === "draft" && ( + + )} + {isOwner && (status === "draft" || status === "active") && ( + + )} + {isOwner && status === "archived" && ( + + )} +
+ )} +
+
+ ); + })} + + {/* Pagination footer */} +
+ + {t("Showing {{count}} of {{total}} DPPs", { + count: filteredDpps.length, + total: total, + })} + +
+ + +
+
+
+ )} +
+ ); +} + +// ─── 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..1f964999 --- /dev/null +++ b/components/ProjectCardNew.tsx @@ -0,0 +1,431 @@ +// 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 { extractUserTagValues } from "lib/tagging"; +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 user-facing tags (strips `tag-` prefix and filters out system tags). + const tags = extractUserTagValues(project.classifiedAs).slice(0, 4); + + // Design-specific: requirements + const machineTags = (project.classifiedAs || []).filter(tag => tag.startsWith("machine-")).map(humanizeSlug); + 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..539bc747 --- /dev/null +++ b/components/ProjectDetailNew.tsx @@ -0,0 +1,2147 @@ +// 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 findProjectModels from "lib/findProjectModels"; +import { isProjectType } from "lib/isProjectType"; +import MdParser from "lib/MdParser"; +import { extractUserTagValues } from "lib/tagging"; + +import { EconomicResource } from "lib/types"; +import { useTranslation } from "next-i18next"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { ReactNode, useEffect, useMemo, useState } from "react"; +import StepModelViewer from "./StepModelViewer"; + +function getProjectType(project: Partial): ProjectType { + const name = project.conformsTo?.name; + 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 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 */} + + + {t("View DPP")} + + +
+
+ ); +} + +/** 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]); + const models = useMemo(() => findProjectModels(project), [project]); + const primaryModel = useMemo(() => models.find(model => model.isViewable) || models[0], [models]); + const additionalModels = useMemo( + () => models.filter(model => model.url !== primaryModel?.url), + [models, primaryModel] + ); + + // User-facing tags: filtered to `tag-*` entries (prefix stripped) with legacy + // un-prefixed values kept visible for backwards compatibility. + const tags = useMemo(() => extractUserTagValues(project.classifiedAs), [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 */} + + + {/* 3D model viewer for designs */} + {projectType === ProjectType.DESIGN && primaryModel && ( + + + + + } + iconBg="bg-ifr-hover" + title={t("3D Model")} + subtitle={ + primaryModel.isViewable + ? t("Inspect the fabrication model directly in the browser") + : t("3D source file attached for download") + } + sectionId="3d-model" + > +
+

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

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

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

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

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

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

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

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

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

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

+ {t("Material Composition")} +

+

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

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

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

+

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

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

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

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

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

+

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

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

+ {t("Repair Actions")} +

+

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

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

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

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

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

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

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

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

+ ) : ( +
+ {productDpps.map((dpp, idx) => ( + + ))} +
+ )} +
+ )} +
+
+ + {/* Sidebar */} +
+ +
+
+ + {/* Mobile sidebar */} +
+ +
+
+ ); +} diff --git a/components/ProjectTypeChip.tsx b/components/ProjectTypeChip.tsx index 703aa3ef..ea1e57ad 100644 --- a/components/ProjectTypeChip.tsx +++ b/components/ProjectTypeChip.tsx @@ -1,21 +1,13 @@ -import { Collaborate, DataDefinition, GroupObjectsNew, ToolKit } from "@carbon/icons-react"; import classNames from "classnames"; import { EconomicResource } from "lib/types"; import { useTranslation } from "next-i18next"; -import { ReactNode } from "react"; +import EntityTypeIcon from "./EntityTypeIcon"; import LinkWrapper from "./LinkWrapper"; import { ProjectTypeRenderProps } from "./ProjectTypeRenderProps"; import { ProjectType } from "./types"; // -const icons: Record = { - Design: , - Product: , - Service: , - Machine: , -}; - interface Props { project?: Partial; projectType?: ProjectType; @@ -44,7 +36,7 @@ export default function ProjectTypeChip(props: Props) { const baseChip = ( {renderProps?.label} - {renderProps && } + ); diff --git a/components/ProjectTypeRenderProps.tsx b/components/ProjectTypeRenderProps.tsx index 761e496b..4fe1e24d 100644 --- a/components/ProjectTypeRenderProps.tsx +++ b/components/ProjectTypeRenderProps.tsx @@ -1,9 +1,7 @@ -import { CarbonIconType, Collaborate, DataDefinition, GroupObjectsNew, ToolKit } from "@carbon/icons-react"; import { ProjectType } from "./types"; export type RenderProps = { label: string; - icon: CarbonIconType; classes: { bg: string; content: string; @@ -14,34 +12,38 @@ export type RenderProps = { export const ProjectTypeRenderProps: Record = { [ProjectType.DESIGN]: { label: ProjectType.DESIGN, - icon: GroupObjectsNew, classes: { - bg: "bg-[#E4CCE3]", - content: "text-[#413840] fill-[#413840]", - border: "border-[#C18ABF] ring-[#C18ABF]", + bg: "bg-ifr-green", + content: "text-white fill-white", + border: "border-[#014837] ring-[#014837]", }, }, [ProjectType.PRODUCT]: { label: ProjectType.PRODUCT, - icon: DataDefinition, classes: { - bg: "bg-[#FAE5B7]", - content: "text-[#614C1F] fill-[#614C1F]", - border: "border-[#614C1F] ring-[#614C1F]", + bg: "bg-ifr-product", + content: "text-white fill-white", + border: "border-[#0b1324] ring-[#0b1324]", }, }, [ProjectType.SERVICE]: { label: ProjectType.SERVICE, - icon: Collaborate, classes: { - bg: "bg-[#CDE0E4]", - content: "text-[#024960] fill-[#024960]", - border: "border-[#5D8CA0] ring-[#5D8CA0]", + bg: "bg-ifr-service", + content: "text-white fill-white", + border: "border-[#570093] ring-[#570093]", + }, + }, + [ProjectType.DPP]: { + label: ProjectType.DPP, + classes: { + bg: "bg-ifr-dpp", + content: "text-white fill-white", + border: "border-[#9e3c00] ring-[#9e3c00]", }, }, [ProjectType.MACHINE]: { label: ProjectType.MACHINE, - icon: ToolKit, classes: { bg: "bg-[#D4E5D7]", content: "text-[#2D5035] fill-[#2D5035]", diff --git a/components/ProjectTypeRoundIcon.tsx b/components/ProjectTypeRoundIcon.tsx index 1be1a426..66d64c29 100644 --- a/components/ProjectTypeRoundIcon.tsx +++ b/components/ProjectTypeRoundIcon.tsx @@ -1,14 +1,15 @@ +import EntityTypeIcon from "./EntityTypeIcon"; import { ProjectTypeRenderProps } from "./ProjectTypeRenderProps"; import { ProjectType } from "./types"; export default function ProjectTypeRoundIcon(props: { projectType?: ProjectType }) { const { projectType } = props; - const renderProps = ProjectTypeRenderProps[projectType || ProjectType.DESIGN]; + const type = projectType || ProjectType.DESIGN; + const renderProps = ProjectTypeRenderProps[type]; return (
- {/* @ts-ignore */} - {renderProps && } +
); } diff --git a/components/ProjectsFilters.tsx b/components/ProjectsFilters.tsx index e3d7f2db..79a366f0 100644 --- a/components/ProjectsFilters.tsx +++ b/components/ProjectsFilters.tsx @@ -21,6 +21,7 @@ import { useState } from "react"; // Select components import { Button, Card, Stack, Text } from "@bbtgnn/polaris-interfacer"; import { getOptionValue } from "components/brickroom/utils/BrSelectUtils"; +import { extractUserTagValues, normalizeUserTagsForSave } from "lib/tagging"; import SearchUsers from "./SearchUsers"; import SelectProjectType from "./SelectProjectType"; import SelectTags from "./SelectTags"; @@ -56,7 +57,10 @@ export default function ProjectsFilters(props: ProjectsFiltersProps) { // Converts query value in string array function getFilterValues(filter: ProjectFilter): Array { if (!query[filter]) return []; - else return query[filter].split(","); + const values = query[filter].split(","); + // Present user tags to the user without the `tag-` prefix. + if (filter === "tags") return extractUserTagValues(values); + return values; } // Creating state and loading it them with existing values @@ -72,7 +76,10 @@ export default function ProjectsFilters(props: ProjectsFiltersProps) { function applyFilters() { for (let f of ProjectFilters) { - if (queryFilters[f].length > 0) query[f] = queryFilters[f].join(","); + // User tags are prefixed when placed in the URL so they match the stored + // canonical `tag-` form. + const values = f === "tags" ? normalizeUserTagsForSave(queryFilters[f]) : queryFilters[f]; + if (values.length > 0) query[f] = values.join(","); else delete query[f]; } diff --git a/components/ProjectsTableRow.tsx b/components/ProjectsTableRow.tsx index 665162df..5798cdd5 100644 --- a/components/ProjectsTableRow.tsx +++ b/components/ProjectsTableRow.tsx @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { extractUserTagValues } from "lib/tagging"; import { EconomicResource } from "lib/types"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -74,7 +75,7 @@ const ProjectsTableRow = (props: { project: { node: EconomicResource } }) => { - + diff --git a/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/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/StepModelViewer.tsx b/components/StepModelViewer.tsx new file mode 100644 index 00000000..76faac5e --- /dev/null +++ b/components/StepModelViewer.tsx @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { useEffect, useRef, useState } from "react"; + +type StepModelViewerProps = { + downloadUrl?: string; + fileName?: string; + modelUrl: string; + height?: string; +}; + +const StepModelViewer = ({ downloadUrl, fileName, modelUrl, height = "min(72vh, 760px)" }: StepModelViewerProps) => { + const viewerContainerRef = useRef(null); + const viewerRef = useRef<{ Destroy: () => void; Resize: () => void } | null>(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + useEffect(() => { + let isCancelled = false; + const loadingStart = Date.now(); + + setIsLoading(true); + setError(null); + setElapsedSeconds(0); + + const loadingTimer = window.setInterval(() => { + if (!isCancelled) { + setElapsedSeconds(Math.floor((Date.now() - loadingStart) / 1000)); + } + }, 1000); + + const setupViewer = async () => { + if (!viewerContainerRef.current) { + return; + } + + try { + const OV = await import("online-3d-viewer"); + if (isCancelled || !viewerContainerRef.current) { + return; + } + + const viewer = new OV.EmbeddedViewer(viewerContainerRef.current, { + backgroundColor: new OV.RGBAColor(247, 247, 245, 255), + defaultColor: new OV.RGBColor(186, 186, 186), + // Edge extraction can add a noticeable CPU cost on large STEP files. + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(44, 44, 44), 15), + onModelLoaded: () => { + if (!isCancelled) { + window.clearInterval(loadingTimer); + setIsLoading(false); + } + }, + onModelLoadFailed: () => { + if (!isCancelled) { + window.clearInterval(loadingTimer); + setError(`Model loading failed for ${modelUrl}.`); + setIsLoading(false); + } + }, + }); + + viewerRef.current = viewer; + viewer.LoadModelFromUrlList([modelUrl]); + + const handleResize = () => { + viewerRef.current?.Resize(); + }; + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + } catch { + if (!isCancelled) { + window.clearInterval(loadingTimer); + setError("Unable to initialize Online3DViewer."); + setIsLoading(false); + } + } + }; + + let cleanupResizeListener: (() => void) | undefined; + setupViewer().then(cleanup => { + cleanupResizeListener = cleanup; + }); + + return () => { + isCancelled = true; + window.clearInterval(loadingTimer); + cleanupResizeListener?.(); + viewerRef.current?.Destroy(); + viewerRef.current = null; + }; + }, [modelUrl]); + + return ( + <> + {(fileName || downloadUrl) && ( +
+ {fileName ?

{fileName}

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

{error}

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

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

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

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

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

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

+

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

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

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

+

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

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

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

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

+ {t("Identification")} +

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

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

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

+ {formState.errors.batchId.message} +

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

+ {titles[projectType].title} +

+

+ {titles[projectType].subtitle} +

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

{t("Selected machines")}:

- +
{selectedMachines.map(machine => ( - handleMachineRemove(machine.id)}> + {machine.name} - + + ))} - +
)} diff --git a/components/partials/create/project/steps/ModelFilesStep.tsx b/components/partials/create/project/steps/ModelFilesStep.tsx new file mode 100644 index 00000000..fa87cf96 --- /dev/null +++ b/components/partials/create/project/steps/ModelFilesStep.tsx @@ -0,0 +1,61 @@ +import { Stack } from "@bbtgnn/polaris-interfacer"; +import PError from "components/polaris/PError"; +import PFileUpload from "components/polaris/PFileUpload"; +import PTitleSubtitle from "components/polaris/PTitleSubtitle"; +import { formSetValueOptions } from "lib/formSetValueOptions"; +import { useTranslation } from "next-i18next"; +import { useFormContext } from "react-hook-form"; +import * as yup from "yup"; +import { CreateProjectValues } from "../CreateProjectForm"; + +export type ModelFilesStepValues = Array; +export const modelFilesStepSchema = () => yup.array().default([]); +export const modelFilesStepDefaultValues: ModelFilesStepValues = []; + +const allowedExtensions = new Set(["step", "stp", "stl"]); +const maxFileSize = 50000000; + +function getExtension(fileName: string): string { + return fileName.split(".").pop()?.toLowerCase() || ""; +} + +export default function ModelFilesStep() { + const { t } = useTranslation("createProjectProps"); + const { setValue, watch, formState } = useFormContext(); + const { errors } = formState; + const modelFiles = watch().modelFiles; + const modelFilesError = errors.modelFiles?.message; + + function handleUpdate(files: Array) { + setValue("modelFiles", files, formSetValueOptions); + } + + return ( + + +
+ ({ + valid: allowedExtensions.has(getExtension(file.name)), + message: t("Only STEP, STP, and STL files are supported"), + }), + ]} + /> + {modelFilesError && } +
+
+ ); +} diff --git a/components/partials/create/project/steps/ServiceFiltersStep.tsx b/components/partials/create/project/steps/ServiceFiltersStep.tsx new file mode 100644 index 00000000..c4504fc4 --- /dev/null +++ b/components/partials/create/project/steps/ServiceFiltersStep.tsx @@ -0,0 +1,91 @@ +import { Stack } from "@bbtgnn/polaris-interfacer"; +import PHelp from "components/polaris/PHelp"; +import PTitleSubtitle from "components/polaris/PTitleSubtitle"; +import { formSetValueOptions } from "lib/formSetValueOptions"; +import { AVAILABILITY_OPTIONS, SERVICE_TYPE_OPTIONS } from "lib/tagging"; +import { useTranslation } from "next-i18next"; +import { useFormContext } from "react-hook-form"; +import * as yup from "yup"; +import { CreateProjectValues } from "../CreateProjectForm"; + +export interface ServiceFiltersStepValues { + serviceType: string[]; + availability: string[]; +} + +export const serviceFiltersStepDefaultValues: ServiceFiltersStepValues = { + serviceType: [], + availability: [], +}; + +export const serviceFiltersStepSchema = () => + yup.object().shape({ + serviceType: yup.array().of(yup.string().required()).default([]), + availability: yup.array().of(yup.string().required()).default([]), + }); + +function toggleValue(list: string[], value: string, checked: boolean): string[] { + if (checked) return list.includes(value) ? list : [...list, value]; + return list.filter(v => v !== value); +} + +export default function ServiceFiltersStep() { + const { t } = useTranslation("createProjectProps"); + const form = useFormContext(); + const { watch, setValue } = form; + + const values = watch("serviceFilters") || serviceFiltersStepDefaultValues; + + return ( + + + + + +
+ {SERVICE_TYPE_OPTIONS.map(option => ( + + ))} +
+
+ + + +
+ {AVAILABILITY_OPTIONS.map(option => ( + + ))} +
+
+
+ ); +} diff --git a/components/partials/project/projectSections.tsx b/components/partials/project/projectSections.tsx index da4177da..b9a98077 100644 --- a/components/partials/project/projectSections.tsx +++ b/components/partials/project/projectSections.tsx @@ -2,7 +2,6 @@ import { ProjectType } from "components/types"; import { CreateProjectValues } from "../create/project/CreateProjectForm"; import ContributorsStep from "../create/project/steps/ContributorsStep"; import DeclarationsStep from "../create/project/steps/DeclarationsStep"; -import DPPStep from "../create/project/steps/DPPStep"; import ImagesStep from "../create/project/steps/ImagesStep"; import ImportDesignStep from "../create/project/steps/ImportDesignStep"; import LicenseStep from "../create/project/steps/LicenseStep"; @@ -11,8 +10,10 @@ import LocationStep from "../create/project/steps/LocationStep"; import MachinesStep from "../create/project/steps/MachinesStep"; import MainStep from "../create/project/steps/MainStep"; import MaterialsStep from "../create/project/steps/MaterialsStep"; +import ModelFilesStep from "../create/project/steps/ModelFilesStep"; import ProductFiltersStep from "../create/project/steps/ProductFiltersStep"; import RelationsStep from "../create/project/steps/RelationsStep"; +import ServiceFiltersStep from "../create/project/steps/ServiceFiltersStep"; // @@ -62,6 +63,19 @@ export const projectSections: Array = [ required: [ProjectType.PRODUCT, ProjectType.SERVICE, ProjectType.DESIGN, ProjectType.MACHINE], editPage: "edit/images", }, + { + navLabel: "3D files", + id: "modelFiles", + component: , + for: [ProjectType.DESIGN], + editPage: "edit/model", + }, + { + navLabel: "Service details", + id: "serviceFilters", + component: , + for: [ProjectType.SERVICE], + }, { navLabel: "Location", id: "location", @@ -113,13 +127,6 @@ export const projectSections: Array = [ for: [ProjectType.DESIGN, ProjectType.PRODUCT, ProjectType.SERVICE, ProjectType.MACHINE], editPage: "edit/relations", }, - { - navLabel: "DPP", - id: "dpp", - component: , - required: [ProjectType.PRODUCT], - for: [ProjectType.PRODUCT], - }, { navLabel: "Machines", id: "machines", diff --git a/components/partials/topbar/Topbar.tsx b/components/partials/topbar/Topbar.tsx index 48e21a71..c488cd56 100644 --- a/components/partials/topbar/Topbar.tsx +++ b/components/partials/topbar/Topbar.tsx @@ -14,13 +14,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import LocationMenu from "components/LocationMenu"; -import SearchBar from "components/SearchBar"; +import BrUserAvatar from "components/brickroom/BrUserAvatar"; +import InterfacerLogo from "components/InterfacerLogo"; +import NavigationMenu from "components/NavigationMenu"; +import UserDropdown from "components/UserDropdown"; import { useAuth } from "hooks/useAuth"; +import useInBox from "hooks/useInBox"; import { useTranslation } from "next-i18next"; +import Link from "next/link"; import { useRouter } from "next/router"; -import TopbarNotifications from "./TopbarNotifications"; -import TopbarUser from "./TopbarUser"; +import { useCallback, useEffect, useState } from "react"; type topbarProps = { userMenu?: boolean; @@ -30,65 +33,246 @@ type topbarProps = { burger?: boolean; }; -function Topbar({ search = true, children, userMenu = true, cta, burger = true }: topbarProps) { +function Topbar({ search = true, userMenu = true, cta, burger = true }: topbarProps) { const router = useRouter(); const path = router.asPath; const { user } = useAuth(); const { t } = useTranslation("common"); + const { unread } = useInBox(); const isSignup = path === "/sign_up"; const isSignin = path === "/sign_in"; + const [menuOpen, setMenuOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + + // Close menu/dropdown on route change + useEffect(() => { + const handleRouteChange = () => { + setMenuOpen(false); + setDropdownOpen(false); + }; + router.events.on("routeChangeComplete", handleRouteChange); + return () => router.events.off("routeChangeComplete", handleRouteChange); + }, [router.events]); + + const [searchString, setSearchString] = useState(""); + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && searchString.trim()) { + router.push(`/search?q=${encodeURIComponent(searchString.trim())}`); + } + }, + [searchString, router] + ); + + // Active nav link detection — exact catalog matching, no fallback + const isDesigns = path === "/designs" || path.startsWith("/designs/"); + const isProducts = path === "/products" || path.startsWith("/products/"); + const isServices = path === "/services" || path.startsWith("/services/"); + return ( -
-
- {children} - {burger && ( -
+ + {/* Right section */} +
+ {cta} + {/* Sign-in / Sign-up buttons for unauthenticated users */} + {!user && !isSignin && !isSignup && ( +
+ + +
+ )} + {(isSignup || isSignin) && ( +
+ + +
+ )} + + {/* User avatar with notification dot */} + {user && userMenu && ( +
+ + + {dropdownOpen && setDropdownOpen(false)} />} +
+ )} +
+ + + {/* Navigation drawer */} + setMenuOpen(false)} /> + ); } diff --git a/components/types/index.ts b/components/types/index.ts index cdda5613..fc65c820 100644 --- a/components/types/index.ts +++ b/components/types/index.ts @@ -15,6 +15,7 @@ export enum ProjectType { PRODUCT = "Product", SERVICE = "Service", MACHINE = "Machine", + DPP = "DPP", } export const projectTypes = Object.values(ProjectType); diff --git a/history/redesign-issues.md b/history/redesign-issues.md new file mode 100644 index 00000000..992fbbc0 --- /dev/null +++ b/history/redesign-issues.md @@ -0,0 +1,29 @@ +# Redesign Issues Reference + +## Dependency Graph + +``` +Epic 1 (60u) P0 ──► Epic 2 (0i2) P1 ──┐ + ──► Epic 3 (z7y) P1 ──► Epic 7 (ady) P2 + ──► Epic 4 (1ne) P1 ──┤ + ──► Epic 5 (zdx) P1 ──► Epic 8 (5pp) P2 + ──► Epic 6 (dwo) P2 ──┤ + └► Epic 9 (7gg) P3 +``` + +## Epics + +| ID | P | Title | Subtasks | +| ------------------ | --- | ------------------------------------------- | --------- | +| interfacer-gui-60u | 0 | Design System Foundation | .1-.5 (5) | +| interfacer-gui-0i2 | 1 | Navigation and Layout Redesign | .1-.3 (3) | +| interfacer-gui-z7y | 1 | Catalog Pages (Designs, Products, Services) | .1-.5 (5) | +| interfacer-gui-1ne | 1 | Detail Pages (Design, Product, Service) | .1-.4 (4) | +| interfacer-gui-zdx | 1 | Profile Page Redesign | .1-.8 (8) | +| interfacer-gui-dwo | 2 | Creation Forms Redesign | .1-.5 (5) | +| interfacer-gui-ady | 2 | Search and Filtering System | .1-.3 (3) | +| interfacer-gui-5pp | 2 | Interactive Features | .1-.5 (5) | +| interfacer-gui-7gg | 3 | Polish and Legacy Cleanup | .1-.6 (6) | + +Total: 9 epics + 44 subtasks = 53 new issues (73 total in bd) +Created: 2026-03-16 diff --git a/hooks/useProjectCRUD.ts b/hooks/useProjectCRUD.ts index 3a932d94..824b4bee 100644 --- a/hooks/useProjectCRUD.ts +++ b/hooks/useProjectCRUD.ts @@ -20,9 +20,11 @@ import { UPDATE_METADATA, } from "lib/QueryAndMutation"; import { arrayEquals, getNewElements } from "lib/arrayOperations"; +import useDppApi from "lib/dpp"; import { errorFormatter } from "lib/errorFormatter"; import { prepFilesForZenflows, uploadFiles } from "lib/fileUpload"; -import { derivedProductFilterTags, mergeTags, prefixedTag, removeTagsWithPrefixes, TAG_PREFIX } from "lib/tagging"; +import { uploadModelFilesToDpp } from "lib/projectModelFiles"; +import { derivedProductFilterTags, mergeTags, normalizeUserTagsForSave, prefixedTag, TAG_PREFIX } from "lib/tagging"; import { CreateLocationMutation, CreateLocationMutationVariables, @@ -50,6 +52,7 @@ import { useResourceSpecs } from "./useResourceSpecs"; export const useProjectCRUD = () => { const { user } = useAuth(); + const { uploadFile: uploadDppFile } = useDppApi(); const { sendMessage } = useInBox(); const { addIdeaPoints, addStrengthsPoints } = useWallet({}); const { t } = useTranslation(); @@ -79,6 +82,7 @@ export const useProjectCRUD = () => { [ProjectType.SERVICE]: specProjectService!.id, [ProjectType.PRODUCT]: specProjectProduct!.id, [ProjectType.MACHINE]: specMachine!.id, + [ProjectType.DPP]: specDpp!.id, } : undefined; @@ -223,7 +227,8 @@ export const useProjectCRUD = () => { const images: IFile[] = await prepFilesForZenflows(formData.images); devLog("info: images prepared", images); - const tags = formData.main.tags.length > 0 ? formData.main.tags : undefined; + const normalizedTags = normalizeUserTagsForSave(formData.main.tags); + const tags = normalizedTags.length > 0 ? normalizedTags : undefined; devLog("info: tags prepared", tags); const metadata = JSON.stringify({ @@ -309,6 +314,8 @@ export const useProjectCRUD = () => { //todo: This should be uncommented, seems broken with last zenroom version see lib/fileUpload.ts const images: IFile[] = await prepFilesForZenflows(formData.images); devLog("info: images prepared", images); + const models = await uploadModelFilesToDpp(formData.modelFiles || [], uploadDppFile); + devLog("info: models prepared", models); const machineTags = (formData.machines?.machineDetails || []) .map(m => prefixedTag(TAG_PREFIX.MACHINE, m.name)) @@ -341,18 +348,32 @@ export const useProjectCRUD = () => { const productFilterTags = derivedProductFilterTags(normalizedProductFilters); - const baseTags = removeTagsWithPrefixes(formData.main.tags, [ - TAG_PREFIX.CATEGORY, - TAG_PREFIX.POWER_COMPAT, - TAG_PREFIX.POWER_REQ, - TAG_PREFIX.REPLICABILITY, - TAG_PREFIX.RECYCLABILITY, - TAG_PREFIX.REPAIRABILITY, - TAG_PREFIX.ENV_ENERGY, - TAG_PREFIX.ENV_CO2, - ]); - - const merged = mergeTags(baseTags, machineTags, materialTags, productFilterTags); + const serviceTypeTags = (formData.serviceFilters?.serviceType || []) + .map(s => prefixedTag(TAG_PREFIX.SERVICE_TYPE, s)) + .filter((t): t is string => Boolean(t)); + + const availabilityTags = (formData.serviceFilters?.availability || []) + .map(a => prefixedTag(TAG_PREFIX.AVAILABILITY, a)) + .filter((t): t is string => Boolean(t)); + + const licenseTags = (formData.licenses || []) + .map(l => prefixedTag(TAG_PREFIX.LICENSE, l.licenseId)) + .filter((t): t is string => Boolean(t)); + + // User-entered free-form tags from the form are normalized into the + // canonical `tag-` shape. System-derived tags (machines, materials, + // categories, ...) are appended separately below. + const baseTags = normalizeUserTagsForSave(formData.main.tags); + + const merged = mergeTags( + baseTags, + machineTags, + materialTags, + productFilterTags, + serviceTypeTags, + availabilityTags, + licenseTags + ); const tags = merged.length > 0 ? merged : undefined; devLog("info: tags prepared", tags); @@ -406,6 +427,7 @@ export const useProjectCRUD = () => { declarations: formData.declarations, remote: location?.remote, design: design, + models, machines: formData.machines?.machineDetails || [], materials: formData.materials?.materialDetails || [], productFilters: normalizedProductFilters, @@ -520,7 +542,7 @@ export const useProjectCRUD = () => { processId ); - await uploadImages(formData.images); + await uploadFiles(formData.images); } catch (e) { devLog(e); let err = errorFormatter(e); diff --git a/lib/QueryAndMutation.ts b/lib/QueryAndMutation.ts index cfb789c4..c09e6efb 100644 --- a/lib/QueryAndMutation.ts +++ b/lib/QueryAndMutation.ts @@ -470,8 +470,15 @@ export const FETCH_USER = gql` `; export const FETCH_RESOURCES = gql` - query FetchInventory($first: Int, $after: ID, $last: Int, $before: ID, $filter: EconomicResourceFilterParams) { - economicResources(first: $first, after: $after, before: $before, last: $last, filter: $filter) { + query FetchInventory( + $first: Int + $after: ID + $last: Int + $before: ID + $filter: EconomicResourceFilterParams + $orderBy: EconomicResourceSortInput + ) { + economicResources(first: $first, after: $after, before: $before, last: $last, filter: $filter, orderBy: $orderBy) { pageInfo { startCursor endCursor @@ -479,6 +486,7 @@ export const FETCH_RESOURCES = gql` hasNextPage totalCount pageLimit + distinctPrimaryAccountableCount } edges { cursor @@ -505,7 +513,6 @@ export const FETCH_RESOURCES = gql` hash name mimeType - bin } version licensor diff --git a/lib/dpp-pdf.ts b/lib/dpp-pdf.ts new file mode 100644 index 00000000..73274c1c --- /dev/null +++ b/lib/dpp-pdf.ts @@ -0,0 +1,284 @@ +import { jsPDF } from "jspdf"; +import autoTable from "jspdf-autotable"; +import type { DppDocument } from "./dpp-types"; + +// --- Helpers (mirrored from the page) --- + +function prettifyKey(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .replace(/^./, first => first.toUpperCase()); +} + +function getFieldValue(value: unknown): string | null { + if (value == null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + if (value.length === 0) return null; + return value.map(getFieldValue).filter(Boolean).join(", ") || null; + } + if (typeof value === "object") { + const tv = value as { value?: unknown; units?: unknown }; + if (Object.prototype.hasOwnProperty.call(tv, "value")) { + const scalar = getFieldValue(tv.value); + if (!scalar) return null; + const units = typeof tv.units === "string" ? tv.units : null; + return units ? `${scalar} ${units}` : scalar; + } + } + return null; +} + +function sectionFields(section: unknown): Array<{ label: string; value: string }> { + if (!section || typeof section !== "object" || Array.isArray(section)) return []; + return Object.entries(section) + .map(([key, raw]) => { + const value = getFieldValue(raw); + if (!value) return null; + return { label: prettifyKey(key), value }; + }) + .filter((f): f is { label: string; value: string } => f !== null); +} + +// --- Section configuration (same order & colors as UI) --- + +const sectionConfig: Array<{ key: keyof DppDocument; title: string; subtitle: string; color: string }> = [ + { + key: "productOverview", + title: "DPP Overview", + subtitle: "Basic product information and identification", + color: "#E87C1E", + }, + { + key: "complianceAndStandards", + title: "Compliance & Standards", + subtitle: "Regulatory compliance information", + color: "#2E7D32", + }, + { + key: "reparability", + title: "Reparability", + subtitle: "Repair instructions and spare parts availability", + color: "#1565C0", + }, + { + key: "environmentalImpact", + title: "Environmental Impact", + subtitle: "Resource consumption and emissions data", + color: "#558B2F", + }, + { + key: "certificates", + title: "Certificates", + subtitle: "Environmental and quality certifications", + color: "#6A1B9A", + }, + { + key: "recyclability", + title: "Recyclability", + subtitle: "Material composition and recycling information", + color: "#00838F", + }, + { + key: "energyUseAndEfficiency", + title: "Energy Use & Efficiency", + subtitle: "Battery and power specifications", + color: "#EF6C00", + }, + { + key: "economicOperator", + title: "Economic Operator", + subtitle: "Manufacturer and company information", + color: "#4E342E", + }, + { + key: "repairInformation", + title: "Information about the Repair", + subtitle: "Repair events and documentation", + color: "#1565C0", + }, + { + key: "refurbishmentInformation", + title: "Information about the Refurbishment", + subtitle: "Refurbishment events and processes", + color: "#00695C", + }, + { + key: "recyclingInformation", + title: "Information on the Recycling", + subtitle: "End-of-life recycling data", + color: "#37474F", + }, + { + key: "consumerInformation", + title: "Consumer Information", + subtitle: "Product usage and safety details", + color: "#AD1457", + }, + { + key: "dosageInstructions", + title: "Dosage Instructions", + subtitle: "Application and dosage guidance", + color: "#C62828", + }, + { key: "ingredients", title: "Ingredients", subtitle: "Ingredient and substance listing", color: "#283593" }, + { key: "packaging", title: "Packaging", subtitle: "Packaging materials and specifications", color: "#795548" }, +]; + +// --- Color helpers --- + +function hexToRgb(hex: string): [number, number, number] { + const n = parseInt(hex.replace("#", ""), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +function rgbAlpha(hex: string, alpha: number): [number, number, number] { + const [r, g, b] = hexToRgb(hex); + return [ + Math.round(r * alpha + 255 * (1 - alpha)), + Math.round(g * alpha + 255 * (1 - alpha)), + Math.round(b * alpha + 255 * (1 - alpha)), + ]; +} + +// --- PDF generation --- + +const BRAND = "#E87C1E"; +const PAGE_LEFT = 20; +const PAGE_RIGHT = 190; +const CONTENT_WIDTH = PAGE_RIGHT - PAGE_LEFT; + +export function generateDppPdf(dpp: DppDocument): void { + const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + + const productName = dpp.productOverview?.productName?.value || dpp.batchId || dpp.id; + const status = (dpp.status || "draft").charAt(0).toUpperCase() + (dpp.status || "draft").slice(1); + const createdAt = dpp.createdAt ? new Date(dpp.createdAt).toLocaleDateString() : "-"; + + let y = 20; + + // ── Header band ── + doc.setFillColor(...hexToRgb(BRAND)); + doc.rect(0, 0, 210, 38, "F"); + + doc.setTextColor(255, 255, 255); + doc.setFontSize(10); + doc.setFont("helvetica", "normal"); + doc.text("Digital Product Passport", PAGE_LEFT, 14); + + doc.setFontSize(20); + doc.setFont("helvetica", "bold"); + doc.text(String(productName), PAGE_LEFT, 26); + + doc.setFontSize(9); + doc.setFont("helvetica", "normal"); + const statusText = `Status: ${status}`; + const dateText = `Published: ${createdAt}`; + const idText = `ID: ${dpp.id}`; + doc.text([statusText, dateText, idText].join(" • "), PAGE_LEFT, 34); + + y = 46; + + // Batch row + if (dpp.batchId) { + doc.setTextColor(100, 100, 100); + doc.setFontSize(9); + doc.text(`Batch: ${dpp.batchId}`, PAGE_LEFT, y); + y += 6; + } + + // Product link + if (dpp.productId) { + doc.setTextColor(100, 100, 100); + doc.setFontSize(9); + doc.text(`Product: ${dpp.productId}`, PAGE_LEFT, y); + y += 6; + } + + y += 4; + + // ── Sections ── + const populatedSections = sectionConfig + .map(cfg => { + const fields = sectionFields(dpp[cfg.key]); + if (fields.length === 0) return null; + return { ...cfg, fields }; + }) + .filter(Boolean) as Array<(typeof sectionConfig)[number] & { fields: Array<{ label: string; value: string }> }>; + + for (const section of populatedSections) { + // Check if we have room for at least the section header + a few rows + if (y > 255) { + doc.addPage(); + y = 20; + } + + // Section header bar + const [bgR, bgG, bgB] = rgbAlpha(section.color, 0.1); + doc.setFillColor(bgR, bgG, bgB); + doc.roundedRect(PAGE_LEFT, y, CONTENT_WIDTH, 12, 2, 2, "F"); + + // Colored dot + doc.setFillColor(...hexToRgb(section.color)); + doc.circle(PAGE_LEFT + 6, y + 6, 2.5, "F"); + + // Section title + doc.setTextColor(...hexToRgb(section.color)); + doc.setFontSize(11); + doc.setFont("helvetica", "bold"); + doc.text(section.title, PAGE_LEFT + 12, y + 5.5); + + // Section subtitle + doc.setTextColor(120, 120, 120); + doc.setFontSize(7.5); + doc.setFont("helvetica", "normal"); + doc.text(section.subtitle, PAGE_LEFT + 12, y + 10); + + y += 16; + + // Field table + autoTable(doc, { + startY: y, + margin: { left: PAGE_LEFT, right: 210 - PAGE_RIGHT }, + head: [["Field", "Value"]], + body: section.fields.map(f => [f.label, f.value]), + theme: "grid", + styles: { + fontSize: 8.5, + cellPadding: 3, + lineColor: [220, 220, 220], + lineWidth: 0.3, + textColor: [50, 50, 50], + }, + headStyles: { + fillColor: hexToRgb(section.color), + textColor: [255, 255, 255], + fontStyle: "bold", + fontSize: 8.5, + }, + alternateRowStyles: { + fillColor: [248, 248, 248], + }, + columnStyles: { + 0: { cellWidth: 60, fontStyle: "bold", textColor: [80, 80, 80] }, + }, + }); + + // Advance y past the table + y = (doc as any).lastAutoTable.finalY + 10; + } + + // ── Footer on every page ── + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(7); + doc.setTextColor(160, 160, 160); + doc.text(`Digital Product Passport — ${String(productName)}`, PAGE_LEFT, 290); + doc.text(`Page ${i} of ${pageCount}`, PAGE_RIGHT, 290, { align: "right" }); + } + + // Trigger download + doc.save(`DPP-${dpp.id}.pdf`); +} diff --git a/lib/dpp-types.ts b/lib/dpp-types.ts new file mode 100644 index 00000000..005c2de2 --- /dev/null +++ b/lib/dpp-types.ts @@ -0,0 +1,232 @@ +/** + * TypeScript types matching the interfacer-dpp Go backend model. + * These represent the API document shapes (not form input shapes). + * + * @see ~/dyne/interfacer-dpp/internal/model/model.go + */ + +// --- Primitives --- + +export type TransformedValue = { + type: string; + value: T; + units?: string; +}; + +export type Attachment = { + id: string; + fileName: string; + contentType: string; + url: string; + size: number; + checksum: string; + uploadedAt: string; +}; + +// --- Forward-looking types (ie5.1) --- + +export type BatchType = "batch" | "unit"; +export type DppStatus = "active" | "draft" | "archived"; + +// --- DPP Sections --- + +export type ProductOverview = { + brandName?: TransformedValue; + productImage?: TransformedValue; + globalProductClassificationCode?: TransformedValue; + countryOfSale?: TransformedValue; + productDescription?: TransformedValue; + productName?: TransformedValue; + netWeight?: TransformedValue; + gtin?: TransformedValue; + color?: TransformedValue; + countryOfOrigin?: TransformedValue; + dimensions?: TransformedValue; + modelName?: TransformedValue; + taricCode?: TransformedValue; + conditionOfTheProduct?: TransformedValue; + netContent?: TransformedValue; + nominalMaximumRPM?: TransformedValue; + maximumDrillingDiameter?: TransformedValue; + numberOfGears?: TransformedValue; + torque?: TransformedValue; + warrantyDuration?: TransformedValue; + safetyInstructions?: TransformedValue; + consumerUnit?: TransformedValue; + netContentAndUnitOfMeasure?: TransformedValue; + yearOfSale?: TransformedValue; +}; + +export type Reparability = { + serviceAndRepairInstructions?: TransformedValue; + availabilityOfSpareParts?: TransformedValue; +}; + +export type EnvironmentalImpact = { + waterConsumptionPerUnit?: TransformedValue; + chemicalConsumptionPerUnit?: TransformedValue; + co2eEmissionsPerUnit?: TransformedValue; + energyConsumptionPerUnit?: TransformedValue; + cleaningPerformanceAtLowTemperature?: TransformedValue; + minimumContentOfMaterialWithSustainabilityCertification?: TransformedValue; +}; + +export type ComplianceAndStandards = { + ceMarking?: TransformedValue; + rohsCompliance?: TransformedValue; +}; + +export type Certificates = { + nameOfCertificate?: TransformedValue; +}; + +export type Recyclability = { + recyclingInstructions?: TransformedValue; + materialComposition?: TransformedValue; + substancesOfConcern?: TransformedValue; +}; + +export type EnergyUseAndEfficiency = { + batteryType?: TransformedValue; + batteryChargingTime?: TransformedValue; + batteryLife?: TransformedValue; + chargerType?: TransformedValue; + maximumElectricalPower?: TransformedValue; + maximumVoltage?: TransformedValue; + maximumCurrent?: TransformedValue; + powerRating?: TransformedValue; + dcVoltage?: TransformedValue; +}; + +export type ComponentInformation = { + componentDescription?: TransformedValue; + componentGTIN?: TransformedValue; + linkToDPP?: TransformedValue; +}; + +export type EconomicOperator = { + companyName?: TransformedValue; + gln?: TransformedValue; + eoriNumber?: TransformedValue; + addressLine1?: TransformedValue; + addressLine2?: TransformedValue; + contactInformation?: TransformedValue; +}; + +export type RepairInformation = { + reasonForRepair?: TransformedValue; + performedAction?: TransformedValue; + materialsUsed?: TransformedValue; + dateOfRepair?: TransformedValue; +}; + +export type RefurbishmentInformation = { + performedAction?: TransformedValue; + materialsUsed?: TransformedValue; + dateOfRefurbishment?: TransformedValue; +}; + +export type RecyclingInformation = { + performedAction?: TransformedValue; + dateOfRecycling?: TransformedValue; +}; + +export type ConsumerInformation = { + marketingClaim?: TransformedValue; +}; + +export type DosageInstructions = { + usageAndDisposalInfo?: TransformedValue; +}; + +export type Ingredients = { + ingredientList?: TransformedValue; + minimumContentOfBiodegradableSubstances?: TransformedValue; + presenceOfNonBiodegradableMicroplastics?: TransformedValue; +}; + +export type ChemicalConsumption = { + amount?: TransformedValue; + ingredient?: TransformedValue; +}; + +export type Packaging = { + chemicalConsumption?: ChemicalConsumption; + disposalInstructions?: TransformedValue; + minimumRecycledContent?: TransformedValue; + recyclablePackaging?: TransformedValue; +}; + +// --- Main Document --- + +export type DppDocument = { + id: string; + // Forward-looking fields (ie5.1 — not yet in backend) + productId?: string; + batchType?: BatchType; + batchId?: string; + createdBy?: string; + status?: DppStatus; + createdAt?: string; + updatedAt?: string; + // Sections + productOverview?: ProductOverview; + reparability?: Reparability; + environmentalImpact?: EnvironmentalImpact; + complianceAndStandards?: ComplianceAndStandards; + certificates?: Certificates; + recyclability?: Recyclability; + energyUseAndEfficiency?: EnergyUseAndEfficiency; + components?: ComponentInformation[]; + economicOperator?: EconomicOperator; + repairInformation?: RepairInformation; + refurbishmentInformation?: RefurbishmentInformation; + recyclingInformation?: RecyclingInformation; + consumerInformation?: ConsumerInformation; + dosageInstructions?: DosageInstructions; + ingredients?: Ingredients; + packaging?: Packaging; +}; + +// --- API Request/Response types --- + +export type CreateDppResponse = { + insertedID: string; +}; + +export type UpdateDppResponse = { + matchedCount: number; + modifiedCount: number; +}; + +export type DeleteDppResponse = { + deletedCount: number; +}; + +export type ListDppsFilters = { + productId?: string; + createdBy?: string; + status?: DppStatus; + q?: string; + sortBy?: "createdAt" | "name"; + sortOrder?: "asc" | "desc"; + limit?: number; + offset?: number; +}; + +export type StatusFacets = { + active: number; + draft: number; + archived: number; +}; + +export type ListDppsResponse = { + dpps: DppDocument[]; + total: number; + facets?: StatusFacets; +}; + +export type DppApiError = { + error: string; + details?: string; +}; diff --git a/lib/dpp.ts b/lib/dpp.ts new file mode 100644 index 00000000..577acf7e --- /dev/null +++ b/lib/dpp.ts @@ -0,0 +1,289 @@ +/** + * API client for the interfacer-dpp REST backend. + * Provides a React hook `useDppApi` with typed methods for all DPP operations. + * + * Auth: Signs requests with EdDSA keys via did-sign/did-pk headers + * (same pattern as useSignedPost.ts signDidRequest). + * + * @see ~/dyne/interfacer-dpp/cmd/main/main.go for route definitions + */ + +// @ts-ignore +import { useAuth } from "hooks/useAuth"; +import { useCallback, useMemo } from "react"; +// @ts-ignore +import sign from "zenflows-crypto/src/sign_graphql.zen"; +import type { + Attachment, + CreateDppResponse, + DeleteDppResponse, + DppApiError, + DppDocument, + DppStatus, + ListDppsFilters, + ListDppsResponse, + UpdateDppResponse, +} from "./dpp-types"; + +const DPP_BASE_URL = process.env.NEXT_PUBLIC_DPP_URL; + +class DppRequestError extends Error { + status: number; + details?: string; + + constructor(message: string, status: number, details?: string) { + super(message); + this.name = "DppRequestError"; + this.status = status; + this.details = details; + } +} + +export { DppRequestError }; + +const useDppApi = () => { + const { user } = useAuth(); + + const signBody = useCallback( + async (body: string): Promise<{ "did-sign": string; "did-pk": string }> => { + const zencode_exec = (await import("zenroom")).zencode_exec; + const eddsaKey = typeof window !== "undefined" ? window.localStorage.getItem("eddsaPrivateKey") || "" : ""; + const data = `{"gql": "${Buffer.from(body, "utf8").toString("base64")}"}`; + const keys = `{"keyring": {"eddsa": "${eddsaKey}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + return { + "did-sign": JSON.parse(result).eddsa_signature, + "did-pk": String(user?.publicKey), + }; + }, + [user?.publicKey] + ); + + const request = useCallback( + async (method: string, path: string, body?: any): Promise => { + const url = `${DPP_BASE_URL}${path}`; + const jsonBody = body != null ? JSON.stringify(body) : undefined; + + const headers: Record = {}; + + // Always send user ULID so backend can store/filter by it + if (user?.ulid) { + headers["x-user-id"] = user.ulid; + } + + if (jsonBody) { + const authHeaders = await signBody(jsonBody); + Object.assign(headers, authHeaders); + headers["Content-Type"] = "application/json"; + } + + const res = await fetch(url, { method, headers, body: jsonBody }); + + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch { + // response body may not be JSON + } + throw new DppRequestError(apiError.error, res.status, apiError.details); + } + + if (res.status === 204) return undefined as T; + return res.json(); + }, + [signBody, user?.ulid] + ); + + // --- CRUD --- + + const createDpp = useCallback( + async (data: Omit): Promise => { + return request("POST", "/dpp", data); + }, + [request] + ); + + const getDpp = useCallback( + async (id: string): Promise => { + return request("GET", `/dpp/${encodeURIComponent(id)}`); + }, + [request] + ); + + const updateDpp = useCallback( + async (id: string, data: Partial): Promise => { + return request("PUT", `/dpp/${encodeURIComponent(id)}`, data); + }, + [request] + ); + + const deleteDpp = useCallback( + async (id: string): Promise => { + return request("DELETE", `/dpp/${encodeURIComponent(id)}`); + }, + [request] + ); + + // --- List / Query --- + + const listDpps = useCallback( + async (filters?: ListDppsFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.productId) params.set("productId", filters.productId); + if (filters?.createdBy) params.set("createdBy", filters.createdBy); + if (filters?.status) params.set("status", filters.status); + if (filters?.q) params.set("q", filters.q); + if (filters?.sortBy) params.set("sortBy", filters.sortBy); + if (filters?.sortOrder) params.set("sortOrder", filters.sortOrder); + if (filters?.limit != null) params.set("limit", String(filters.limit)); + if (filters?.offset != null) params.set("offset", String(filters.offset)); + + const qs = params.toString(); + const path = qs ? `/dpps?${qs}` : "/dpps"; + + return request("GET", path); + }, + [request] + ); + + // --- File operations --- + + const uploadFile = useCallback( + async (file: File): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const checksum = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + + const zencode_exec = (await import("zenroom")).zencode_exec; + const eddsaKey = typeof window !== "undefined" ? window.localStorage.getItem("eddsaPrivateKey") || "" : ""; + const data = `{"gql": "${checksum}"}`; + const keys = `{"keyring": {"eddsa": "${eddsaKey}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + const signature = JSON.parse(result).eddsa_signature; + + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch(`${DPP_BASE_URL}/upload`, { + method: "POST", + headers: { + "did-pk": String(user?.publicKey), + "did-sign": signature, + }, + body: formData, + }); + + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch {} + throw new DppRequestError(apiError.error, res.status, apiError.details); + } + + return res.json(); + }, + [user?.publicKey] + ); + + const getFileUrl = useCallback((id: string): string => { + return `${DPP_BASE_URL}/file/${encodeURIComponent(id)}`; + }, []); + + const getQrCodeUrl = useCallback((dppId: string, size?: number): string => { + const params = size ? `?size=${size}` : ""; + return `${DPP_BASE_URL}/dpp/${encodeURIComponent(dppId)}/qr${params}`; + }, []); + + const updateDppStatus = useCallback( + async (id: string, status: DppStatus): Promise => { + return request("PUT", `/dpp/${encodeURIComponent(id)}/status`, { status }); + }, + [request] + ); + + const addAttachment = useCallback( + async (dppId: string, section: string, file: File): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const checksum = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + + const zencode_exec = (await import("zenroom")).zencode_exec; + const eddsaKey = typeof window !== "undefined" ? window.localStorage.getItem("eddsaPrivateKey") || "" : ""; + const data = `{"gql": "${checksum}"}`; + const keys = `{"keyring": {"eddsa": "${eddsaKey}"}}`; + const { result } = await zencode_exec(sign, { data, keys }); + const signature = JSON.parse(result).eddsa_signature; + + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch( + `${DPP_BASE_URL}/dpp/${encodeURIComponent(dppId)}/attachments?section=${encodeURIComponent(section)}`, + { + method: "POST", + headers: { + "did-pk": String(user?.publicKey), + "did-sign": signature, + }, + body: formData, + } + ); + + if (!res.ok) { + let apiError: DppApiError = { error: res.statusText }; + try { + apiError = await res.json(); + } catch {} + throw new DppRequestError(apiError.error, res.status, apiError.details); + } + + return res.json(); + }, + [user?.publicKey] + ); + + const deleteAttachment = useCallback( + async (dppId: string, attachmentId: string): Promise => { + return request( + "DELETE", + `/dpp/${encodeURIComponent(dppId)}/attachments/${encodeURIComponent(attachmentId)}` + ); + }, + [request] + ); + + return useMemo( + () => ({ + createDpp, + getDpp, + updateDpp, + deleteDpp, + listDpps, + uploadFile, + getFileUrl, + getQrCodeUrl, + updateDppStatus, + addAttachment, + deleteAttachment, + }), + [ + createDpp, + getDpp, + updateDpp, + deleteDpp, + listDpps, + uploadFile, + getFileUrl, + getQrCodeUrl, + updateDppStatus, + addAttachment, + deleteAttachment, + ] + ); +}; + +export default useDppApi; diff --git a/lib/fetchLocation.ts b/lib/fetchLocation.ts index 78dc08ca..d8324285 100644 --- a/lib/fetchLocation.ts +++ b/lib/fetchLocation.ts @@ -18,13 +18,137 @@ import { formatSelectOption, SelectOption } from "components/brickroom/utils/BrS // +interface NominatimAddress { + country?: string; + country_code?: string; + state?: string; +} + +interface NominatimItem { + place_id?: number; + osm_type?: "node" | "way" | "relation"; + osm_id?: number; + display_name?: string; + lat?: string; + lon?: string; + boundingbox?: [string, string, string, string]; + address?: NominatimAddress; +} + +function osmTypePrefix(osmType?: string): "N" | "W" | "R" | "" { + if (osmType === "node") return "N"; + if (osmType === "way") return "W"; + if (osmType === "relation") return "R"; + return ""; +} + +function toLocationId(item: NominatimItem): string { + const prefix = osmTypePrefix(item.osm_type); + if (prefix && item.osm_id) return `${prefix}${item.osm_id}`; + if (item.lat && item.lon) { + const title = encodeURIComponent(item.display_name || ""); + return `COORD:${item.lat},${item.lon}|${title}`; + } + return ""; +} + +function isValidOsmId(id: string): boolean { + return /^[NWR]\d+$/.test(id); +} + +function parseCoordId(id: string): { lat: number; lng: number; title: string } | null { + if (!id.startsWith("COORD:")) return null; + + const payload = id.slice(6); + const [coords, encodedTitle = ""] = payload.split("|"); + const [latStr, lngStr] = coords.split(","); + const lat = Number(latStr); + const lng = Number(lngStr); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + + return { + lat, + lng, + title: decodeURIComponent(encodedTitle || ""), + }; +} + +function mapSearchItem(item: NominatimItem): FetchLocation.Location { + const lat = Number(item.lat || 0); + const lng = Number(item.lon || 0); + + return { + title: item.display_name || "", + id: toLocationId(item), + language: "", + resultType: item.osm_type || "", + administrativeAreaType: "", + address: { + label: item.display_name || "", + countryCode: (item.address?.country_code || "").toUpperCase(), + countryName: item.address?.country || "", + }, + highlights: { + title: [], + address: { + label: [], + countryCode: [], + }, + }, + position: { + lat, + lng, + }, + }; +} + +function mapLookupItem(item: NominatimItem): LocationLookup.Location { + const lat = Number(item.lat || 0); + const lng = Number(item.lon || 0); + const bbox = item.boundingbox; + + return { + title: item.display_name || "", + id: toLocationId(item), + language: "", + resultType: item.osm_type || "", + administrativeAreaType: "", + address: { + label: item.display_name || "", + countryCode: (item.address?.country_code || "").toUpperCase(), + countryName: item.address?.country || "", + state: item.address?.state || "", + }, + position: { + lat, + lng, + }, + mapView: { + west: bbox ? Number(bbox[2]) : lng, + south: bbox ? Number(bbox[0]) : lat, + east: bbox ? Number(bbox[3]) : lng, + north: bbox ? Number(bbox[1]) : lat, + }, + }; +} + // Fetches the location from the API export async function fetchLocation(text: string): Promise> { if (!text) return []; - const result = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_AUTOCOMPLETE}?q=${encodeURI(text)}`); - const data = (await result.json()) as FetchLocation.Response; - return [...data.items]; + try { + const params = new URLSearchParams({ + q: text, + format: "jsonv2", + addressdetails: "1", + limit: "10", + }); + const result = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_AUTOCOMPLETE}?${params.toString()}`); + const data = (await result.json()) as Array; + return Array.isArray(data) ? data.map(mapSearchItem).filter(item => !!item.id) : []; + } catch { + return []; + } } // Loads the options for the async multiselect @@ -33,11 +157,51 @@ export async function getLocationOptions(text: string): Promise { +export async function lookupLocation(id: string): Promise { if (!id) throw new Error("NoLocationId"); - const response = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_LOOKUP}?id=${encodeURI(id)}`); - return await response.json(); + if (id.startsWith("here:")) return null; + const coord = parseCoordId(id); + if (coord) { + return { + title: coord.title, + id, + language: "", + resultType: "", + administrativeAreaType: "", + address: { + label: coord.title, + countryCode: "", + countryName: "", + state: "", + }, + position: { + lat: coord.lat, + lng: coord.lng, + }, + mapView: { + west: coord.lng, + south: coord.lat, + east: coord.lng, + north: coord.lat, + }, + }; + } + if (!isValidOsmId(id)) return null; + + try { + const params = new URLSearchParams({ + osm_ids: id, + format: "jsonv2", + addressdetails: "1", + }); + const response = await fetch(`${process.env.NEXT_PUBLIC_LOCATION_LOOKUP}?${params.toString()}`); + const data = (await response.json()) as Array; + if (!Array.isArray(data) || data.length === 0) return null; + return mapLookupItem(data[0]); + } catch { + return null; + } } // @@ -77,6 +241,10 @@ export namespace FetchLocation { administrativeAreaType: string; address: Address; highlights: Highlights; + position?: { + lat: number; + lng: number; + }; } export interface Response { diff --git a/lib/findProjectImages.ts b/lib/findProjectImages.ts index f1edfba6..bde92d39 100644 --- a/lib/findProjectImages.ts +++ b/lib/findProjectImages.ts @@ -15,7 +15,13 @@ const findProjectImages = (project: Partial): string[] => { }); const projectImages = project?.images?.length - ? project.images.filter(image => Boolean(image.bin)).map(image => `data:${image.mimeType};base64,${image.bin}`) + ? project.images + .filter(image => Boolean(image.bin) || Boolean(image.hash)) + .map(image => + image.bin + ? `data:${image.mimeType};base64,${image.bin}` + : `/api/image/${image.hash}?type=${encodeURIComponent(image.mimeType || "image/png")}` + ) : []; return projectImages.length > 0 ? projectImages : filteredMetadataImages; diff --git a/lib/findProjectModels.ts b/lib/findProjectModels.ts new file mode 100644 index 00000000..bdef72c8 --- /dev/null +++ b/lib/findProjectModels.ts @@ -0,0 +1,214 @@ +import { EconomicResource } from "./types"; + +type RawModelEntry = + | string + | { + url?: string; + href?: string; + src?: string; + downloadUrl?: string; + id?: string; + hash?: string; + name?: string; + fileName?: string; + mimeType?: string; + contentType?: string; + extension?: string; + size?: number; + storage?: string; + }; + +export type ProjectModelDescriptor = { + downloadUrl: string; + extension: string; + format: "step" | "stl" | "unknown"; + hash?: string; + id?: string; + isViewable: boolean; + mimeType?: string; + name: string; + size?: number; + url: string; +}; + +const supportedExtensions = new Set(["step", "stp", "stl"]); +const DPP_BASE_URL = process.env.NEXT_PUBLIC_DPP_URL; + +function isResolvableUrl(value: string): boolean { + if (!value) { + return false; + } + + if (value.startsWith("/")) { + return true; + } + + try { + new URL(value); + return true; + } catch { + return false; + } +} + +function getExtensionFromMimeType(mimeType?: string): string { + if (!mimeType) { + return ""; + } + + const normalizedMimeType = mimeType.toLowerCase(); + if (normalizedMimeType.includes("stl") || normalizedMimeType.includes("sla")) { + return "stl"; + } + if (normalizedMimeType.includes("step") || normalizedMimeType.includes("stp")) { + return "step"; + } + return ""; +} + +function getExtensionFromValue(value?: string): string { + if (!value) { + return ""; + } + + const match = value.toLowerCase().match(/\.([a-z0-9]+)(?:$|\?)/); + return match?.[1] || ""; +} + +function normalizeExtension(extension?: string, name?: string, url?: string, mimeType?: string): string { + const normalizedExtension = (extension || "").toLowerCase().replace(/^\./, ""); + return ( + normalizedExtension || + getExtensionFromValue(name) || + getExtensionFromValue(url) || + getExtensionFromMimeType(mimeType) + ); +} + +function formatFromExtension(extension: string): ProjectModelDescriptor["format"] { + if (extension === "stl") { + return "stl"; + } + if (extension === "step" || extension === "stp") { + return "step"; + } + return "unknown"; +} + +function inferName(url: string, fallbackExtension: string): string { + const fileName = url.split("/").pop()?.split("?")[0]; + if (fileName) { + return decodeURIComponent(fileName); + } + if (fallbackExtension) { + return `3d-model.${fallbackExtension}`; + } + return "3d-model"; +} + +function buildFileUrl(hash: string, mimeType?: string): string { + const mimeTypeQuery = mimeType ? `?type=${encodeURIComponent(mimeType)}` : ""; + return `/api/file/${encodeURIComponent(hash)}${mimeTypeQuery}`; +} + +function buildDppDownloadUrl(id: string, fallbackUrl: string): string { + if (!DPP_BASE_URL) { + return fallbackUrl; + } + + return `${DPP_BASE_URL}/file/${encodeURIComponent(id)}`; +} + +function buildDppPreviewUrl(id: string, fileName: string, mimeType?: string): string { + const mimeTypeQuery = mimeType ? `?type=${encodeURIComponent(mimeType)}` : ""; + return `/api/dpp-file/${encodeURIComponent(id)}/${encodeURIComponent(fileName)}${mimeTypeQuery}`; +} + +function normalizeEntry(entry: RawModelEntry): ProjectModelDescriptor | null { + if (typeof entry === "string") { + if (!isResolvableUrl(entry)) { + return null; + } + + const extension = normalizeExtension(undefined, undefined, entry, undefined); + return { + downloadUrl: entry, + extension, + format: formatFromExtension(extension), + isViewable: supportedExtensions.has(extension), + name: inferName(entry, extension), + url: entry, + }; + } + + const mimeType = entry.mimeType || entry.contentType; + const explicitUrl = entry.url || entry.href || entry.src || entry.downloadUrl; + const url = + explicitUrl && isResolvableUrl(explicitUrl) ? explicitUrl : entry.hash ? buildFileUrl(entry.hash, mimeType) : ""; + + if (!url) { + return null; + } + + const fileName = entry.name || entry.fileName; + const extension = normalizeExtension(entry.extension, fileName, explicitUrl || url, mimeType); + const name = fileName || inferName(explicitUrl || url, extension); + const isDppFile = entry.storage === "dpp" && Boolean(entry.id); + const resolvedUrl = isDppFile ? buildDppPreviewUrl(entry.id!, name, mimeType) : url; + const resolvedDownloadUrl = isDppFile + ? buildDppDownloadUrl(entry.id!, entry.downloadUrl || explicitUrl || url) + : entry.downloadUrl && isResolvableUrl(entry.downloadUrl) + ? entry.downloadUrl + : url; + + return { + downloadUrl: resolvedDownloadUrl, + extension, + format: formatFromExtension(extension), + hash: entry.hash, + id: entry.id, + isViewable: supportedExtensions.has(extension), + mimeType, + name, + size: entry.size, + url: resolvedUrl, + }; +} + +export function getRawMetadataModelEntries(metadata: Record): RawModelEntry[] { + const entries: RawModelEntry[] = []; + + if (typeof metadata.model === "string") { + entries.push(metadata.model); + } else if (metadata.model && typeof metadata.model === "object") { + entries.push(metadata.model as RawModelEntry); + } + + if (typeof metadata.modelUrl === "string") { + entries.push(metadata.modelUrl); + } + + if (Array.isArray(metadata.models)) { + entries.push(...(metadata.models as RawModelEntry[])); + } + + return entries; +} + +const findProjectModels = (project: Partial): ProjectModelDescriptor[] => { + const metadata = ((project.metadata || {}) as Record) || {}; + const resolvedEntries = getRawMetadataModelEntries(metadata) + .map(normalizeEntry) + .filter((entry): entry is ProjectModelDescriptor => Boolean(entry)); + + const seen = new Set(); + return resolvedEntries.filter(entry => { + if (seen.has(entry.url)) { + return false; + } + seen.add(entry.url); + return true; + }); +}; + +export default findProjectModels; diff --git a/lib/isProjectType.ts b/lib/isProjectType.ts index 1c69cb62..dc15a37a 100644 --- a/lib/isProjectType.ts +++ b/lib/isProjectType.ts @@ -6,5 +6,6 @@ export function isProjectType(name: string | ProjectType): Record Promise; +const DPP_BASE_URL = process.env.NEXT_PUBLIC_DPP_URL; + +function getExtension(fileName: string): string { + return fileName.split(".").pop()?.toLowerCase() || ""; +} + +export function dppAttachmentToProjectModel(attachment: Attachment): ProjectModelMetadata { + const url = DPP_BASE_URL ? `${DPP_BASE_URL}/file/${encodeURIComponent(attachment.id)}` : attachment.url; + + return { + contentType: attachment.contentType, + checksum: attachment.checksum, + downloadUrl: url, + extension: getExtension(attachment.fileName), + fileName: attachment.fileName, + id: attachment.id, + mimeType: attachment.contentType, + name: attachment.fileName, + size: attachment.size, + storage: "dpp", + uploadedAt: attachment.uploadedAt, + url, + }; +} + +export async function uploadModelFilesToDpp( + files: Array, + uploadModelFile: UploadModelFile +): Promise> { + const models: Array = []; + + for (const file of files) { + const attachment = await uploadModelFile(file); + models.push(dppAttachmentToProjectModel(attachment)); + } + + return models; +} diff --git a/lib/tagging.ts b/lib/tagging.ts index d983d426..e30037c8 100644 --- a/lib/tagging.ts +++ b/lib/tagging.ts @@ -11,6 +11,9 @@ export function slugifyTagValue(value: string): string { } export const TAG_PREFIX = { + // Dedicated prefix for free-form, user-entered tags. All other prefixes are + // system-derived metadata that happens to share the classifiedAs field. + USER: "tag", CATEGORY: "category", MACHINE: "machine", MATERIAL: "material", @@ -21,10 +24,45 @@ export const TAG_PREFIX = { REPAIRABILITY: "repairability", ENV_ENERGY: "env-energy", ENV_CO2: "env-co2", + SERVICE_TYPE: "servicetype", + AVAILABILITY: "availability", + LICENSE: "license", } as const; export type TagPrefix = (typeof TAG_PREFIX)[keyof typeof TAG_PREFIX]; +// All known system prefixes (everything except USER). +export const SYSTEM_TAG_PREFIXES: ReadonlyArray = [ + TAG_PREFIX.CATEGORY, + TAG_PREFIX.MACHINE, + TAG_PREFIX.MATERIAL, + TAG_PREFIX.POWER_COMPAT, + TAG_PREFIX.POWER_REQ, + TAG_PREFIX.REPLICABILITY, + TAG_PREFIX.RECYCLABILITY, + TAG_PREFIX.REPAIRABILITY, + TAG_PREFIX.ENV_ENERGY, + TAG_PREFIX.ENV_CO2, + TAG_PREFIX.SERVICE_TYPE, + TAG_PREFIX.AVAILABILITY, + TAG_PREFIX.LICENSE, +]; + +// Legacy/stale system prefixes that still appear in historical classifiedAs data. +// These must not leak into user-facing tag displays. +export const LEGACY_SYSTEM_TAG_PATTERNS: ReadonlyArray = [ + "power_compat-", + "power_", + "env_", + "mat:", + "c:", + "pc:", + "env:", + "pwr:", + "rep:", + "m:", +]; + // Shared option lists used across create flow + products filters. export const PRODUCT_CATEGORY_OPTIONS = [ "Electronics", @@ -49,6 +87,15 @@ export const POWER_COMPATIBILITY_OPTIONS = [ export const REPLICABILITY_OPTIONS = ["High", "Medium", "Low"] as const; +export const SERVICE_TYPE_OPTIONS = ["Fabrication", "Learning & Education", "Space Access"] as const; + +export const AVAILABILITY_OPTIONS = [ + "Available Now", + "Booking Required", + "Weekdays Only", + "Weekends Available", +] as const; + // Recyclability uses a numeric percentage (0–100) stored with monotonic range tags, // following the same pattern as energy consumption and CO2 emissions. export const RECYCLABILITY_THRESHOLDS_PCT = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] as const; @@ -187,3 +234,77 @@ export function mergeTags(...tagLists: Array | undefined>) return merged; } + +// ---------- User tag helpers ---------- +// +// User-entered free-form tags are stored in classifiedAs alongside system-derived +// tags (machine-*, category-*, etc.). To disambiguate them we prefix every new +// user tag with `tag-`. Display and filter code should go through these helpers +// so there is a single source of truth and no drifting per-component blocklists. + +// Build a canonical user tag from raw input. Returns undefined for empty values. +export function userTag(raw: string): string | undefined { + const slug = slugifyTagValue(raw); + if (!slug) return undefined; + return `${TAG_PREFIX.USER}-${slug}`; +} + +export function isUserTag(tag: string): boolean { + return tag.startsWith(`${TAG_PREFIX.USER}-`); +} + +export function stripUserTagPrefix(tag: string): string { + return isUserTag(tag) ? tag.substring(TAG_PREFIX.USER.length + 1) : tag; +} + +// A tag is "system" if it uses one of the known system prefixes (current or +// legacy). USER tags and legacy un-prefixed free-form tags are NOT system. +export function isSystemTag(tag: string): boolean { + if (isUserTag(tag)) return false; + if (SYSTEM_TAG_PREFIXES.some(p => tag.startsWith(`${p}-`))) return true; + if (LEGACY_SYSTEM_TAG_PATTERNS.some(p => tag.startsWith(p))) return true; + return false; +} + +// Extract the values a user should see as "tags" from a classifiedAs list: +// - entries that match TAG_PREFIX.USER (stripped of the prefix) +// - legacy un-prefixed free-form entries (kept visible for backwards compat) +// System-prefixed entries are filtered out. +export function extractUserTagValues(tags: ReadonlyArray | null | undefined): string[] { + if (!tags || tags.length === 0) return []; + const out: string[] = []; + const seen = new Set(); + for (const tag of tags) { + if (!tag) continue; + let value: string | undefined; + if (isUserTag(tag)) { + value = decodeURIComponent(stripUserTagPrefix(tag)); + } else if (!isSystemTag(tag)) { + value = decodeURIComponent(tag); + } + if (!value) continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +// Normalize raw user tag inputs (free-form strings, optionally already prefixed) +// into canonical `tag-` form. System-prefixed entries are dropped so they +// cannot accidentally ride in via the user-tag pipeline. +export function normalizeUserTagsForSave(tags: ReadonlyArray): string[] { + const out: string[] = []; + const seen = new Set(); + for (const raw of tags) { + const trimmed = raw?.trim(); + if (!trimmed) continue; + if (isSystemTag(trimmed)) continue; + const canonical = isUserTag(trimmed) ? trimmed : userTag(trimmed); + if (!canonical) continue; + if (seen.has(canonical)) continue; + seen.add(canonical); + out.push(canonical); + } + return out; +} diff --git a/lib/types/index.ts b/lib/types/index.ts index a8fb56bf..27d9b499 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -631,6 +631,9 @@ export type EconomicResourceFilterParams = { gtOnhandQuantityHasNumericalValue?: InputMaybe; id?: InputMaybe>; name?: InputMaybe; + nearDistanceKm?: InputMaybe; + nearLat?: InputMaybe; + nearLong?: InputMaybe; notCustodian?: InputMaybe>; notPrimaryAccountable?: InputMaybe>; note?: InputMaybe; @@ -647,6 +650,21 @@ export type EconomicResourceFilterParams = { repo?: InputMaybe; }; +export enum EconomicResourceSortField { + CreatedAt = "CREATED_AT", + Name = "NAME", +} + +export enum SortDirection { + Asc = "ASC", + Desc = "DESC", +} + +export type EconomicResourceSortInput = { + field: EconomicResourceSortField; + direction: SortDirection; +}; + export type EconomicResourceResponse = { __typename?: "EconomicResourceResponse"; economicResource: EconomicResource; @@ -4233,6 +4251,7 @@ export type FetchInventoryQueryVariables = Exact<{ last?: InputMaybe; before?: InputMaybe; filter?: InputMaybe; + orderBy?: InputMaybe; }>; export type FetchInventoryQuery = { @@ -4247,6 +4266,7 @@ export type FetchInventoryQuery = { hasNextPage: boolean; totalCount?: number | null; pageLimit?: number | null; + distinctPrimaryAccountableCount?: number | null; }; edges: Array<{ __typename?: "EconomicResourceEdge"; diff --git a/package.json b/package.json index 38339d46..7d192112 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "focus-trap-react": "^10.3.1", "graphql": "^15.10.1", "husky": "^8.0.3", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.7", "lint-staged": "^13.3.0", "mapbox-gl": "^2.15.0", "markdown-it": "^13.0.2", @@ -76,6 +78,7 @@ "next-i18next": "^12.1.0", "next-seo": "^6.8.0", "octokit": "^2.1.0", + "online-3d-viewer": "^0.18.0", "playwright": "1.34.0-alpha-may-11-2023", "postcss": "^8.5.6", "postcss-preset-env": "^7.8.3", diff --git a/pages/_app.tsx b/pages/_app.tsx index 7f4f1772..5abd61b3 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -14,8 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import "@fontsource/ibm-plex-sans"; -import "@fontsource/space-grotesk"; +import "@fontsource/ibm-plex-sans/400"; +import "@fontsource/ibm-plex-sans/500"; +import "@fontsource/ibm-plex-sans/600"; +import "@fontsource/ibm-plex-sans/700"; +import "@fontsource/space-grotesk/400"; +import "@fontsource/space-grotesk/500"; +import "@fontsource/space-grotesk/700"; import Layout from "components/layout/Layout"; import { AuthProvider } from "contexts/AuthContext"; @@ -51,7 +56,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { {getLayout()} - + ); } diff --git a/pages/api/dpp-file/[id]/[filename].ts b/pages/api/dpp-file/[id]/[filename].ts new file mode 100644 index 00000000..f3855395 --- /dev/null +++ b/pages/api/dpp-file/[id]/[filename].ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const DPP_BASE_URL = process.env.NEXT_PUBLIC_DPP_URL; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { id, type } = req.query; + + if (!id || typeof id !== "string") { + res.status(400).end("Missing file id"); + return; + } + + if (!DPP_BASE_URL) { + res.status(500).end("Missing DPP base URL"); + return; + } + + try { + const response = await fetch(`${DPP_BASE_URL}/file/${encodeURIComponent(id)}`); + if (!response.ok) { + res.status(response.status).end(); + return; + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const mimeType = + typeof type === "string" ? type : response.headers.get("content-type") || "application/octet-stream"; + + res.setHeader("Content-Type", mimeType); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.send(buffer); + } catch { + res.status(502).end("Failed to fetch DPP file"); + } +} diff --git a/pages/api/file/[hash].ts b/pages/api/file/[hash].ts new file mode 100644 index 00000000..5420f89e --- /dev/null +++ b/pages/api/file/[hash].ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const ZENFLOWS_FILE_URL = process.env.NEXT_PUBLIC_ZENFLOWS_FILE_URL; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { hash } = req.query; + if (!hash || typeof hash !== "string") { + res.status(400).end("Missing hash"); + return; + } + + try { + const response = await fetch(`${ZENFLOWS_FILE_URL}/${hash}`); + if (!response.ok) { + res.status(response.status).end(); + return; + } + + const base64 = await response.text(); + const buffer = Buffer.from(base64, "base64"); + + const mimeType = (req.query.type as string) || "application/octet-stream"; + res.setHeader("Content-Type", mimeType); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.send(buffer); + } catch { + res.status(502).end("Failed to fetch file"); + } +} diff --git a/pages/api/image/[hash].ts b/pages/api/image/[hash].ts new file mode 100644 index 00000000..323668b1 --- /dev/null +++ b/pages/api/image/[hash].ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const ZENFLOWS_FILE_URL = process.env.NEXT_PUBLIC_ZENFLOWS_FILE_URL; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { hash } = req.query; + if (!hash || typeof hash !== "string") { + res.status(400).end("Missing hash"); + return; + } + + try { + const response = await fetch(`${ZENFLOWS_FILE_URL}/${hash}`); + if (!response.ok) { + res.status(response.status).end(); + return; + } + + const base64 = await response.text(); + const buffer = Buffer.from(base64, "base64"); + + const mimeType = (req.query.type as string) || "image/png"; + res.setHeader("Content-Type", mimeType); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.send(buffer); + } catch { + res.status(502).end("Failed to fetch image"); + } +} diff --git a/pages/create/project/index.tsx b/pages/create/project/index.tsx index d5a17a1a..bb00b2fa 100644 --- a/pages/create/project/index.tsx +++ b/pages/create/project/index.tsx @@ -1,24 +1,7 @@ -import { useTranslation } from "next-i18next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { NextPageWithLayout } from "pages/_app"; -import { ReactElement, useState } from "react"; - -// Components -import { Banner, Card, Icon, Stack, Text } from "@bbtgnn/polaris-interfacer"; -import { ChevronRightMinor } from "@shopify/polaris-icons"; -import FullWidthBanner from "components/FullWidthBanner"; -import Layout from "components/layout/Layout"; -import PTitleSubtitle from "components/polaris/PTitleSubtitle"; -import ProjectTypeRoundIcon from "components/ProjectTypeRoundIcon"; - -// Icons -import { ProjectType } from "components/types"; import { useAuth } from "hooks/useAuth"; -import { useDrafts } from "hooks/useFormSaveDraft"; -import Link from "next/link"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { useRouter } from "next/router"; - -// +import { useEffect } from "react"; export async function getStaticProps({ locale }: any) { return { @@ -29,126 +12,19 @@ export async function getStaticProps({ locale }: any) { }; } -// - -const CreateProject: NextPageWithLayout = () => { - const { t } = useTranslation(["createProjectProps", "common"]); - const { hasDrafts } = useDrafts(); - const router = useRouter(); - const { draft_deleted, draft_saved } = router.query; +const CreateProject = () => { const { user } = useAuth(); - const [isOpenSavedBanner, setIsOpenSavedBanner] = useState(!!draft_saved); - const [isOpenDeletedBanner, setIsOpenDeletedBanner] = useState(!!draft_deleted); - - const sections: Array<{ title: string; description: string; url: string; projectType: ProjectType }> = [ - { - title: "Design", - description: t( - "Import your project repository. Share your open source hardware project documentation and collaborate on building it." - ), - url: "/create/project/design", - projectType: ProjectType.DESIGN, - }, - { - title: "Product", - description: t( - "Showcase your open source hardware product and connect with a global network of makers. Import your product details to our platform." - ), - url: "/create/project/product", - projectType: ProjectType.PRODUCT, - }, - { - title: "Service", - description: t( - "Offer your expertise, training courses, or equipment rentals on our platform, supporting the development and collaboration of open source hardware projects in the community." - ), - url: "/create/project/service", - projectType: ProjectType.SERVICE, - }, - { - title: "Machine", - description: t( - "Add a machine or equipment to the platform. Share details about fabrication tools, 3D printers, laser cutters, and other machines available in your maker space or lab." - ), - url: "/create/project/machine", - projectType: ProjectType.MACHINE, - }, - ]; - - return ( - <> - setIsOpenDeletedBanner(false)} status="info"> - - {t("Your draft project was deleted")} - - - setIsOpenSavedBanner(false)}> - - {t("Your project was saved as draft successfully")} - - -
- - - {t( - "Submit your new open source hardware project and ensure that all relevant information is included. This information will be used to identify your project and provide context to users who may be interested in it." - )} -
-
- {t("Need help? Read the User Guide to get started.")} - - {"[↗]"} - - - } - /> - - {hasDrafts && ( - to your drafts"), url: `/profile/${user?.ulid}?tab=2` }} - /> - )} - {sections.map(s => ( - - -
- -
- -
-
- -
-
- -
- ))} -
-
-
- - ); -}; + const router = useRouter(); -// + useEffect(() => { + if (user?.ulid) { + router.replace(`/profile/${user.ulid}`); + } else { + router.replace("/"); + } + }, [user, router]); -CreateProject.getLayout = function getLayout(page: ReactElement) { - return {page}; + return null; }; export default CreateProject; diff --git a/pages/designs.tsx b/pages/designs.tsx new file mode 100644 index 00000000..440c44f3 --- /dev/null +++ b/pages/designs.tsx @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; +import { useTranslation } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useCallback, useState } from "react"; +import useFilters from "../hooks/useFilters"; +import { NextPageWithLayout } from "./_app"; + +export async function getStaticProps({ locale }: any) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common"])), + }, + }; +} + +const Designs: NextPageWithLayout = () => { + const { t } = useTranslation("common"); + const { designId, specsLoading } = useFilters(); + const [totalCount, setTotalCount] = useState(null); + const [manufacturerCount, setManufacturerCount] = useState(null); + + const handleDataLoaded = useCallback( + ({ + totalCount, + distinctPrimaryAccountableCount, + }: { + totalCount: number; + distinctPrimaryAccountableCount: number; + loading: boolean; + }) => { + setTotalCount(totalCount); + setManufacturerCount(distinctPrimaryAccountableCount); + }, + [] + ); + + const filter = { + conformsTo: designId ? [designId] : undefined, + notCustodian: [process.env.NEXT_PUBLIC_LOSH_ID!], + }; + + return ( + + + + + ), + }} + searchPlaceholder={t("Search designs, makers, machines, materials...")} + filter={filter} + onDataLoaded={handleDataLoaded} + /> + ); +}; + +Designs.publicPage = true; + +export default Designs; diff --git a/pages/dpps/[id].tsx b/pages/dpps/[id].tsx new file mode 100644 index 00000000..ae683c0d --- /dev/null +++ b/pages/dpps/[id].tsx @@ -0,0 +1,645 @@ +import { + ArrowLeft, + Checkmark, + ChevronDown, + ChevronUp, + Download, + Launch, + OverflowMenuVertical, + Share, +} from "@carbon/icons-react"; +import Layout from "components/layout/Layout"; +import useDppApi, { DppRequestError } from "lib/dpp"; +import { generateDppPdf } from "lib/dpp-pdf"; +import type { DppDocument } from "lib/dpp-types"; +import type { GetStaticPaths } from "next"; +import { useTranslation } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { NextPageWithLayout } from "pages/_app"; +import { ReactElement, useEffect, useMemo, useRef, useState } from "react"; + +function prettifyKey(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .replace(/^./, first => first.toUpperCase()); +} + +function getFieldValue(value: unknown): string | null { + if (value == null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (Array.isArray(value)) { + if (value.length === 0) return null; + return value.map(getFieldValue).filter(Boolean).join(", ") || null; + } + + if (typeof value === "object") { + const maybeTransformed = value as { value?: unknown; units?: unknown }; + if (Object.prototype.hasOwnProperty.call(maybeTransformed, "value")) { + const scalar = getFieldValue(maybeTransformed.value); + if (!scalar) return null; + const units = typeof maybeTransformed.units === "string" ? maybeTransformed.units : null; + return units ? `${scalar} ${units}` : scalar; + } + } + + return null; +} + +function sectionFields(section: unknown): Array<{ label: string; value: string }> { + if (!section || typeof section !== "object" || Array.isArray(section)) return []; + + return Object.entries(section) + .map(([key, raw]) => { + const value = getFieldValue(raw); + if (!value) return null; + return { label: prettifyKey(key), value }; + }) + .filter((field): field is { label: string; value: string } => field !== null); +} + +const sectionConfig: Array<{ key: keyof DppDocument; title: string; subtitle: string; color: string }> = [ + { + key: "productOverview", + title: "DPP Overview", + subtitle: "Basic product information and identification", + color: "#E87C1E", + }, + { + key: "complianceAndStandards", + title: "Compliance & Standards", + subtitle: "Regulatory compliance information", + color: "#2E7D32", + }, + { + key: "reparability", + title: "Reparability", + subtitle: "Repair instructions and spare parts availability", + color: "#1565C0", + }, + { + key: "environmentalImpact", + title: "Environmental Impact", + subtitle: "Resource consumption and emissions data", + color: "#558B2F", + }, + { + key: "certificates", + title: "Certificates", + subtitle: "Environmental and quality certifications", + color: "#6A1B9A", + }, + { + key: "recyclability", + title: "Recyclability", + subtitle: "Material composition and recycling information", + color: "#00838F", + }, + { + key: "energyUseAndEfficiency", + title: "Energy Use & Efficiency", + subtitle: "Battery and power specifications", + color: "#EF6C00", + }, + { + key: "economicOperator", + title: "Economic Operator", + subtitle: "Manufacturer and company information", + color: "#4E342E", + }, + { + key: "repairInformation", + title: "Information about the Repair", + subtitle: "Repair events and documentation", + color: "#1565C0", + }, + { + key: "refurbishmentInformation", + title: "Information about the Refurbishment", + subtitle: "Refurbishment events and processes", + color: "#00695C", + }, + { + key: "recyclingInformation", + title: "Information on the Recycling", + subtitle: "End-of-life recycling data", + color: "#37474F", + }, + { + key: "consumerInformation", + title: "Consumer Information", + subtitle: "Product usage and safety details", + color: "#AD1457", + }, + { + key: "dosageInstructions", + title: "Dosage Instructions", + subtitle: "Application and dosage guidance", + color: "#C62828", + }, + { key: "ingredients", title: "Ingredients", subtitle: "Ingredient and substance listing", color: "#283593" }, + { key: "packaging", title: "Packaging", subtitle: "Packaging materials and specifications", color: "#795548" }, +]; + +const DppDetailPage: NextPageWithLayout = () => { + const { t } = useTranslation("common"); + const router = useRouter(); + const dppApi = useDppApi(); + const [dpp, setDpp] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const dppId = typeof router.query.id === "string" ? router.query.id : ""; + + useEffect(() => { + if (!router.isReady || !dppId) return; + + let cancelled = false; + setLoading(true); + setError(null); + + dppApi + .getDpp(dppId) + .then(doc => { + if (!cancelled) setDpp(doc); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof DppRequestError && err.status === 404) { + setDpp(null); + setError("not-found"); + return; + } + setError("generic"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [dppApi, dppId, router.isReady]); + + const dppTitle = dpp?.productOverview?.productName?.value || dpp?.batchId || dpp?.id || dppId; + const createdAt = dpp?.createdAt ? new Date(dpp.createdAt).toLocaleDateString() : "-"; + const parentProductLabel = dpp?.productId || t("Unknown product"); + const breadcrumbSeparator = "/"; + + const onShare = async () => { + const url = typeof window !== "undefined" ? window.location.href : ""; + if (typeof navigator !== "undefined" && navigator.share) { + await navigator.share({ title: dppTitle, text: t("Digital Product Passport"), url }); + return; + } + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(url); + } + }; + + const onDownloadJson = () => { + if (!dpp || typeof window === "undefined") return; + const blob = new Blob([JSON.stringify(dpp, null, 2)], { type: "application/json" }); + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = `${dpp.id}.json`; + anchor.click(); + URL.revokeObjectURL(href); + }; + + const [pdfGenerating, setPdfGenerating] = useState(false); + + const onDownloadPdf = async () => { + if (!dpp || typeof window === "undefined" || pdfGenerating) return; + setPdfGenerating(true); + try { + generateDppPdf(dpp); + } finally { + setPdfGenerating(false); + } + }; + + const sections = useMemo(() => { + if (!dpp) return []; + return sectionConfig + .map(config => { + const fields = sectionFields(dpp[config.key]); + if (fields.length === 0) return null; + return { key: String(config.key), title: config.title, subtitle: config.subtitle, color: config.color, fields }; + }) + .filter(Boolean) as Array<{ + key: string; + title: string; + subtitle: string; + color: string; + fields: Array<{ label: string; value: string }>; + }>; + }, [dpp]); + + const [dppInfoOpen, setDppInfoOpen] = useState(true); + const sectionRefs = useRef>({}); + + const scrollToSection = (key: string) => { + sectionRefs.current[key]?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + const dppIcon = ( + + + + ); + + return ( +
+
+ + + + {t("Back")} + + + + {loading && ( +
+ {t("Loading DPP details...")} +
+ )} + + {!loading && error === "not-found" && ( +
+

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

+

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

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

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

+

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

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

+ {dppTitle} +

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

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

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

+ {t(section.title)} +

+

+ {t(section.subtitle)} +

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

+ {t(field.label)} +

+

+ {field.value} +

+
+ ))} +
+
+ )) + )} +
+ + {/* Sidebar navigation (desktop only) */} + {sections.length > 0 && ( +
+
+ {sections.map(section => ( + + ))} +
+
+ )} +
+ + )} +
+
+ ); +}; + +export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => { + return { + paths: [], + fallback: "blocking", + }; +}; + +export async function getStaticProps({ locale }: { locale: string }) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common"])), + }, + }; +} + +DppDetailPage.publicPage = true; + +DppDetailPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default DppDetailPage; diff --git a/pages/dpps/new.tsx b/pages/dpps/new.tsx new file mode 100644 index 00000000..e0bd6dcb --- /dev/null +++ b/pages/dpps/new.tsx @@ -0,0 +1,25 @@ +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { NextPageWithLayout } from "pages/_app"; +import { ReactElement } from "react"; + +import Layout from "components/layout/Layout"; +import CreateDppForm from "components/partials/create/dpp/CreateDppForm"; + +export async function getStaticProps({ locale }: any) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common", "createProjectProps"])), + }, + }; +} + +const CreateDpp: NextPageWithLayout = () => { + return ; +}; + +CreateDpp.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default CreateDpp; diff --git a/pages/products.tsx b/pages/products.tsx index 11f5aa76..f2495faa 100644 --- a/pages/products.tsx +++ b/pages/products.tsx @@ -1,239 +1,67 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2022-2023 Dyne.org foundation . -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -import { gql, useQuery } from "@apollo/client"; +import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { useRouter } from "next/router"; -import React from "react"; - -// Components -import ProductsActiveFiltersBar from "components/ProductsActiveFiltersBar"; -import ProductsCategoriesBar from "components/ProductsCategoriesBar"; -import ProductsFilters from "components/ProductsFilters"; -import ProductsHeader from "components/ProductsHeader"; -import ProductsSearchBar from "components/ProductsSearchBar"; -import ProjectsCards from "components/ProjectsCards"; +import { useCallback, useState } from "react"; import useFilters from "../hooks/useFilters"; - -// - -const GET_PRODUCTS_STATS = gql` - query GetProductsStats { - economicResources(last: 1) { - pageInfo { - totalCount - } - } - } -`; - -// +import { NextPageWithLayout } from "./_app"; export async function getStaticProps({ locale }: any) { return { props: { + publicPage: true, ...(await serverSideTranslations(locale, ["common", "productsProps"])), }, }; } -// - -const Products = () => { - const { t } = useTranslation("productsProps"); - const router = useRouter(); - const { proposalFilter } = useFilters(); - const [resultsCount, setResultsCount] = React.useState(0); - const [resultsLoading, setResultsLoading] = React.useState(true); - const [showMobileFilters, setShowMobileFilters] = React.useState(false); - - // Fetch stats - const { data: statsData, loading: statsLoading } = useQuery(GET_PRODUCTS_STATS); - const totalProjects = statsData?.economicResources?.pageInfo?.totalCount || 0; - - // Sort and show controls - const sortBy = (router.query.sort as string) || "latest"; - const showFilter = (router.query.show as string) || "all"; - - const handleSortChange = (value: string) => { - const query = { ...router.query, sort: value }; - router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); - }; - - const handleShowChange = (value: string) => { - const query = { ...router.query }; - if (value === "all") { - delete query.show; - } else { - query.show = value; - } - router.push({ pathname: router.pathname, query }, undefined, { shallow: true }); - }; +const Products: NextPageWithLayout = () => { + const { t } = useTranslation("common"); + const { productId, specsLoading } = useFilters(); + const [totalCount, setTotalCount] = useState(null); + const [manufacturerCount, setManufacturerCount] = useState(null); + + const handleDataLoaded = useCallback( + ({ + totalCount, + distinctPrimaryAccountableCount, + }: { + totalCount: number; + distinctPrimaryAccountableCount: number; + loading: boolean; + }) => { + setTotalCount(totalCount); + setManufacturerCount(distinctPrimaryAccountableCount); + }, + [] + ); - const clearFilters = () => { - router.push({ pathname: router.pathname }, undefined, { shallow: true }); + const filter = { + conformsTo: productId ? [productId] : undefined, + notCustodian: [process.env.NEXT_PUBLIC_LOSH_ID!], }; - const hasActiveFilters = Object.keys(router.query).length > 0; - - // Custom empty state for filtered results - const filteredEmptyState = ( -
- - - -

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

-

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

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

{t("Loading...")}

- ) : ( -

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

- )} -
- - {/* Products Grid */} - { - setResultsCount(totalCount); - setResultsLoading(loading); - }} - /> -
-
-
+ + + + + ), + }} + searchPlaceholder={t("Search products, manufacturers, materials...")} + filter={filter} + onDataLoaded={handleDataLoaded} + /> ); }; diff --git a/pages/profile/[id]/index.tsx b/pages/profile/[id]/index.tsx index bdfdbd77..5fbb00b2 100644 --- a/pages/profile/[id]/index.tsx +++ b/pages/profile/[id]/index.tsx @@ -16,9 +16,7 @@ import FetchUserLayout from "components/layout/FetchUserLayout"; import Layout from "components/layout/Layout"; -import EditProfileBanner from "components/partials/profile/[id]/EditProfileBanner"; -import ProfileHeading from "components/partials/profile/[id]/ProfileHeading"; -import ProfileTabs from "components/partials/profile/[id]/ProfileTabs"; +import ProfilePageNew from "components/ProfilePageNew"; import type { GetStaticPaths } from "next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { NextPageWithLayout } from "pages/_app"; @@ -26,15 +24,7 @@ import { NextPageWithLayout } from "pages/_app"; // const Profile: NextPageWithLayout = () => { - return ( - <> - -
- - -
- - ); + return ; }; export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => { diff --git a/pages/project/[id]/edit/model.tsx b/pages/project/[id]/edit/model.tsx new file mode 100644 index 00000000..8e5b9d80 --- /dev/null +++ b/pages/project/[id]/edit/model.tsx @@ -0,0 +1,118 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { GetStaticPaths } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { NextPageWithLayout } from "pages/_app"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; + +import EditProjectLayout from "components/layout/EditProjectLayout"; +import FetchProjectLayout, { useProject } from "components/layout/FetchProjectLayout"; +import Layout from "components/layout/Layout"; +import ModelFilesStep, { + modelFilesStepSchema, + ModelFilesStepValues, +} from "components/partials/create/project/steps/ModelFilesStep"; +import EditFormLayout from "components/partials/project/edit/EditFormLayout"; +import { useProjectCRUD } from "hooks/useProjectCRUD"; +import useDppApi from "lib/dpp"; +import findProjectModels, { getRawMetadataModelEntries } from "lib/findProjectModels"; +import { uploadModelFilesToDpp } from "lib/projectModelFiles"; + +type ModelMetadataEntry = string | Record; + +interface EditModelFilesValues { + modelFiles: ModelFilesStepValues; +} + +const SEPARATOR = " @ "; + +function createToken(entry: ReturnType[number], index: number): string { + return entry.hash || entry.url || `${entry.name}-${index}`; +} + +const EditModelFiles: NextPageWithLayout = () => { + const { project } = useProject(); + const { updateMetadata } = useProjectCRUD(); + const { uploadFile: uploadDppFile } = useDppApi(); + + const metadata = ((project.metadata || {}) as Record) || {}; + const existingEntries = getRawMetadataModelEntries(metadata); + const resolvedModels = findProjectModels(project); + + const existingModels = resolvedModels.map((model, index) => { + const token = createToken(model, index); + + return { + entry: existingEntries[index] as ModelMetadataEntry, + file: new File([], `${model.name}${SEPARATOR}${token}`), + token, + }; + }); + + const defaultValues: EditModelFilesValues = { + modelFiles: existingModels.map(model => model.file), + }; + + const schema = yup.object({ + modelFiles: modelFilesStepSchema(), + }); + + const formMethods = useForm({ + mode: "all", + resolver: yupResolver(schema), + defaultValues, + }); + + function getExistingEntry(fileName: string): ModelMetadataEntry | undefined { + const fileNameParts = fileName.split(SEPARATOR); + const token = fileNameParts[fileNameParts.length - 1]; + return existingModels.find(model => model.token === token)?.entry; + } + + function isExistingEntry(fileName: string): boolean { + return Boolean(getExistingEntry(fileName)); + } + + async function onSubmit(values: EditModelFilesValues) { + const preservedEntries = values.modelFiles + .map(file => getExistingEntry(file.name)) + .filter((entry): entry is ModelMetadataEntry => Boolean(entry)); + + const newFiles = values.modelFiles.filter(file => !isExistingEntry(file.name)); + const newEntries = await uploadModelFilesToDpp(newFiles, uploadDppFile); + + await updateMetadata(project, { models: [...preservedEntries, ...newEntries] }); + } + + return ( + + + + ); +}; + +export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => { + return { + paths: [], + fallback: "blocking", + }; +}; + +export async function getStaticProps({ locale }: any) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common", "createProjectProps"])), + }, + }; +} + +EditModelFiles.getLayout = page => ( + + + {page} + + +); + +export default EditModelFiles; diff --git a/pages/project/[id]/index.tsx b/pages/project/[id]/index.tsx index 593aa12c..77dc107f 100644 --- a/pages/project/[id]/index.tsx +++ b/pages/project/[id]/index.tsx @@ -17,23 +17,15 @@ import { GetStaticPaths } from "next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { useRouter } from "next/router"; -import { createContext, Dispatch, ReactElement, SetStateAction, useContext, useEffect, useState } from "react"; +import { createContext, Dispatch, ReactElement, SetStateAction, useContext, useMemo, useState } from "react"; -// Components -import { Stack } from "@bbtgnn/polaris-interfacer"; - -// Icons -import BrThumbinailsGallery from "components/brickroom/BrThumbinailsGallery"; import FetchProjectLayout, { useProject } from "components/layout/FetchProjectLayout"; import Layout from "components/layout/Layout"; -import EditBanner from "components/partials/project/[id]/EditBanner"; -import ProjectHeader from "components/partials/project/[id]/ProjectHeader"; -import ProjectSidebar from "components/partials/project/[id]/ProjectSidebar"; -import ProjectTabs from "components/partials/project/[id]/ProjectTabs"; import SuccessBanner from "components/partials/project/[id]/SuccessBanner"; +import ProjectDetailNew from "components/ProjectDetailNew"; +import findProjectImages from "lib/findProjectImages"; import { useTranslation } from "next-i18next"; import { NextPageWithLayout } from "pages/_app"; -import findProjectImages from "lib/findProjectImages"; //opengraph import { NextSeo } from "next-seo"; @@ -50,21 +42,16 @@ const Project: NextPageWithLayout = () => { const router = useRouter(); const { t } = useTranslation("common"); const { id } = router.query; - const [images, setImages] = useState([]); const [selected, setSelected] = useState(0); const { project } = useProject(); + const images = useMemo(() => findProjectImages(project), [project]); // (Temp) Redirect if project is LOSH owned if (process.env.NEXT_PUBLIC_LOSH_ID == project?.primaryAccountable?.id) { router.push(`/resource/${id}`); } - useEffect(() => { - const _images = findProjectImages(project); - setImages(_images); - }, [project]); - if (!project) return null; return ( @@ -94,22 +81,7 @@ const Project: NextPageWithLayout = () => { /> {t("Project succesfully created!")} - -
-
- - - -
- -
- -
-
-
- -
-
+
); diff --git a/pages/resource/[id]/claim.tsx b/pages/resource/[id]/claim.tsx index 9aa63d19..97a9cd96 100644 --- a/pages/resource/[id]/claim.tsx +++ b/pages/resource/[id]/claim.tsx @@ -35,6 +35,7 @@ import devLog from "lib/devLog"; import { errorFormatter } from "lib/errorFormatter"; import { formSetValueOptions } from "lib/formSetValueOptions"; import { isRequired } from "lib/isFieldRequired"; +import { normalizeUserTagsForSave } from "lib/tagging"; import { TransferProjectMutationVariables } from "lib/types"; import { GetStaticPaths } from "next"; import { useTranslation } from "next-i18next"; @@ -77,7 +78,7 @@ const ClaimProject: NextPageWithLayout = () => { async function handleClaim(formData: ClaimProjectNS.FormValues) { try { - const tags = formData.tags; + const tags = normalizeUserTagsForSave(formData.tags); devLog("info: tags prepared", tags); const contributors = formData.contributors; devLog("info: contributors prepared", contributors); diff --git a/pages/search.tsx b/pages/search.tsx index fd9b00ca..b35c6a9c 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { Checkbox, Stack, Tabs, Button } from "@bbtgnn/polaris-interfacer"; +import { Button, Checkbox, Stack, Tabs } from "@bbtgnn/polaris-interfacer"; import { Cube, Events } from "@carbon/icons-react"; import ProjectsCards from "components/ProjectsCards"; // import ProjectsMaps from "components/ProjectsMaps"; @@ -22,10 +22,10 @@ import { useTranslation } from "next-i18next"; import { useRouter } from "next/router"; import { ReactElement, useState } from "react"; // import AgentsTable from "../components/AgentsTable"; +import dynamic from "next/dynamic"; import SearchBar from "../components/SearchBar"; import Layout from "../components/layout/SearchLayout"; import useFilters from "../hooks/useFilters"; -import dynamic from "next/dynamic"; import { NextPageWithLayout } from "./_app"; const ProjectsMaps = dynamic(() => import("../components/ProjectsMaps"), { ssr: false }); @@ -46,11 +46,9 @@ const Search: NextPageWithLayout = () => { orName: q?.toString(), ...(!checkedNotDescription && { orNote: q?.toString() }), }; - const projectsFilter = { - name: q?.toString(), - ...(!checkedNotDescription && { orNote: q?.toString() }), - }; - const filters = isNotEmptyObj(proposalFilter) ? { ...proposalFilter, ...projectsFilter } : projectsOrFilter; + const filters = isNotEmptyObj(proposalFilter) + ? { ...proposalFilter, ...(!checkedNotDescription && { orNote: q?.toString() }) } + : projectsOrFilter; // diff --git a/pages/services.tsx b/pages/services.tsx new file mode 100644 index 00000000..c000f915 --- /dev/null +++ b/pages/services.tsx @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022-2023 Dyne.org foundation . + +import CatalogLayout, { HeroStatCard } from "components/CatalogLayout"; +import { useTranslation } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useCallback, useState } from "react"; +import useFilters from "../hooks/useFilters"; +import { NextPageWithLayout } from "./_app"; + +export async function getStaticProps({ locale }: any) { + return { + props: { + publicPage: true, + ...(await serverSideTranslations(locale, ["common"])), + }, + }; +} + +const Services: NextPageWithLayout = () => { + const { t } = useTranslation("common"); + const { serviceId, specsLoading } = useFilters(); + const [totalCount, setTotalCount] = useState(null); + const [providerCount, setProviderCount] = useState(null); + + const handleDataLoaded = useCallback( + ({ + totalCount, + distinctPrimaryAccountableCount, + }: { + totalCount: number; + distinctPrimaryAccountableCount: number; + loading: boolean; + }) => { + setTotalCount(totalCount); + setProviderCount(distinctPrimaryAccountableCount); + }, + [] + ); + + const filter = { + conformsTo: serviceId ? [serviceId] : undefined, + notCustodian: [process.env.NEXT_PUBLIC_LOSH_ID!], + }; + + return ( + + + + + ), + }} + searchPlaceholder={t("Search services, providers, locations...")} + filter={filter} + onDataLoaded={handleDataLoaded} + /> + ); +}; + +Services.publicPage = true; + +export default Services; diff --git a/pages/sign_in.tsx b/pages/sign_in.tsx index 7020e7ec..938d8a2a 100644 --- a/pages/sign_in.tsx +++ b/pages/sign_in.tsx @@ -29,7 +29,7 @@ import type { NextPageWithLayout } from "./_app"; import keypairoomClientRecreateKeys from "zenflows-crypto/src/keypairoomClientRecreateKeys.zen"; // Layout -import NRULayout from "../components/layout/NRULayout"; +import Layout from "../components/layout/Layout"; // Components import { Button } from "@bbtgnn/polaris-interfacer"; @@ -250,7 +250,7 @@ const Sign_in: NextPageWithLayout = () => { // Sign_in.getLayout = function getLayout(page: ReactElement) { - return {page}; + return {page}; }; Sign_in.publicPage = true; export default Sign_in; diff --git a/pages/sign_up.tsx b/pages/sign_up.tsx index 2b1f7d9e..6cf084a3 100644 --- a/pages/sign_up.tsx +++ b/pages/sign_up.tsx @@ -26,7 +26,7 @@ import { ReactElement, useState } from "react"; import type { NextPageWithLayout } from "./_app"; // Layout -import NRULayout from "../components/layout/NRULayout"; +import Layout from "../components/layout/Layout"; // Partials import Passphrase from "components/partials/auth/Passphrase"; @@ -169,7 +169,7 @@ const SignUp: NextPageWithLayout = () => { // SignUp.getLayout = function getLayout(page: ReactElement) { - return {page}; + return {page}; }; SignUp.publicPage = true; export default SignUp; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddb52a3c..d268297f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,12 @@ importers: husky: specifier: ^8.0.3 version: 8.0.3 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 + jspdf-autotable: + specifier: ^5.0.7 + version: 5.0.7(jspdf@4.2.1) lint-staged: specifier: ^13.3.0 version: 13.3.0 @@ -167,6 +173,9 @@ importers: octokit: specifier: ^2.1.0 version: 2.1.0 + online-3d-viewer: + specifier: ^0.18.0 + version: 0.18.0 playwright: specifier: 1.34.0-alpha-may-11-2023 version: 1.34.0-alpha-may-11-2023 @@ -573,6 +582,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1361,6 +1374,9 @@ packages: '@sideway/pinpoint@2.0.0': resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + '@simonwep/pickr@1.9.0': + resolution: {integrity: sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==} + '@swc/helpers@0.4.3': resolution: {integrity: sha512-6JrF+fdUK2zbGpJIlN7G3v966PQjyx/dPt1T9km2wj+EUBqgrxCk3uX4Kct16MIm9gGxfKRcfax2hVf5jvlTzA==} @@ -1455,6 +1471,9 @@ packages: '@types/node@18.7.15': resolution: {integrity: sha512-XnjpaI8Bgc3eBag2Aw4t2Uj/49lLBSStHWfqKvIuXD7FIrZyMLWp8KuAFHAqxMZYTF9l08N1ctUn9YNybZJVmQ==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -1467,6 +1486,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@18.0.6': resolution: {integrity: sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==} @@ -1484,6 +1506,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1822,6 +1847,10 @@ packages: base45@2.0.1: resolution: {integrity: sha512-Cr2mmczMfOQSkt9OyzpUpUNWKCurLpYhCjkN+yPXicjEc47gkN9LbklR3c7dUrpcPonjoAyZp7bZQChRRg4G1Q==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1925,6 +1954,10 @@ packages: caniuse-lite@1.0.30001749: resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -2063,6 +2096,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.32.2: + resolution: {integrity: sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==} + core-js@3.45.1: resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} @@ -2107,6 +2143,9 @@ packages: peerDependencies: postcss: ^8.4 + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-prefers-color-scheme@6.0.3: resolution: {integrity: sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==} engines: {node: ^12 || ^14 || >=16} @@ -2335,6 +2374,9 @@ packages: dom7@4.0.6: resolution: {integrity: sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==} + dompurify@3.4.0: + resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -2617,6 +2659,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} @@ -2638,6 +2683,9 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -2926,6 +2974,10 @@ packages: html-void-elements@2.0.1: resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + http-proxy-agent@6.1.1: resolution: {integrity: sha512-JRCz+4Whs6yrrIoIlrH+ZTmhrRwtMnmOHsHn8GFEn9O2sVfSE+DAZ3oyyGIKF8tjJEeSJmP89j7aTjVsSqsU0g==} engines: {node: '>= 14'} @@ -3008,6 +3060,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + is-absolute@1.0.0: resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} engines: {node: '>=0.10.0'} @@ -3291,6 +3346,14 @@ packages: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} + jspdf-autotable@5.0.7: + resolution: {integrity: sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==} + peerDependencies: + jspdf: ^2 || ^3 || ^4 + + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3710,6 +3773,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanopop@2.3.0: + resolution: {integrity: sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==} + natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -3849,6 +3915,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + online-3d-viewer@0.18.0: + resolution: {integrity: sha512-y7ZlV/zkakNUyjqcXz6XecA7vXgLEUnaAey9tyx8o6/wcdV64RfjXAQOjGXGY2JOZoDi4Cg1ic9icSWMWAvRQA==} + optimism@0.18.1: resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} @@ -3891,6 +3960,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -3963,6 +4035,9 @@ packages: resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} hasBin: true + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4297,6 +4372,9 @@ packages: quickselect@2.0.0: resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + react-base16-styling@0.6.0: resolution: {integrity: sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==} @@ -4439,6 +4517,9 @@ packages: refractor@4.9.0: resolution: {integrity: sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -4548,6 +4629,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -4731,6 +4816,10 @@ packages: ssr-window@4.0.2: resolution: {integrity: sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + start-server-and-test@1.15.5: resolution: {integrity: sha512-o3EmkX0++GV+qsvIJ/OKWm3w91fD8uS/bPQVPrh/7loaxkpXSuAIHdnmN/P/regQK9eNAK76aBJcHt+OSTk+nA==} engines: {node: '>=6'} @@ -4848,6 +4937,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + swap-case@2.0.2: resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} @@ -4876,6 +4969,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -4886,6 +4982,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.176.0: + resolution: {integrity: sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -5120,6 +5219,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -5668,6 +5770,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -6731,6 +6835,11 @@ snapshots: '@sideway/pinpoint@2.0.0': {} + '@simonwep/pickr@1.9.0': + dependencies: + core-js: 3.32.2 + nanopop: 2.3.0 + '@swc/helpers@0.4.3': dependencies: tslib: 2.8.1 @@ -6817,6 +6926,8 @@ snapshots: '@types/node@18.7.15': {} + '@types/pako@2.0.4': {} + '@types/parse-json@4.0.2': {} '@types/parse5@6.0.3': {} @@ -6825,6 +6936,9 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/raf@3.4.3': + optional: true + '@types/react-dom@18.0.6': dependencies: '@types/react': 18.0.18 @@ -6843,6 +6957,9 @@ snapshots: '@types/semver@7.7.1': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/ws@8.18.1': @@ -7289,6 +7406,9 @@ snapshots: base45@2.0.1: {} + base64-arraybuffer@1.0.2: + optional: true + base64-js@1.5.1: {} base64url@3.0.1: {} @@ -7392,6 +7512,18 @@ snapshots: caniuse-lite@1.0.30001749: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.2 + '@types/raf': 3.4.3 + core-js: 3.45.1 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + capital-case@1.0.4: dependencies: no-case: 3.0.4 @@ -7539,6 +7671,8 @@ snapshots: convert-source-map@2.0.0: {} + core-js@3.32.2: {} + core-js@3.45.1: {} cosmiconfig-typescript-loader@4.4.0(@types/node@18.7.15)(cosmiconfig@7.1.0)(ts-node@10.9.2(@types/node@18.7.15)(typescript@5.9.3))(typescript@5.9.3): @@ -7587,6 +7721,11 @@ snapshots: postcss: 8.5.6 postcss-selector-parser: 6.1.2 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + css-prefers-color-scheme@6.0.3(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -7773,6 +7912,11 @@ snapshots: dependencies: ssr-window: 4.0.2 + dompurify@3.4.0: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -8209,6 +8353,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 @@ -8245,6 +8395,8 @@ snapshots: transitivePeerDependencies: - encoding + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -8604,6 +8756,12 @@ snapshots: html-void-elements@2.0.1: {} + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + http-proxy-agent@6.1.1: dependencies: agent-base: 7.1.4 @@ -8692,6 +8850,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@5.4.0: {} + is-absolute@1.0.0: dependencies: is-relative: 1.0.0 @@ -8971,6 +9131,21 @@ snapshots: ms: 2.1.3 semver: 7.7.3 + jspdf-autotable@5.0.7(jspdf@4.2.1): + dependencies: + jspdf: 4.2.1 + + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.2 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.45.1 + dompurify: 3.4.0 + html2canvas: 1.4.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -9557,6 +9732,8 @@ snapshots: nanoid@3.3.11: {} + nanopop@2.3.0: {} + natural-compare-lite@1.4.0: {} natural-compare@1.4.0: {} @@ -9722,6 +9899,12 @@ snapshots: dependencies: mimic-fn: 4.0.0 + online-3d-viewer@0.18.0: + dependencies: + '@simonwep/pickr': 1.9.0 + fflate: 0.8.2 + three: 0.176.0 + optimism@0.18.1: dependencies: '@wry/caches': 1.0.1 @@ -9780,6 +9963,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.1.0: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -9858,6 +10043,9 @@ snapshots: ieee754: 1.2.1 resolve-protobuf-schema: 2.1.0 + performance-now@2.1.0: + optional: true + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -10170,6 +10358,11 @@ snapshots: quickselect@2.0.0: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + react-base16-styling@0.6.0: dependencies: base16: 1.0.0 @@ -10371,6 +10564,9 @@ snapshots: hastscript: 7.2.0 parse-entities: 4.0.2 + regenerator-runtime@0.13.11: + optional: true + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -10522,6 +10718,9 @@ snapshots: rfdc@1.4.1: {} + rgbcolor@1.0.1: + optional: true + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -10725,6 +10924,9 @@ snapshots: ssr-window@4.0.2: {} + stackblur-canvas@2.7.0: + optional: true + start-server-and-test@1.15.5: dependencies: arg: 5.0.2 @@ -10867,6 +11069,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-pathdata@6.0.3: + optional: true + swap-case@2.0.2: dependencies: tslib: 2.8.1 @@ -10916,6 +11121,11 @@ snapshots: - tsx - yaml + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + text-table@0.2.0: {} thenify-all@1.6.0: @@ -10926,6 +11136,8 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.176.0: {} + through@2.3.8: {} tinyqueue@2.0.3: {} @@ -11174,6 +11386,11 @@ snapshots: util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + uuid@8.3.2: {} uvu@0.5.6: diff --git a/styles/globals.scss b/styles/globals.scss index a6af1dc4..81cd8b8f 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@import "./theme.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -26,6 +28,17 @@ html, body { padding: 0; margin: 0; + font-family: var(--ifr-font-body); + font-size: var(--ifr-fs-base); + line-height: 1.5; + color: var(--ifr-text-primary); +} + +button, +input, +select, +textarea { + font: inherit; } a { @@ -59,7 +72,8 @@ h4, p, .paragraph { - @apply text-sm; + font-size: var(--ifr-fs-base); + line-height: 1.5; } .logo { @@ -138,7 +152,6 @@ table { scrollbar-width: none; /* Firefox */ } - // layout .drawer { @apply grid overflow-hidden w-full; @@ -245,7 +258,6 @@ table { } } - .drawer.drawer-end > .drawer-toggle:checked ~ .drawer-content { @apply -translate-x-2; } @@ -344,5 +356,3 @@ table { width: 50%; justify-content: flex-end; } - - diff --git a/styles/theme.css b/styles/theme.css new file mode 100644 index 00000000..20d0998e --- /dev/null +++ b/styles/theme.css @@ -0,0 +1,145 @@ +/* + * Interfacer Design Tokens (--ifr-* custom properties) + * + * These tokens define the visual language shared between the prototype + * (DTEC 03/2026) and the production GUI. Components reference them + * via var(--ifr-*) or through the Tailwind utilities configured in + * tailwind.config.js. + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * Copyright (C) 2022-2023 Dyne.org foundation . + */ + +:root { + /* --- Fonts --- */ + --ifr-font-body: "IBM Plex Sans", sans-serif; + --ifr-font-heading: "Space Grotesk", sans-serif; + + /* --- Font sizes --- */ + --ifr-fs-2xs: 8px; + --ifr-fs-xs: 10px; + --ifr-fs-sm: 12px; + --ifr-fs-base: 14px; + --ifr-fs-md: 16px; + --ifr-fs-lg: 20px; + --ifr-fs-xl: 24px; + --ifr-fs-2xl: 30px; + + /* --- Font weights --- */ + --ifr-fw-regular: 400; + --ifr-fw-medium: 500; + --ifr-fw-semibold: 600; + --ifr-fw-bold: 700; + + /* --- Brand / text colors --- */ + --ifr-text-primary: #0b1324; + --ifr-text-secondary: #6c707c; + --ifr-text-muted: #6b7280; + --ifr-green: #036a53; + --ifr-green-hover: #025a46; + --ifr-green-dark: #014837; + --ifr-red: #c5281d; + --ifr-red-hover-bg: #fef5f5; + + /* --- Entity type colors --- */ + --ifr-type-product: #143bb5; + --ifr-type-product-border: #0b1324; + --ifr-type-product-hover: #0f2f96; + --ifr-type-product-bg: rgba(20, 59, 181, 0.1); + --ifr-type-service: #8200db; + --ifr-type-service-hover: #6e00b8; + --ifr-type-service-border: #570093; + --ifr-type-service-bg: rgba(130, 0, 219, 0.1); + --ifr-type-dpp: #eb7b35; + --ifr-type-dpp-border: #9e3c00; + --ifr-type-dpp-hover: #d46a28; + --ifr-type-dpp-bg: rgba(235, 123, 53, 0.1); + --ifr-type-location: #036a53; + --ifr-type-location-hover: #025a46; + + /* --- Status colors --- */ + --ifr-yellow: #f1bd4d; + --ifr-yellow-hover: #e5af3a; + --ifr-yellow-bg: #fff5ea; + --ifr-yellow-text: #916a00; + --ifr-green-accent: #5da091; + --ifr-green-bg: #f1f8f5; + --ifr-green-status-text: #008060; + + /* --- Active / selected state --- */ + --ifr-bg-active: var(--ifr-green-bg); + --ifr-text-active: var(--ifr-green); + + /* --- Stat icon colors --- */ + --ifr-stat-green-bg: rgba(3, 106, 83, 0.1); + --ifr-stat-green-border: rgba(3, 106, 83, 0.2); + --ifr-stat-yellow-bg: rgba(240, 177, 0, 0.3); + --ifr-stat-yellow-icon: #a65f00; + --ifr-stat-blue-bg: rgba(43, 127, 255, 0.1); + --ifr-stat-blue-icon: #1447e6; + + /* --- Surface / Background colors --- */ + --ifr-bg-page: #e9e9e8; + --ifr-bg-profile: #fafafa; + --ifr-bg-surface: #ffffff; + --ifr-bg-elevated: #fdfdfd; + --ifr-bg-hover: #f5f5f5; + --ifr-bg-input: #f3f3f5; + --ifr-bg-search: rgba(200, 212, 229, 0.15); + --ifr-bg-tag: rgba(200, 212, 229, 0.25); + --ifr-bg-hover-light: rgba(200, 212, 229, 0.1); + --ifr-bg-results: rgba(255, 255, 255, 0.6); + --ifr-bg-bookmark: rgba(255, 255, 255, 0.9); + --ifr-bg-avatar: rgba(3, 106, 83, 0.2); + --ifr-bg-quote: #f6f6f7; + + /* --- Border colors --- */ + --ifr-border: #c9cccf; + --ifr-border-light: #c4c4c4; + --ifr-border-avatar: #aaa69d; + --ifr-border-env: rgba(200, 212, 229, 0.5); + + /* --- Overlay / gradient --- */ + --ifr-overlay-dark: rgba(0, 0, 0, 0.3); + --ifr-gradient-dark: rgba(0, 0, 0, 0.6); + --ifr-placeholder: rgba(11, 19, 36, 0.5); + + /* --- Shadows --- */ + --ifr-shadow-avatar: 0px 1px 6px 0px rgba(164, 167, 172, 0.15); + --ifr-shadow-sm: 0px 1px 2px 0px rgba(0, 0, 0, 0.05); + --ifr-shadow-dropdown: 0px 10px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px -4px rgba(0, 0, 0, 0.1); + --ifr-shadow-toggle: 0px 1px 2px 0px rgba(0, 0, 0, 0.1); + + /* --- Border radius --- */ + --ifr-radius-sm: 4px; + --ifr-radius-md: 6px; + --ifr-radius-lg: 8px; + --ifr-radius-full: 9999px; + + /* --- Layout sizes --- */ + --ifr-topbar-height: 64px; + --ifr-sidebar-width: 288px; + --ifr-dropdown-width: 240px; + --ifr-avatar-size: 44px; + --ifr-avatar-profile-size: 88px; + --ifr-control-height: 36px; + --ifr-card-image-height: 240px; + --ifr-card-min-width: 300px; + --ifr-card-max-width: 360px; + --ifr-grid-gap: 24px; + --ifr-form-sidebar-width: 304px; + --ifr-nav-menu-width: 280px; + --ifr-nav-menu-divider: #e5a100; + + /* --- Form-specific --- */ + --ifr-bg-form-input: #fafbfb; + --ifr-border-form-input: #cacccf; + --ifr-bg-upload-btn: #fff5dd; + --ifr-icon-muted: #a4a7ac; + --ifr-section-icon-size: 52px; + --ifr-section-icon-bg: var(--ifr-green-bg); + + /* --- Toggle switch --- */ + --ifr-switch-off: #cbd5e1; + --ifr-switch-on: var(--ifr-green); +} diff --git a/tailwind.config.js b/tailwind.config.js index d3badfc0..3619080e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -46,9 +46,98 @@ module.exports = { display: ['"Space Grotesk"', "sans-serif"], sans: ['"IBM Plex Sans"', "sans-serif"], }, - colors: {...tokens.colors, - primary: "#02604B"}, + colors: { + ...tokens.colors, + primary: "#02604B", + /* Interfacer design tokens */ + ifr: { + "text-primary": "var(--ifr-text-primary)", + "text-secondary": "var(--ifr-text-secondary)", + "text-muted": "var(--ifr-text-muted)", + green: { + DEFAULT: "var(--ifr-green)", + hover: "var(--ifr-green-hover)", + dark: "var(--ifr-green-dark)", + accent: "var(--ifr-green-accent)", + bg: "var(--ifr-green-bg)", + "status-text": "var(--ifr-green-status-text)", + }, + red: { + DEFAULT: "var(--ifr-red)", + "hover-bg": "var(--ifr-red-hover-bg)", + }, + yellow: { + DEFAULT: "var(--ifr-yellow)", + hover: "var(--ifr-yellow-hover)", + bg: "var(--ifr-yellow-bg)", + text: "var(--ifr-yellow-text)", + }, + product: { + DEFAULT: "var(--ifr-type-product)", + hover: "var(--ifr-type-product-hover)", + border: "var(--ifr-type-product-border)", + bg: "var(--ifr-type-product-bg)", + }, + service: { + DEFAULT: "var(--ifr-type-service)", + hover: "var(--ifr-type-service-hover)", + border: "var(--ifr-type-service-border)", + bg: "var(--ifr-type-service-bg)", + }, + dpp: { + DEFAULT: "var(--ifr-type-dpp)", + hover: "var(--ifr-type-dpp-hover)", + border: "var(--ifr-type-dpp-border)", + bg: "var(--ifr-type-dpp-bg)", + }, + location: { + DEFAULT: "var(--ifr-type-location)", + hover: "var(--ifr-type-location-hover)", + }, + }, + }, + backgroundColor: { + ifr: { + page: "var(--ifr-bg-page)", + profile: "var(--ifr-bg-profile)", + surface: "var(--ifr-bg-surface)", + elevated: "var(--ifr-bg-elevated)", + hover: "var(--ifr-bg-hover)", + input: "var(--ifr-bg-input)", + search: "var(--ifr-bg-search)", + tag: "var(--ifr-bg-tag)", + "hover-light": "var(--ifr-bg-hover-light)", + results: "var(--ifr-bg-results)", + bookmark: "var(--ifr-bg-bookmark)", + avatar: "var(--ifr-bg-avatar)", + quote: "var(--ifr-bg-quote)", + active: "var(--ifr-bg-active)", + "form-input": "var(--ifr-bg-form-input)", + "upload-btn": "var(--ifr-bg-upload-btn)", + }, + }, + borderColor: { + ifr: { + DEFAULT: "var(--ifr-border)", + light: "var(--ifr-border-light)", + avatar: "var(--ifr-border-avatar)", + env: "var(--ifr-border-env)", + "form-input": "var(--ifr-border-form-input)", + }, + }, + boxShadow: { + "ifr-avatar": "var(--ifr-shadow-avatar)", + "ifr-sm": "var(--ifr-shadow-sm)", + "ifr-dropdown": "var(--ifr-shadow-dropdown)", + "ifr-toggle": "var(--ifr-shadow-toggle)", + }, + borderRadius: { + "ifr-sm": "var(--ifr-radius-sm)", + "ifr-md": "var(--ifr-radius-md)", + "ifr-lg": "var(--ifr-radius-lg)", + "ifr-full": "var(--ifr-radius-full)", + }, }, }, - plugins: [require("@tailwindcss/typography"), require("@tailwindcss/line-clamp")], + plugins: [require("@tailwindcss/typography"), require("@tailwindcss/line-clamp")], };