diff --git a/marketplace/src/app/models/data.state.ts b/marketplace/src/app/models/data.state.ts index 7c6ba002..7424e1d3 100644 --- a/marketplace/src/app/models/data.state.ts +++ b/marketplace/src/app/models/data.state.ts @@ -22,7 +22,7 @@ export interface Collection { twitter?: string; discord?: string; defaultBackground?: string; - mainTrait?: string; + mainTraits?: string[]; contractAddress?: string; type?: 'nft' | 'inscription'; diff --git a/marketplace/src/app/routes/item-view/item-view.component.html b/marketplace/src/app/routes/item-view/item-view.component.html index 2a3156ca..72049aa6 100644 --- a/marketplace/src/app/routes/item-view/item-view.component.html +++ b/marketplace/src/app/routes/item-view/item-view.component.html @@ -50,15 +50,19 @@

{{ phunk.collection?.singleName }} {{ phunk.tokenId }}

} - @if (phunk.attributes && phunk.attributes.length) { -

One of {{ ((phunk.attributes[0].v | traitRarity : phunk.slug) | async) || '----' }} - - - {{ phunk.attributes[0].v }} - {{ phunk.collection?.name }}. + @if (featuredTrait$ | async; as featuredTrait) { +

One of {{ featuredTrait.rarity || '----' }} + @if (featuredTrait.filterable) { + + {{ featuredTrait.value }} + + } @else { + {{ featuredTrait.value }} + } + {{ phunk.collection?.name }}.

} diff --git a/marketplace/src/app/routes/item-view/item-view.component.ts b/marketplace/src/app/routes/item-view/item-view.component.ts index 88f88a72..e465f1f6 100644 --- a/marketplace/src/app/routes/item-view/item-view.component.ts +++ b/marketplace/src/app/routes/item-view/item-view.component.ts @@ -1,11 +1,11 @@ import { CommonModule } from '@angular/common'; -import { Router, ActivatedRoute, RouterModule } from '@angular/router'; +import { ActivatedRoute, RouterModule } from '@angular/router'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { Component, signal, OnDestroy } from '@angular/core'; +import { Component, signal } from '@angular/core'; import { Store } from '@ngrx/store'; import { LazyLoadImageModule } from 'ng-lazyload-image'; -import { distinctUntilChanged, filter, fromEvent, map, shareReplay, switchMap, Subject, takeUntil, tap } from 'rxjs'; +import { combineLatest, distinctUntilChanged, filter, fromEvent, map, shareReplay, switchMap, tap } from 'rxjs'; import { PhunkBillboardComponent } from '@/components/phunk-billboard/phunk-billboard.component'; import { TxHistoryComponent } from '@/components/tx-history/tx-history.component'; @@ -19,21 +19,28 @@ import { ItemAttributesComponent } from './components/item-attributes/item-attri import { WalletAddressDirective } from '@/directives/wallet-address.directive'; -import { TraitRarityPipe } from '@/pipes/trait-rarity.pipe'; import { QueryParamsPipe } from '@/pipes/query-params.pipe'; import { DataService } from '@/services/data.service'; +import { AttributesService } from '@/services/attributes.service'; import { GlobalState } from '@/models/global-state'; import { Phunk } from '@/models/db'; import { Collection } from '@/models/data.state'; +import { Attribute } from '@/models/attributes'; import * as appStateSelectors from '@/state/app/app-state.selectors'; import { environment } from '@environments/environment'; import { setMarketSlug } from '@/state/market/market-state.actions'; import { selectCollections } from '@/state/data/data-state.selectors'; -import { selectMarketSlug } from '@/state/market/market-state.selectors'; + +interface FeaturedTrait { + attribute: Attribute; + value: string; + rarity: string; + filterable: boolean; +} @Component({ standalone: true, @@ -55,7 +62,6 @@ import { selectMarketSlug } from '@/state/market/market-state.selectors'; ItemActionsComponent, ItemAttributesComponent, - TraitRarityPipe, QueryParamsPipe, ], selector: 'app-phunk-item-view', @@ -74,11 +80,16 @@ export class ItemViewComponent { shareReplay({ bufferSize: 1, refCount: true }), ); - collection$ = this.store.select(selectMarketSlug).pipe( - filter((slug: string) => !!slug), - switchMap((slug: string) => this.store.select(selectCollections).pipe( - map((collections: Collection[]) => collections.find((collection: Collection) => collection.slug === slug)), + collection$ = this.singlePhunk$.pipe( + switchMap((phunk: Phunk) => this.store.select(selectCollections).pipe( + map((collections: Collection[]) => collections.find((collection: Collection) => collection.slug === phunk.slug)), + filter((collection: Collection | undefined): collection is Collection => !!collection), )), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + featuredTrait$ = combineLatest([this.singlePhunk$, this.collection$]).pipe( + switchMap(([phunk, collection]: [Phunk, Collection]) => this.getFeaturedTrait(phunk, collection)), ); name$ = this.singlePhunk$.pipe( @@ -104,8 +115,47 @@ export class ItemViewComponent { private store: Store, public route: ActivatedRoute, public dataSvc: DataService, + private attributesSvc: AttributesService, ) {} + private async getFeaturedTrait(phunk: Phunk, collection: Collection): Promise { + const attributes = phunk.attributes || []; + if (!attributes.length) return null; + + const configuredMainTraits = collection?.mainTraits?.filter(Boolean) || []; + const mainTraits = configuredMainTraits.length ? configuredMainTraits : ['Name']; + const priorityAttribute = mainTraits + .map((trait: string) => this.findAttribute(attributes, trait)) + .find((attribute: Attribute | undefined) => !!attribute); + + const attribute = priorityAttribute || attributes.find((item: Attribute) => ( + !collection?.ignoredTraitFilters?.includes(item.k) && this.hasAttributeValue(item) + )); + + if (!attribute) return null; + + const value = String(attribute.v); + const rarityData = await this.attributesSvc.getRarityData(phunk.slug); + const rarity = attribute.k === 'Name' ? '1' : rarityData?.[value]?.toString() || ''; + + return { + attribute, + value, + rarity, + filterable: !collection?.ignoredTraitFilters?.includes(attribute.k), + }; + } + + private findAttribute(attributes: Attribute[], trait: string): Attribute | undefined { + return attributes.find((attribute: Attribute) => ( + attribute.k === trait && this.hasAttributeValue(attribute) + )); + } + + private hasAttributeValue(attribute: Attribute): boolean { + return attribute.v !== null && attribute.v !== undefined && attribute.v !== ''; + } + expandBillboard(): void { this.billboardExpanded.update((expanded) => !expanded); } diff --git a/marketplace/src/app/services/attributes.service.ts b/marketplace/src/app/services/attributes.service.ts index 4a692e3d..9b6d07c3 100644 --- a/marketplace/src/app/services/attributes.service.ts +++ b/marketplace/src/app/services/attributes.service.ts @@ -50,9 +50,15 @@ export class AttributesService { return phunks.map((item: Phunk) => { const originalAttributes = item.sha ? res![item.sha] : []; if (!originalAttributes) return item; + const mainTraits = collection?.mainTraits?.filter(Boolean) || []; + const promotedTrait = mainTraits.find((trait: string) => + originalAttributes.some((attribute: Attribute) => ( + attribute.k === trait && attribute.v !== null && attribute.v !== undefined && attribute.v !== '' + )) + ); const attributes = [...originalAttributes]?.sort((a: Attribute, b: Attribute) => { - if (a.k === collection?.mainTrait) return -1; - if (b.k === collection?.mainTrait) return 1; + if (a.k === promotedTrait) return -1; + if (b.k === promotedTrait) return 1; return 0; }); return { ...item, attributes }; @@ -151,7 +157,7 @@ export class AttributesService { // Skip Description and Name attributes since they aren't used for filtering if (collection?.ignoredTraitFilters?.includes(attribute.k)) return; - // Count traits (exclude mainTrait from trait counting, but still include it as a filter) + // Count traits (collection-level exclusions control what is counted) if (!collection?.ignoredTraitFiltersForCounts?.includes(attribute.k)) { traitCount++; } diff --git a/supabase/migrations/20250127000000_convert_main_trait_to_main_traits.sql b/supabase/migrations/20250127000000_convert_main_trait_to_main_traits.sql new file mode 100644 index 00000000..34ae0e77 --- /dev/null +++ b/supabase/migrations/20250127000000_convert_main_trait_to_main_traits.sql @@ -0,0 +1,138 @@ +-- Convert the single main trait setting into an ordered trait priority list. +ALTER TABLE public.collections +RENAME COLUMN "mainTrait" TO "mainTraits"; + +ALTER TABLE public.collections +ALTER COLUMN "mainTraits" TYPE text[] +USING CASE + WHEN "mainTraits" IS NULL OR "mainTraits" = '' THEN NULL + ELSE ARRAY["mainTraits"] +END; + +ALTER TABLE public.collections_sepolia +RENAME COLUMN "mainTrait" TO "mainTraits"; + +ALTER TABLE public.collections_sepolia +ALTER COLUMN "mainTraits" TYPE text[] +USING CASE + WHEN "mainTraits" IS NULL OR "mainTraits" = '' THEN NULL + ELSE ARRAY["mainTraits"] +END; + +-- Update mainnet fetch_collections_with_previews function. +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", + 'mainTraits', c."mainTraits", + '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."mainTraits", c."contractAddress", c."type"; +END;$$; + +-- Update sepolia fetch_collections_with_previews function. +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", + 'mainTraits', c."mainTraits", + '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."mainTraits", c."contractAddress", c."type"; +END;$$;