Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion marketplace/src/app/models/data.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Collection {
twitter?: string;
discord?: string;
defaultBackground?: string;
mainTrait?: string;
mainTraits?: string[];
contractAddress?: string;
type?: 'nft' | 'inscription';

Expand Down
22 changes: 13 additions & 9 deletions marketplace/src/app/routes/item-view/item-view.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,19 @@ <h1 i18n>{{ phunk.collection?.singleName }} {{ phunk.tokenId }}</h1>
</div>
}

@if (phunk.attributes && phunk.attributes.length) {
<h2 i18n>One of {{ ((phunk.attributes[0].v | traitRarity : phunk.slug) | async) || '----' }}
<a
class="highlight"
[routerLink]="['/', 'curated', phunk.slug, 'market', 'all']"
[queryParams]="phunk.attributes[0] | queryParams">

{{ phunk.attributes[0].v }}
</a> {{ phunk.collection?.name }}.
@if (featuredTrait$ | async; as featuredTrait) {
<h2 i18n>One of {{ featuredTrait.rarity || '----' }}
@if (featuredTrait.filterable) {
<a
class="highlight"
[routerLink]="['/', 'curated', phunk.slug, 'market', 'all']"
[queryParams]="featuredTrait.attribute | queryParams">
{{ featuredTrait.value }}
</a>
} @else {
<span class="highlight">{{ featuredTrait.value }}</span>
}
{{ phunk.collection?.name }}.
</h2>
}
</div>
Expand Down
70 changes: 60 additions & 10 deletions marketplace/src/app/routes/item-view/item-view.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -55,7 +62,6 @@ import { selectMarketSlug } from '@/state/market/market-state.selectors';
ItemActionsComponent,
ItemAttributesComponent,

TraitRarityPipe,
QueryParamsPipe,
],
selector: 'app-phunk-item-view',
Expand All @@ -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(
Expand All @@ -104,8 +115,47 @@ export class ItemViewComponent {
private store: Store<GlobalState>,
public route: ActivatedRoute,
public dataSvc: DataService,
private attributesSvc: AttributesService,
) {}

private async getFeaturedTrait(phunk: Phunk, collection: Collection): Promise<FeaturedTrait | null> {
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);
}
Expand Down
12 changes: 9 additions & 3 deletions marketplace/src/app/services/attributes.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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++;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;$$;