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;$$;