diff --git a/src/components/JourneyCards/JourneyCards.astro b/src/components/JourneyCards/JourneyCards.astro index 528b1c1f625..0d16686ecad 100644 --- a/src/components/JourneyCards/JourneyCards.astro +++ b/src/components/JourneyCards/JourneyCards.astro @@ -1,6 +1,6 @@ --- -import { Tag, Typography } from "@chainlink/blocks" -import { JourneyTabGrid } from "./JourneyTabGrid" +import { JourneyCardsDesktop } from "./JourneyCardsDesktop.tsx" +import { JourneyTabGrid } from "./JourneyTabGrid.tsx" const columns = [ { @@ -102,52 +102,11 @@ const tabs = columns.map((column) => ({ badge: item.badge, })), })) - -const rows = columns[0].items.map((_, rowIndex) => ({ - items: columns.map((col) => col.items[rowIndex]), -})) ---
- Start your Chainlink journey - -
-
- { - columns.map((column) => ( -
- - {column.title} - -
- )) - } -
- { - rows.map((row, i) => ( - - )) - } -
+
@@ -163,92 +122,6 @@ const rows = columns[0].items.map((_, rowIndex) => ({ display: block; } - .journey-rows { - display: grid; - gap: 0; - } - - .journey-row { - display: grid; - grid-template-columns: repeat(3, 1fr); - } - - .journey-row:not(.journey-row--header) > * { - border-left: 1px solid var(--border); - } - - .journey-card { - display: grid; - grid-template-rows: auto 1fr auto; - gap: var(--space-6x); - padding: var(--space-6x); - } - - .footer-tag { - text-transform: uppercase; - } - - .journey-card:hover { - background-color: var(--muted); - - .footer-tag { - background-color: var(--background) !important; - } - - .footer-icon { - opacity: 1; - } - } - - .card-content { - display: flex; - flex-direction: column; - gap: var(--space-2x); - } - - .card-content > :last-child { - min-height: 3em; - } - - .journey-footer { - display: flex; - align-items: center; - justify-content: space-between; - } - - .section-title { - font-size: 28px; - margin-bottom: var(--space-10x); - } - - .footer { - padding-top: var(--space-1x); - } - .footer-icon { - height: 12px; - width: 12px; - opacity: 0; - } - - .card-badge { - display: inline-flex; - align-items: center; - padding: var(--space-1x) var(--space-2x); - background-color: var(--gray-100); - border-radius: 4px; - font-size: 11px; - font-weight: 500; - text-transform: uppercase; - color: var(--gray-600); - letter-spacing: 0.5px; - align-self: flex-start; - } - - .column-header { - padding: var(--space-2x) var(--space-6x); - border-left: 3px solid var(--brand); - } - @media (min-width: 769px) { .desktop { display: block; @@ -257,21 +130,5 @@ const rows = columns[0].items.map((_, rowIndex) => ({ .mobile { display: none; } - - .column-title { - font-size: 22px; - line-height: 26px; - } - - .journey-card h4 { - font-size: 16px; - line-height: 22px; - } - } - - @media screen and (min-width: 62em) { - .section-title { - font-size: 32px; - } } diff --git a/src/components/JourneyCards/JourneyCardsDesktop.module.css b/src/components/JourneyCards/JourneyCardsDesktop.module.css new file mode 100644 index 00000000000..1270644aae2 --- /dev/null +++ b/src/components/JourneyCards/JourneyCardsDesktop.module.css @@ -0,0 +1,159 @@ +.container { + width: 100%; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-10x); + gap: var(--space-4x); + flex-wrap: wrap; +} + +.sectionTitle { + font-size: 28px; + margin: 0; +} + +.filterWrapper { + display: flex; + align-items: center; + gap: var(--space-2x); +} + +.journeyRows { + display: flex; + flex-direction: column; +} + +.journeyRow { + display: grid; + grid-template-columns: repeat(3, 1fr); +} + +.journeyCard { + gap: var(--space-6x); + padding: var(--space-6x); + display: block; + text-decoration: none; + color: inherit; + transition: background-color 0.2s ease; + border-left: 1px solid var(--border); +} + +.journeyCard:hover { + background-color: var(--muted); +} + +.journeyCard:hover .footerTag { + background-color: var(--background) !important; +} + +.journeyCard:hover .footerIcon { + opacity: 1; +} + +.cardContent { + display: flex; + flex-direction: column; + gap: var(--space-2x); + margin-bottom: var(--space-8x); +} + +.journeyFooter { + display: flex; + align-items: center; + justify-content: space-between; +} + +.footerTag { + text-transform: uppercase; +} + +.footerIcon { + height: 12px; + width: 12px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.columnHeader { + padding: var(--space-2x) var(--space-6x); + border-left: 3px solid var(--brand); +} + +.columnTitle { + font-size: 22px; + line-height: 26px; + margin: 0; +} + +.noResults { + padding: var(--space-10x); + text-align: center; +} + +.paginationControls { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: var(--space-2x); +} + +.paginationButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + min-width: 40px; + max-width: 40px; + min-height: 40px; + max-height: 40px; + padding: var(--space-4x, 14px); + border: 1px solid var(--border); + background-color: var(--background); + color: var(--foreground); + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; +} + +.paginationButton svg { + display: block; + flex-shrink: 0; +} + +.paginationButton:hover:not(:disabled) { + background-color: var(--muted); + border-color: var(--brand); + color: var(--brand); +} + +.paginationButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +@media screen and (min-width: 62em) { + .sectionTitle { + font-size: 32px; + } +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + } + + .filterWrapper { + width: 100%; + } + + .journeyRow { + grid-template-columns: 1fr; + } +} diff --git a/src/components/JourneyCards/JourneyCardsDesktop.tsx b/src/components/JourneyCards/JourneyCardsDesktop.tsx new file mode 100644 index 00000000000..6178e4177af --- /dev/null +++ b/src/components/JourneyCards/JourneyCardsDesktop.tsx @@ -0,0 +1,197 @@ +import { useState, useMemo, useEffect } from "react" +import { Typography, Tag } from "@chainlink/blocks" +import styles from "./JourneyCardsDesktop.module.css" +import { ProductFilterDropdown } from "./ProductFilterDropdown.tsx" +import { PaginationControls } from "./PaginationControls.tsx" + +export interface JourneyItem { + title: string + description: string + badge: string + href: string +} + +export interface JourneyColumn { + title: string + items: JourneyItem[] +} + +interface JourneyCardsDesktopProps { + columns: JourneyColumn[] +} + +// Product filter options +const PRODUCT_FILTERS = [ + { label: "All Products", value: "all" }, + { label: "Automation", value: "automation" }, + { label: "CCIP", value: "ccip" }, + { label: "CRE", value: "cre" }, + { label: "DataLink", value: "datalink" }, + { label: "Data Feeds", value: "data feeds" }, + { label: "Data Streams", value: "data streams" }, + { label: "DTA", value: "dta" }, + { label: "Functions", value: "functions" }, + { label: "VRF", value: "vrf" }, +] + +type ProductFilterValue = (typeof PRODUCT_FILTERS)[number]["value"] + +// Validate badge values against expected product types +const VALID_BADGE_VALUES = new Set([ + "automation", + "ccip", + "cre", + "datalink", + "data feeds", + "data streams", + "dta", + "functions", + "vrf", +]) + +function validateBadge(badge: string): boolean { + return VALID_BADGE_VALUES.has(badge) +} + +const ITEMS_PER_PAGE = 4 + +export const JourneyCardsDesktop = ({ columns }: JourneyCardsDesktopProps) => { + const [selectedFilters, setSelectedFilters] = useState(["all"]) + const [currentPage, setCurrentPage] = useState(0) + + // Filter columns based on selected products + const filteredColumns = useMemo(() => { + // If "all" is selected or no filters selected, show all items + if (selectedFilters.includes("all") || selectedFilters.length === 0) { + return columns + } + + return columns + .map((column) => ({ + ...column, + items: column.items.filter((item) => { + // Validate badge value + if (!validateBadge(item.badge)) { + console.warn(`Invalid badge value: ${item.badge}`) + return false + } + // Show item if it matches ANY of the selected filters (OR logic) + return selectedFilters.some((filter) => item.badge.toLowerCase() === filter.toLowerCase()) + }), + })) + .filter((column) => column.items.length > 0) // Hide columns with no matching cards + }, [columns, selectedFilters]) + + // Transform columns to rows (row-based layout) with pagination + const rows = useMemo(() => { + if (filteredColumns.length === 0) return [] + + const maxItems = Math.max(...filteredColumns.map((col) => col.items.length)) + const maxPage = Math.max(0, Math.ceil(maxItems / ITEMS_PER_PAGE) - 1) + + // Clamp currentPage to valid bounds to prevent flash of empty content + const validPage = Math.min(currentPage, maxPage) + + const startIndex = validPage * ITEMS_PER_PAGE + const endIndex = startIndex + ITEMS_PER_PAGE + + // Slice items from each column based on current page + const paginatedColumns = filteredColumns.map((col) => ({ + ...col, + items: col.items.slice(startIndex, endIndex), + })) + + const maxPaginatedItems = Math.max(...paginatedColumns.map((col) => col.items.length)) + return Array.from({ length: maxPaginatedItems }, (_, rowIndex) => ({ + id: `row-${rowIndex}`, + items: paginatedColumns.map((col) => col.items[rowIndex]).filter(Boolean), + })) + }, [filteredColumns, currentPage]) + + // Calculate total pages based on max items across all columns + const totalPages = useMemo(() => { + if (filteredColumns.length === 0) return 0 + const maxItems = Math.max(...filteredColumns.map((col) => col.items.length)) + return Math.ceil(maxItems / ITEMS_PER_PAGE) + }, [filteredColumns]) + + // Reset pagination when filters change + useEffect(() => { + setCurrentPage(0) + }, [filteredColumns]) + + const handlePreviousPage = () => { + setCurrentPage((prev) => Math.max(0, prev - 1)) + } + + const handleNextPage = () => { + setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1)) + } + + return ( +
+
+ + Start your Chainlink journey + +
+ +
+
+ + {filteredColumns.length > 0 ? ( +
+
+ {filteredColumns.map((column) => ( +
+ + {column.title} + +
+ ))} +
+ {rows.map((row) => ( + + ))} + + +
+ ) : ( +
+ + No journey cards match the selected filter. + +
+ )} +
+ ) +} diff --git a/src/components/JourneyCards/JourneyTabGrid.tsx b/src/components/JourneyCards/JourneyTabGrid.tsx index ab44a3b0fba..610380c3d8e 100644 --- a/src/components/JourneyCards/JourneyTabGrid.tsx +++ b/src/components/JourneyCards/JourneyTabGrid.tsx @@ -1,5 +1,7 @@ +import { useState, useMemo, useEffect } from "react" import styles from "./JourneyTabGrid.module.css" import { Tabs, TabsContent, TabsList, TabsTrigger, Typography, Tag } from "@chainlink/blocks" +import { ProductFilterDropdown } from "./ProductFilterDropdown.tsx" export interface JourneyItem { title: string @@ -18,9 +20,72 @@ interface JourneyTabGridProps { header: string } +// Product filter options +const PRODUCT_FILTERS = [ + { label: "All Products", value: "all" }, + { label: "Automation", value: "automation" }, + { label: "CCIP", value: "ccip" }, + { label: "CRE", value: "cre" }, + { label: "DataLink", value: "datalink" }, + { label: "Data Feeds", value: "data feeds" }, + { label: "Data Streams", value: "data streams" }, + { label: "DTA", value: "dta" }, + { label: "Functions", value: "functions" }, + { label: "VRF", value: "vrf" }, +] + +// Validate badge values against expected product types +const VALID_BADGE_VALUES = new Set([ + "automation", + "ccip", + "cre", + "datalink", + "data feeds", + "data streams", + "dta", + "functions", + "vrf", +]) + +function validateBadge(badge: string): boolean { + return VALID_BADGE_VALUES.has(badge) +} + export const JourneyTabGrid = ({ tabs, header }: JourneyTabGridProps) => { + const [selectedFilters, setSelectedFilters] = useState(["all"]) + const [activeTab, setActiveTab] = useState() + + // Filter tabs based on selected products + const filteredTabs = useMemo(() => { + // If "all" is selected or no filters selected, show all items + if (selectedFilters.includes("all") || selectedFilters.length === 0) { + return tabs + } + + return tabs + .map((tab) => ({ + ...tab, + items: tab.items.filter((item) => { + if (!item.badge) return false + // Validate badge value + if (!validateBadge(item.badge)) { + console.warn(`Invalid badge value: ${item.badge}`) + return false + } + // Show item if it matches ANY of the selected filters (OR logic) + return selectedFilters.some((filter) => item.badge!.toLowerCase() === filter.toLowerCase()) + }), + })) + .filter((tab) => tab.items.length > 0) // Hide tabs with no matching items + }, [tabs, selectedFilters]) + + // Reset activeTab when filteredTabs changes + useEffect(() => { + setActiveTab(filteredTabs[0]?.name) + }, [filteredTabs]) + return ( - +
{ > {header} - - {tabs.map((tab) => ( - -

{tab.name}

-
- ))} -
+
+ +
+ {filteredTabs.length > 0 && ( + + {filteredTabs.map((tab) => ( + +

{tab.name}

+
+ ))} +
+ )}
- {tabs.map((tab) => ( - -
- - - ))} + + )) + ) : ( +
+ + No journey cards match the selected filter. + +
+ )} ) } diff --git a/src/components/JourneyCards/PaginationControls.tsx b/src/components/JourneyCards/PaginationControls.tsx new file mode 100644 index 00000000000..80352198ca1 --- /dev/null +++ b/src/components/JourneyCards/PaginationControls.tsx @@ -0,0 +1,57 @@ +interface PaginationControlsProps { + currentPage: number + totalPages: number + onPrevious: () => void + onNext: () => void + containerClassName?: string + buttonClassName?: string +} + +export const PaginationControls = ({ + currentPage, + totalPages, + onPrevious, + onNext, + containerClassName, + buttonClassName, +}: PaginationControlsProps) => { + // Only render when there's more than one page + if (totalPages <= 1) { + return null + } + + return ( +
+ + +
+ ) +} diff --git a/src/components/JourneyCards/ProductFilterDropdown.module.css b/src/components/JourneyCards/ProductFilterDropdown.module.css new file mode 100644 index 00000000000..3a54d50c977 --- /dev/null +++ b/src/components/JourneyCards/ProductFilterDropdown.module.css @@ -0,0 +1,105 @@ +.dropdown { + position: relative; + width: 148px; +} + +.trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 10px 12px; + max-height: 40px; + background-color: var(--input); + border: 1px solid var(--input-border-active); + border-radius: var(--rounded-sm, 4px); + font-size: 14px; + font-weight: 500; + color: var(--foreground); + cursor: pointer; + transition: all 0.2s ease; +} + +.trigger:hover { + border-color: var(--brand); + background-color: var(--muted); +} + +.trigger:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1); +} + +.chevronIcon { + flex-shrink: 0; + transition: transform 0.2s ease; + color: var(--foreground); +} + +.chevronOpen { + transform: rotate(180deg); +} + +.menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background-color: var(--background); + border: 1px solid var(--border); + border-radius: var(--rounded-sm, 4px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 1000; + overflow-y: auto; + padding: 8px 0; + box-shadow: 0 16px 24px 0 rgba(0, 0, 0, 0.04); +} + +.checkboxLabel { + display: flex; + align-items: center; + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.15s ease; + gap: 8px; + user-select: none; + width: 100%; +} + +.checkboxLabel:hover { + background-color: var(--muted); +} + +.checkboxInput { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.customCheckbox { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: var(--rounded-sm, 4px); + border: 1px solid var(--input-border); + background: var(--input); + flex-shrink: 0; + transition: all 0.2s ease; +} + +.checkboxInput:checked + .customCheckbox { + background-color: var(--link); +} + +.checkboxLabel:hover .customCheckbox { + border-color: var(--link); +} + +@media (max-width: 768px) { + .dropdown { + width: 100%; + } +} diff --git a/src/components/JourneyCards/ProductFilterDropdown.tsx b/src/components/JourneyCards/ProductFilterDropdown.tsx new file mode 100644 index 00000000000..26ee7b34fe5 --- /dev/null +++ b/src/components/JourneyCards/ProductFilterDropdown.tsx @@ -0,0 +1,136 @@ +import { useState, useRef } from "react" +import styles from "./ProductFilterDropdown.module.css" +import { Typography } from "@chainlink/blocks" +import { useClickOutside } from "~/hooks/useClickOutside.tsx" + +export interface ProductFilterOption { + label: string + value: string +} + +interface ProductFilterDropdownProps { + selectedFilters: string[] + onFiltersChange: (filters: string[]) => void + options: ProductFilterOption[] +} + +export const ProductFilterDropdown = ({ selectedFilters, onFiltersChange, options }: ProductFilterDropdownProps) => { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + useClickOutside(dropdownRef, () => setIsOpen(false), { enabled: isOpen }) + + const handleCheckboxChange = (value: string) => { + if (value === "all") { + // When clicking "All Products" + if (selectedFilters.includes("all")) { + // If already checked, uncheck it and default to showing all + onFiltersChange(["all"]) + } else { + // If not checked, check it and clear all individual selections + onFiltersChange(["all"]) + } + } else { + // Handle individual product selection + if (selectedFilters.includes("all")) { + // If "All Products" is currently selected, uncheck it and select only this product + onFiltersChange([value]) + } else { + // "All Products" is not selected, toggle the individual product + if (selectedFilters.includes(value)) { + // Uncheck the item + const updated = selectedFilters.filter((f) => f !== value) + // If no products are selected, default to showing all + onFiltersChange(updated.length === 0 ? ["all"] : updated) + } else { + // Check the item (keep other individual selections) + onFiltersChange([...selectedFilters, value]) + } + } + } + } + + const isChecked = (value: string) => { + if (value === "all") { + return selectedFilters.includes("all") + } + // Individual products are only checked if explicitly in the filters array + // (not when "all" is selected) + return selectedFilters.includes(value) && !selectedFilters.includes("all") + } + + // Get the display text for the trigger button + const getTriggerText = () => { + // If "All Products" is selected or no filters selected + if (selectedFilters.includes("all") || selectedFilters.length === 0) { + return "All Products" + } + + // If exactly 1 product is selected + if (selectedFilters.length === 1) { + const selectedProduct = options.find((filter) => filter.value === selectedFilters[0]) + return selectedProduct?.label || "All Products" + } + + // If 2 or more products are selected + return "Multiple Products" + } + + return ( +
+ + + {isOpen && ( +
+ {options.map((filter) => ( + + ))} +
+ )} +
+ ) +}