Parks & Recreation
//v1
import * as React from "react"
import { addPropertyControls, ControlType, RenderTarget } from "framer"
// Constants for localStorage keys
const FILTER_STORAGE_KEY = "shopx_product_filters"
const SORT_STORAGE_KEY = "shopx_product_sort"
// Helper function to find 13-digit number
function findDeep13DigitNumber(obj: any): string | null {
if (typeof obj === "string") {
const match = obj.match(/\d{13}/)
if (match) return match[0]
return null
}
if (!obj || typeof obj !== "object") return null
for (const key in obj) {
if (Array.isArray(obj[key]) || typeof obj[key] === "object") {
const found = findDeep13DigitNumber(obj[key])
if (found) return found
} else if (typeof obj[key] === "string") {
const match = obj[key].match(/\d{13}/)
if (match) return match[0]
}
}
return null
}
export default function ProductSort(props) {
const { MarketSector } = props
const [sortedChildren, setSortedChildren] = React.useState(null)
const [products, setProducts] = React.useState([])
const [isTransitioning, setIsTransitioning] = React.useState(false)
const [isSettling, setIsSettling] = React.useState(false)
const transitionTimeoutRef = React.useRef(null)
const settlingTimeoutRef = React.useRef(null)
const lastUpdateRef = React.useRef(null)
const urlParams =
typeof window !== "undefined"
? new URLSearchParams(window.location.search)
: new URLSearchParams()
const [sortConfig, setSortConfig] = React.useState(() => {
const savedSort = urlParams.get("sortConfig")
return savedSort
? JSON.parse(savedSort)
: {
type: "relevancy",
sortBy: "relevancy",
sortDirection: null,
}
})
const [filters, setFilters] = React.useState(() => {
const savedFilters = urlParams.get("filters")
return savedFilters
? JSON.parse(savedFilters)
: {
"market sector": {
active: false,
values: [],
},
"building type": {
active: false,
values: [],
},
service: {
active: false,
values: [],
},
"on-sale": {
active: false,
value: true,
},
"in-stock": {
active: false,
value: true,
},
bundles: {
active: false,
value: true,
},
subscriptions: {
active: false,
value: true,
},
price: {
active: false,
ranges: [],
},
discount: {
active: false,
ranges: [],
},
variant: {
active: false,
values: [],
},
}
})
// Save filters to localStorage whenever they change
React.useEffect(() => {
try {
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(filters))
} catch (e) {
console.error("Error saving filters to localStorage:", e)
}
}, [filters])
// Save sort config to localStorage whenever it changes
React.useEffect(() => {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sortConfig))
} catch (e) {
console.error("Error saving sort config to localStorage:", e)
}
}, [sortConfig])
// Listen for products data
React.useEffect(() => {
const handleProductsReady = (e) => {
console.log("🚀 Full Product Data Structure:", {
source: "data__products-ready event",
sampleProduct: e.detail.products[0],
availableFields: e.detail.products[0]
? Object.keys(e.detail.products[0])
: [],
nodeFields: e.detail.products[0]?.node
? Object.keys(e.detail.products[0].node)
: [],
variants: e.detail.products[0]?.node?.variants?.edges?.[0]?.node
? Object.keys(
e.detail.products[0].node.variants.edges[0].node
)
: [],
rawData: e.detail.products[0]?.node,
})
if (Array.isArray(e.detail.products)) {
setProducts(e.detail.products)
}
}
// Initial check for existing products
if (window["shopXtools"]?.products) {
console.log("🚀 Full Product Data Structure:", {
source: "window.shopXtools",
sampleProduct: window["shopXtools"].products[0],
availableFields: window["shopXtools"].products[0]
? Object.keys(window["shopXtools"].products[0])
: [],
nodeFields: window["shopXtools"].products[0]?.node
? Object.keys(window["shopXtools"].products[0].node)
: [],
variants: window["shopXtools"].products[0]?.node?.variants
?.edges?.[0]?.node
? Object.keys(
window["shopXtools"].products[0].node.variants
.edges[0].node
)
: [],
rawData: window["shopXtools"].products[0]?.node,
})
if (Array.isArray(window["shopXtools"].products)) {
setProducts(window["shopXtools"].products)
}
}
// Add event listener
document.addEventListener("data__products-ready", handleProductsReady)
return () => {
document.removeEventListener(
"data__products-ready",
handleProductsReady
)
}
}, [])
// Listen for sort change events
React.useEffect(() => {
const handleSortChange = (e) => {
console.log("Sort changed:", e.detail)
setSortConfig(e.detail)
setSortedChildren(null) // Reset sorted children to trigger resort
}
document.addEventListener("product-sort-change", handleSortChange)
return () => {
document.removeEventListener(
"product-sort-change",
handleSortChange
)
}
}, [])
// Listen for filter change events
React.useEffect(() => {
const handleFilter = (e) => {
console.log("📥 Received filter event:", e.detail)
setFilters((prev) => {
const newFilters = { ...prev }
const { type, group, active, value, filterType } = e.detail
console.log("Filter update received:", {
type,
filterType,
group,
active,
value,
})
// If type is 'all', handle complete reset of a filter type
if (type === "all" && filterType) {
if (filterType === "price" || filterType === "discount") {
newFilters[filterType] = {
active: false,
ranges: [],
}
}
}
// Handle different filter types
switch (type) {
case "market sector":
case "building type":
case "service":
if (active) {
newFilters[type].values = [
...(newFilters[type].values || []),
value,
]
} else {
newFilters[type].values = newFilters[
type
].values.filter((v) => v !== value)
}
newFilters[type].active =
newFilters[type].values.length > 0
break
case "variant":
if (!newFilters[type].values) {
newFilters[type].values = []
}
console.log("Processing variant filter:", {
active,
group,
value,
currentValues: newFilters[type].values,
})
if (active) {
// Add the new variant filter
newFilters[type].values.push({
optionName: value.optionName,
optionValue: value.optionValue,
group: group || undefined,
})
} else {
// Remove the variant filter
if (group) {
// Remove all variants in this group
newFilters[type].values = newFilters[
type
].values.filter((v) => v.group !== group)
} else {
// Remove specific variant by matching name and value
newFilters[type].values = newFilters[
type
].values.filter((v) => {
const valueToMatch = value.value || value
return !(
v.optionName ===
valueToMatch.optionName &&
v.optionValue ===
valueToMatch.optionValue
)
})
}
}
newFilters[type].active =
newFilters[type].values.length > 0
console.log("Variant filter update result:", {
active,
group,
value,
newValues: newFilters[type].values,
isActive: newFilters[type].active,
})
break
case "price":
case "discount":
if (!newFilters[type].ranges) {
newFilters[type].ranges = []
}
console.log("Processing price/discount filter:", {
active,
group,
value,
currentRanges: newFilters[type].ranges,
})
if (active) {
newFilters[type].ranges.push(value)
} else {
// If deselecting a group or the value is false, remove all ranges
if (group || !value) {
console.log(`Removing all ranges for ${type}`)
newFilters[type].ranges = []
} else if (value && value.id) {
// Remove specific range by id if we have one
console.log(
`Removing range with id: ${value.id}`
)
newFilters[type].ranges = newFilters[
type
].ranges.filter((r) => r.id !== value.id)
}
}
// Update active state based on remaining ranges
newFilters[type].active =
newFilters[type].ranges.length > 0
console.log("Updated price/discount filter state:", {
remainingRanges: newFilters[type].ranges,
isActive: newFilters[type].active,
})
break
// Simple boolean filters
case "on-sale":
case "in-stock":
case "bundles":
case "subscriptions":
newFilters[type] = {
active,
value: true,
}
break
}
console.log("📤 Updated filters:", {
type,
active,
group,
newState: newFilters[type],
allFilters: newFilters,
})
return newFilters
})
// Force re-render of filtered items
setSortedChildren(null)
}
document.addEventListener("product-filter-change", handleFilter)
return () =>
document.removeEventListener("product-filter-change", handleFilter)
}, [])
// At the top of your component, add this debug log
React.useEffect(() => {
console.log("Products data structure:", {
totalProducts: products.length,
sampleProduct: products[0]?.node,
allFields: products[0]?.node ? Object.keys(products[0].node) : [],
})
}, [products])
// At the top of your component, add this debug log
React.useEffect(() => {
if (products.length > 0) {
console.log("🔍 Debugging Product Structure:", {
firstProduct: products[0]?.node,
hasSellingPlanGroups:
products[0]?.node?.sellingPlanGroups?.edges?.length > 0,
variants: products[0]?.node?.variants?.edges?.map((edge) => ({
title: edge.node.title,
hasSellingPlans:
edge.node.sellingPlanAllocations?.edges?.length > 0,
sellingPlans: edge.node.sellingPlanAllocations?.edges,
})),
})
}
}, [products])
// Helper to get product details
const getProductDetails = React.useCallback(
(productId) => {
const fullId = `gid://shopify/Product/${productId}`
const product = products.find(
({ node }) => node.id === fullId
)?.node
if (product) {
// Collections are now normalized to an array of collection objects
const collections = Array.isArray(product.collections)
? product.collections.map((c) => c.title)
: []
return {
id: product.id,
title: product.title || "",
price: product.priceRange?.minVariantPrice?.amount
? parseFloat(product.priceRange.minVariantPrice.amount)
: 0,
buildingType: product.buildingType || "",
tags: product.tags || [],
compareAtPrice: product.compareAtPriceRange?.minVariantPrice
?.amount
? parseFloat(
product.compareAtPriceRange.minVariantPrice.amount
)
: 0,
isOnSale: product.compareAtPriceRange?.minVariantPrice
?.amount
? parseFloat(
product.compareAtPriceRange.minVariantPrice.amount
) >
parseFloat(
product.priceRange?.minVariantPrice?.amount || "0"
)
: false,
collections,
options: product.options || [],
variants: product.variants?.edges || [],
sellingPlanGroups: product.sellingPlanGroups?.edges || [],
hasProductLevelPlans:
product.sellingPlanGroups?.edges?.length > 0,
hasVariantLevelPlans: product.variants?.edges?.some(
(edge) =>
edge.node.sellingPlanAllocations?.edges?.length > 0
),
}
}
return {
id: "",
title: "",
price: 0,
buildingType: "",
services: [],
compareAtPrice: 0,
isOnSale: false,
marketSector: [],
options: [],
variants: [],
sellingPlanGroups: [],
hasProductLevelPlans: false,
hasVariantLevelPlans: false,
}
},
[products]
)
// Enhanced helper function to handle transitions
const updateSortedChildrenWithTransition = (newChildren) => {
const now = Date.now()
// If we're getting rapid updates (within 100ms), delay the transition
if (lastUpdateRef.current && now - lastUpdateRef.current < 100) {
setIsSettling(true)
if (settlingTimeoutRef.current) {
clearTimeout(settlingTimeoutRef.current)
}
settlingTimeoutRef.current = setTimeout(() => {
setIsSettling(false)
performTransition(newChildren)
}, 150) // Wait for updates to settle
} else {
performTransition(newChildren)
}
lastUpdateRef.current = now
}
// Helper function to perform the actual transition
const performTransition = (newChildren) => {
if (transitionTimeoutRef.current) {
clearTimeout(transitionTimeoutRef.current)
}
setIsTransitioning(true)
transitionTimeoutRef.current = setTimeout(() => {
setSortedChildren(newChildren)
setTimeout(() => {
setIsTransitioning(false)
}, 200) // Fade in duration
}, 200) // Fade out duration
}
// Cleanup timeouts
React.useEffect(() => {
return () => {
if (transitionTimeoutRef.current) {
clearTimeout(transitionTimeoutRef.current)
}
if (settlingTimeoutRef.current) {
clearTimeout(settlingTimeoutRef.current)
}
}
}, [])
if (!MarketSector?.[0]) {
return <div style={{ height: "100%" }} />
}
const collectionInstance = MarketSector[0]
const emptyStateInstance = props.EmptyState?.[0]
const sizedInstance = React.cloneElement(MarketSectorInstance, {
style: {
...(collectionInstance.props?.style || {}),
width: "100%",
height: "100%",
},
})
if (RenderTarget.current() === RenderTarget.canvas) {
return sizedInstance
}
try {
console.log(
"Products data:",
products.map(({ node }) => ({
id: node.id,
title: node.title,
productType: node.productType,
// Log the entire node to see its structure
node: node,
}))
)
// First, let's add some debug logging to see the full product structure
console.log(
"Full products data:",
products.map(({ node }) => ({
id: node.id,
title: node.title,
type: node.buildingType, // see if this exists
product_type: node.building_type, // try alternative format
__typename: node.__typename, // might help identify the correct field
// Log all keys to see what's available
keys: Object.keys(node),
}))
)
return React.cloneElement(sizedInstance, {
...sizedInstance.props,
children: React.cloneElement(sizedInstance.props.children, {
...sizedInstance.props.children.props,
children: React.cloneElement(
sizedInstance.props.children.props.children,
{
...sizedInstance.props.children.props.children.props,
children: (marketsector) => {
const originalRender =
sizedInstance.props.children.props.children.props.children(
marketsector
)
if (!originalRender?.props?.children)
return originalRender
if (!sortedChildren && products.length > 0) {
const items = originalRender.props.children.map(
(child) => {
const item = marketsector.find(
(marketSectorItem) =>
marketSectorItem.id ===
child.key
)
const productId = item
? findDeep13DigitNumber(item)
: null
const details = productId
? getProductDetails(productId)
: {
title: "",
price: 0,
buildingType: "",
}
console.log("Processing item:", {
title: details.title,
price: details.price,
filters: filters["price-under"],
})
return {
child,
productId: productId || "0",
price: details.price,
title: details.title,
originalIndex:
originalRender.props.children.indexOf(
child
),
}
}
)
console.log(
"Before filtering:",
items.length,
"items"
)
// Apply filters
const filteredItems = items.filter((item) => {
const details = item.productId
? getProductDetails(item.productId)
: null
if (!details) return false
// Collection filter (OR within values)
if (
filters.marketsector.active &&
filters.marketSector.values.length > 0
) {
const matchesAnyCollection =
filters.marketSector.values.some(
(value) =>
details.marketSector?.some(
(marketsector) =>
marketsector
.toLowerCase()
.includes(
value.toLowerCase()
)
)
)
if (!matchesAnyCollection) return false
}
// Product Type filter (OR within values)
if (
filters["building-type"].active &&
filters["building-type"].values.length >
0
) {
const matchesAnyType = filters[
"building-type"
].values.some((value) =>
details.productType
?.toLowerCase()
.includes(value.toLowerCase())
)
if (!matchesAnyType) return false
}
// Product Tag filter (OR within values)
if (
filters["serivce"].active &&
filters["service"].values.length > 0
) {
const matchesAnyTag = filters[
"service"
].values.some((value) =>
details.tags?.some((tag) =>
tag
.toLowerCase()
.includes(
value.toLowerCase()
)
)
)
if (!matchesAnyTag) return false
}
// Price filter (OR within ranges)
if (
filters.price.active &&
filters.price.ranges.length > 0
) {
const matchesAnyRange =
filters.price.ranges.some(
(range) => {
if (
range.type === "Range"
) {
return (
details.price >=
range.low &&
details.price <=
range.high
)
} else {
// Under
return (
details.price <=
range.threshold
)
}
}
)
if (!matchesAnyRange) return false
}
// Discount filter (OR within ranges)
if (
filters.discount.active &&
filters.discount.ranges.length > 0
) {
const matchesAnyRange =
filters.discount.ranges.some(
(range) => {
// Skip if no compare price (not on sale)
if (
!details.compareAtPrice ||
details.compareAtPrice <=
0
) {
return false
}
const discount =
details.compareAtPrice -
details.price
// Skip if no actual discount
if (discount <= 0) {
return false
}
// Round all values to whole numbers to avoid decimal comparison issues
const discountPercentage =
Math.round(
(discount /
details.compareAtPrice) *
100
)
const discountAmount =
Math.round(discount)
const discountValue =
range.isPercentage
? discountPercentage
: discountAmount
console.log(
"🔍 Checking discount filter:",
{
productTitle:
details.title,
originalPrice:
details.compareAtPrice,
currentPrice:
details.price,
calculatedDiscount:
discountAmount,
calculatedPercentage:
discountPercentage +
"%",
usingValue:
discountValue,
usingType:
range.isPercentage
? "percentage"
: "amount",
filterType:
range.type,
filterRange:
range.type ===
"Range"
? `${range.low}-${range.high}`
: `over ${range.threshold}`,
rangeConfig: range,
}
)
let matches = false
if (
range.type === "Range"
) {
// Simple inclusive range check with rounded values
matches =
discountValue >=
Math.round(
range.low
) &&
discountValue <=
Math.round(
range.high
)
console.log(
`Range check for ${details.title}: ${discountValue} ${range.isPercentage ? "%" : "$"} should be between ${Math.round(range.low)} and ${Math.round(range.high)}: ${matches}`
)
} else {
// Over
matches =
discountValue >=
Math.round(
range.threshold
)
console.log(
`Over check for ${details.title}: ${discountValue} ${range.isPercentage ? "%" : "$"} should be over ${Math.round(range.threshold)}: ${matches}`
)
}
return matches
}
)
console.log("Final match result:", {
product: details.title,
matches: matchesAnyRange,
activeRanges:
filters.discount.ranges,
})
if (!matchesAnyRange) return false
}
// Simple boolean filters (no OR logic needed)
if (
filters["on-sale"].active &&
!details.isOnSale
) {
return false
}
if (filters["in-stock"].active) {
const hasInStockVariant =
details.variants.some(
(edge) =>
edge.node.availableForSale
)
if (!hasInStockVariant) return false
}
if (filters.bundles.active) {
const isBundle = details.tags.some(
(tag) =>
tag
.toLowerCase()
.includes("bundle")
)
if (!isBundle) return false
}
if (filters.subscriptions.active) {
if (
!details.hasProductLevelPlans &&
!details.hasVariantLevelPlans
) {
return false
}
}
// Variant filter (OR within values)
if (
filters.variant.active &&
filters.variant.values.length > 0
) {
console.log(
"🔍 Checking variant filter:",
{
productTitle: details.title,
filterValues:
filters.variant.values,
productOptions: details.options,
productVariants:
details.variants,
}
)
const matchesAnyVariant =
filters.variant.values.some(
({
optionName,
optionValue,
group,
}) => {
// Check if the option exists and has the value
const hasMatchingOption =
details.options?.some(
(option) =>
option.name.toLowerCase() ===
optionName.toLowerCase() &&
option.values.some(
(val) =>
val.toLowerCase() ===
optionValue.toLowerCase()
)
)
// Check if that specific variant is in stock
const hasInStockVariant =
details.variants?.some(
(edge) => {
const hasMatchingOption =
edge.node.selectedOptions?.some(
(opt) =>
opt.name.toLowerCase() ===
optionName.toLowerCase() &&
opt.value.toLowerCase() ===
optionValue.toLowerCase()
)
const isInStock =
edge.node
.availableForSale
console.log(
`Variant check for ${details.title}:`,
{
optionName,
optionValue,
hasMatchingOption,
isInStock,
selectedOptions:
edge
.node
.selectedOptions,
}
)
return (
hasMatchingOption &&
isInStock
)
}
)
const matches =
hasMatchingOption &&
hasInStockVariant
console.log(
`Final variant check for ${details.title}:`,
{
optionName,
optionValue,
group,
hasMatchingOption,
hasInStockVariant,
matches,
}
)
return matches
}
)
if (!matchesAnyVariant) {
console.log(
`${details.title} did not match any variants:`,
{
filterValues:
filters.variant.values,
productOptions:
details.options,
productVariants:
details.variants,
}
)
return false
}
}
return true
})
console.log(
"After filtering:",
filteredItems.length,
"items"
)
// Modify the child wrapper styles to include settling state
const getTransitionStyles = (baseStyles) => ({
...baseStyles,
opacity:
isTransitioning || isSettling ? 0 : 1,
transition: `opacity ${isSettling ? "0.3s" : "0.2s"} ease-in-out`,
pointerEvents:
isTransitioning || isSettling
? "none"
: "auto",
})
// Update the empty state wrapper
if (
filteredItems.length === 0 &&
emptyStateInstance
) {
updateSortedChildrenWithTransition([
<div
key="empty-state-wrapper"
style={getTransitionStyles({
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
gridColumn: "1 / -1",
})}
>
{React.cloneElement(
emptyStateInstance,
{
style: {
...(emptyStateInstance
.props?.style ||
{}),
width: "100%",
height: "100%",
},
key: "empty-state",
}
)}
</div>,
])
} else {
// Sort the filtered items
const sorted = [...filteredItems].sort(
(a, b) => {
if (
sortConfig.type === "relevancy"
) {
return (
a.originalIndex -
b.originalIndex
)
}
if (sortConfig.sortBy === "price") {
const result =
sortConfig.sortDirection ===
"highToLow"
? b.price - a.price
: a.price - b.price
return (
result ||
a.originalIndex -
b.originalIndex
)
} else {
// sort by name
const result =
sortConfig.sortDirection ===
"aToZ"
? a.title.localeCompare(
b.title
)
: b.title.localeCompare(
a.title
)
return (
result ||
a.originalIndex -
b.originalIndex
)
}
}
)
// Update the product items wrapper
const wrappedChildren = sorted.map((item) =>
React.cloneElement(item.child, {
style: getTransitionStyles(
item.child.props.style
),
})
)
updateSortedChildrenWithTransition(
wrappedChildren
)
}
}
return {
...originalRender,
props: {
...originalRender.props,
children:
sortedChildren ||
originalRender.props.children,
},
}
},
}
),
}),
})
} catch (error) {
console.error("Error in ProductSort:", error)
return sizedInstance
}
// Update URL parameters whenever sortConfig changes
React.useEffect(() => {
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search)
urlParams.set("sortConfig", JSON.stringify(sortConfig))
window.history.replaceState(null, "", "?" + urlParams.toString())
}
}, [sortConfig])
// Update URL parameters whenever filters change
React.useEffect(() => {
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search)
urlParams.set("filters", JSON.stringify(filters))
window.history.replaceState(null, "", "?" + urlParams.toString())
}
}, [filters])
}
addPropertyControls(ProductSort, {
Collection: {
type: ControlType.ComponentInstance,
title: "market sector",
},
EmptyState: {
type: ControlType.ComponentInstance,
title: "Empty State",
},
})



























































































