diff --git a/indexer/src/modules/storage/models/db.ts b/indexer/src/modules/storage/models/db.ts index 49bbf740..60342e35 100644 --- a/indexer/src/modules/storage/models/db.ts +++ b/indexer/src/modules/storage/models/db.ts @@ -109,6 +109,7 @@ export interface Collection { description?: string; image?: string; singleName?: string; + attributeOrder?: string[]; active: boolean; supply: number; id: number; diff --git a/marketplace/src/app/models/data.state.ts b/marketplace/src/app/models/data.state.ts index 7c6ba002..e3efb4f1 100644 --- a/marketplace/src/app/models/data.state.ts +++ b/marketplace/src/app/models/data.state.ts @@ -23,6 +23,7 @@ export interface Collection { discord?: string; defaultBackground?: string; mainTrait?: string; + attributeOrder?: string[]; contractAddress?: string; type?: 'nft' | 'inscription'; diff --git a/marketplace/src/app/services/attributes.service.ts b/marketplace/src/app/services/attributes.service.ts index 4a692e3d..7511dc64 100644 --- a/marketplace/src/app/services/attributes.service.ts +++ b/marketplace/src/app/services/attributes.service.ts @@ -28,6 +28,28 @@ export class AttributesService { private storageSvc: StorageService, ) {} + private sortFilterKeys( + keys: string[], + attributeOrder: string[] | undefined, + ): string[] { + const order = attributeOrder || []; + const orderIndex = new Map(order.map((key, index) => [key, index])); + + return [...keys].sort((a, b) => { + if (a === 'trait_count') return 1; + if (b === 'trait_count') return -1; + + const aIndex = orderIndex.get(a); + const bIndex = orderIndex.get(b); + + if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex; + if (aIndex !== undefined) return -1; + if (bIndex !== undefined) return 1; + + return a.localeCompare(b); + }); + } + /** * Adds attributes to an array of Phunks * @param slug Collection slug @@ -193,7 +215,7 @@ export class AttributesService { }); // Convert the Map of Sets into a plain object with arrays - const attributeObject: { [key: string]: string[] | number[] } = {}; + const unorderedAttributeObject: { [key: string]: string[] | number[] } = {}; attributeMap.forEach((values, key) => { // Sort values by frequency (most common first) const sortedValues = Array.from(values).sort((a, b) => { @@ -210,12 +232,20 @@ export class AttributesService { sortedValues.unshift('none'); } - attributeObject[key] = sortedValues; + unorderedAttributeObject[key] = sortedValues; }); // Add trait count filter options const sortedTraitCounts = Array.from(traitCounts).sort((a, b) => a - b); - attributeObject['trait_count'] = sortedTraitCounts.map(count => count); + unorderedAttributeObject['trait_count'] = sortedTraitCounts.map(count => count); + + const attributeObject: { [key: string]: string[] | number[] } = {}; + this.sortFilterKeys( + Object.keys(unorderedAttributeObject), + collection?.attributeOrder, + ).forEach((key) => { + attributeObject[key] = unorderedAttributeObject[key]; + }); // Generate rarity data from value frequencies const rarityData: { [key: string]: number } = {}; diff --git a/supabase/migrations/20250124000000_add_attribute_order_to_collections.sql b/supabase/migrations/20250124000000_add_attribute_order_to_collections.sql new file mode 100644 index 00000000..510d7f59 --- /dev/null +++ b/supabase/migrations/20250124000000_add_attribute_order_to_collections.sql @@ -0,0 +1,132 @@ +-- Migration: Add attributeOrder to collections tables +-- Description: Stores per-collection trait display order for marketplace UI sorting + +ALTER TABLE public.collections +ADD COLUMN "attributeOrder" text[]; + +COMMENT ON COLUMN public.collections."attributeOrder" IS +'Ordered list of trait keys for collection-specific attribute/filter display.'; + +ALTER TABLE public.collections_sepolia +ADD COLUMN "attributeOrder" text[]; + +COMMENT ON COLUMN public.collections_sepolia."attributeOrder" IS +'Ordered list of trait keys for collection-specific attribute/filter display.'; + +CREATE OR REPLACE FUNCTION "public"."fetch_collections_with_previews"("preview_limit" integer DEFAULT 25, "show_inactive" boolean DEFAULT false) +RETURNS TABLE("ethscription" "json") +LANGUAGE "plpgsql" +AS $$BEGIN + RETURN QUERY + SELECT json_build_object( + 'id', c.id, + 'slug', c.slug, + 'name', c.name, + 'description', c.description, + 'singleName', c."singleName", + 'image', c.image, + 'supply', c.supply, + 'active', c.active, + 'mintEnabled', c."mintEnabled", + 'isMinting', c."isMinting", + 'hasBackgrounds', c."hasBackgrounds", + 'createdAt', c."createdAt", + 'posterHashId', c."posterHashId", + 'website', c.website, + 'twitter', c.twitter, + 'discord', c.discord, + 'notifications', c.notifications, + 'defaultBackground', c."defaultBackground", + 'standalone', c.standalone, + 'adminAddress', c."adminAddress", + 'ignoredTraitFilters', c."ignoredTraitFilters", + 'ignoredTraitFiltersForCounts', c."ignoredTraitFiltersForCounts", + 'mainTrait', c."mainTrait", + 'attributeOrder', c."attributeOrder", + 'contractAddress', c."contractAddress", + 'type', c."type", + 'previews', json_agg(json_build_object( + 'hashId', e."hashId", + 'tokenId', e."tokenId", + 'slug', c.slug, + 'sha', e.sha + )) FILTER (WHERE e."hashId" IS NOT NULL) + ) + FROM public.collections c + LEFT JOIN LATERAL ( + SELECT e."hashId", e."tokenId", e.sha + FROM public.ethscriptions e + WHERE e.slug = c.slug + ORDER BY RANDOM() + LIMIT preview_limit + ) e ON true + WHERE + (CASE + WHEN show_inactive = TRUE THEN c.active = FALSE + ELSE c.active = TRUE + END) + GROUP BY c.id, c.slug, c.name, c.image, c.description, c."singleName", c.supply, c.active, + c."mintEnabled", c."isMinting", c."hasBackgrounds", c."createdAt", c."posterHashId", + c.website, c.twitter, c.discord, c.notifications, c."defaultBackground", c.standalone, + c."adminAddress", c."ignoredTraitFilters", c."ignoredTraitFiltersForCounts", + c."mainTrait", c."attributeOrder", c."contractAddress", c."type"; +END;$$; + +CREATE OR REPLACE FUNCTION "public"."fetch_collections_with_previews_sepolia"("preview_limit" integer DEFAULT 25, "show_inactive" boolean DEFAULT false) +RETURNS TABLE("ethscription" "json") +LANGUAGE "plpgsql" +AS $$BEGIN + RETURN QUERY + SELECT json_build_object( + 'id', c.id, + 'slug', c.slug, + 'name', c.name, + 'description', c.description, + 'singleName', c."singleName", + 'image', c.image, + 'supply', c.supply, + 'active', c.active, + 'mintEnabled', c."mintEnabled", + 'isMinting', c."isMinting", + 'hasBackgrounds', c."hasBackgrounds", + 'createdAt', c."createdAt", + 'posterHashId', c."posterHashId", + 'website', c.website, + 'twitter', c.twitter, + 'discord', c.discord, + 'notifications', c.notifications, + 'defaultBackground', c."defaultBackground", + 'standalone', c.standalone, + 'adminAddress', c."adminAddress", + 'ignoredTraitFilters', c."ignoredTraitFilters", + 'ignoredTraitFiltersForCounts', c."ignoredTraitFiltersForCounts", + 'mainTrait', c."mainTrait", + 'attributeOrder', c."attributeOrder", + 'contractAddress', c."contractAddress", + 'type', c."type", + 'previews', json_agg(json_build_object( + 'hashId', e."hashId", + 'tokenId', e."tokenId", + 'slug', c.slug, + 'sha', e.sha + )) FILTER (WHERE e."hashId" IS NOT NULL) + ) + FROM public.collections_sepolia c + LEFT JOIN LATERAL ( + SELECT e."hashId", e."tokenId", e.sha + FROM public.ethscriptions_sepolia e + WHERE e.slug = c.slug + ORDER BY RANDOM() + LIMIT preview_limit + ) e ON true + WHERE + (CASE + WHEN show_inactive = TRUE THEN c.active = FALSE + ELSE c.active = TRUE + END) + GROUP BY c.id, c.slug, c.name, c.image, c.description, c."singleName", c.supply, c.active, + c."mintEnabled", c."isMinting", c."hasBackgrounds", c."createdAt", c."posterHashId", + c.website, c.twitter, c.discord, c.notifications, c."defaultBackground", c.standalone, + c."adminAddress", c."ignoredTraitFilters", c."ignoredTraitFiltersForCounts", + c."mainTrait", c."attributeOrder", c."contractAddress", c."type"; +END;$$;