embeddable-nfts
Version:
Reusable, embeddable webcomponent for OpenSea assets.
541 lines (500 loc) • 15.7 kB
text/typescript
import { css, customElement, html, LitElement, property } from 'lit-element'
import { styleMap } from 'lit-html/directives/style-map'
import { classMap } from 'lit-html/directives/class-map'
import './info-button'
import { Trait, TraitData, Traits, TraitType } from './types'
import { formatTraitType, getTraitType } from './utils'
const TRAIT_HEADER_HEIGHT = 42
const TRAIT_HEADER_MARGIN_BOTTOM = 8
const RANK_HEIGHT = 40
const RANK_MARGIN = 10
const rankStyle = {
height: RANK_HEIGHT + 'px',
marginBottom: RANK_MARGIN + 'px',
}
const PROP_HEIGHT = 50
const PROP_MARGIN = RANK_MARGIN
const propStyle = {
height: PROP_HEIGHT + 'px',
marginBottom: PROP_MARGIN + 'px',
}
const BOOST_HEIGHT = RANK_HEIGHT
const BOOST_MARGIN = RANK_MARGIN
const BOOST_PADDING = RANK_MARGIN
const boostStyle = {
height: BOOST_HEIGHT + 'px',
}
const STAT_HEIGHT = PROP_HEIGHT
const STAT_MARGIN = RANK_MARGIN
const statStyle = {
height: RANK_HEIGHT + 'px',
marginBottom: RANK_MARGIN + 'px',
}
const traitHeight = {
prop: PROP_HEIGHT + PROP_MARGIN,
boost: BOOST_HEIGHT + BOOST_MARGIN + BOOST_PADDING,
ranking: RANK_HEIGHT + RANK_MARGIN,
stat: STAT_HEIGHT + STAT_MARGIN,
}
('nft-card-back')
export class NftCardBackTemplate extends LitElement {
({ type: Object }) public traitData!: TraitData
({ type: Object }) public openseaLink?: string
({ type: Boolean }) public flippedCard: boolean = false
({ type: Boolean }) public loading = true
({ type: Boolean }) public horizontal!: boolean
({ type: Number }) public cardHeight!: number
({ type: Number }) public cardInnerHeight?: number
({ type: Number }) public cardWidth!: number
({ type: Object }) private traits?: Traits
static get styles() {
return css`
a {
text-decoration: none;
color: inherit;
}
.card-back.is-flipped {
transition-delay: 0.2s;
transition-property: visibility;
visibility: initial;
backface-visibility: initial;
}
.card-back {
position: absolute;
visibility: hidden;
backface-visibility: hidden;
width: 100%;
height: 100%;
transform: rotateY(180deg) translateZ(1px);
top: 0;
overflow: hidden;
padding: 16px 24px;
box-sizing: border-box;
font-size: 15px;
font-weight: 400;
}
.card-back p {
margin: 10px;
}
.card-back-inner {
display: grid;
grid-template-columns: repeat(3, minmax(auto, 33%));
column-gap: 10px;
height: 100%;
}
.is-vertical {
grid-template-columns: 1fr;
grid-template-rows: repeat(3, minmax(auto, 33%));
}
.attribute-container {
text-align: left;
text-transform: capitalize;
}
.is-vertical .attribute-container {
margin: 15px 0;
}
.trait-header {
display: flex;
color: rgba(0, 0, 0, 0.87);
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
line-height: 20px;
margin-bottom: 10px;
}
.trait-header p {
margin: 0 0 10px 8px;
}
.trait-icon {
height: 100%;
}
.trait_property {
display: flex;
flex-flow: column;
justify-content: space-between;
background: #edfbff;
border: 1px solid #2d9cdb;
border-radius: 5px;
width: 100%;
box-sizing: border-box;
text-align: center;
border: 1px solid #2d9cdb;
background-color: #edfbff;
border-radius: 6px;
padding: 8px;
}
.trait_property p {
margin: 7px 0;
font-weight: 400;
color: rgba(0, 0, 0, 0.87);
}
.trait_property .trait_property-type {
margin: 0;
font-size: 11px;
text-transform: uppercase;
font-weight: 500;
color: #2d9cdb;
opacity: 0.8;
}
.trait_property .trait_property-value {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0;
color: rgba(0, 0, 0, 0.87);
}
.trait_ranking {
margin-bottom: 16px;
cursor: pointer;
}
.trait_ranking .trait_ranking-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.trait_ranking .trait_ranking-header .trait_ranking-header-name {
color: rgba(0, 0, 0, 0.87);
font-size: 14px;
}
.trait_ranking .trait_ranking-header .trait_ranking-header-value {
color: #9e9e9e;
font-size: 11px;
text-transform: none;
}
.trait_ranking .trait_ranking-bar {
width: 100%;
height: 6px;
border-radius: 14px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
position: relative;
background: #f3f3f3;
margin-top: 4px;
}
.trait_ranking .trait_ranking-bar .trait_ranking-bar-fill {
position: absolute;
left: 1px;
top: 1px;
height: 4px;
background: #3291e9;
border-radius: 14px;
max-width: calc(100% - 2px);
}
.trait-header-stats {
margin-bottom: 0;
}
.stat {
display: grid;
grid-template-columns: 1fr 4fr;
justify-items: left;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.stat-name {
text-transform: capitalize;
margin-left: 5px;
}
.stat-value {
color: #2d9cdb;
font-size: 25px;
font-weight: 300;
margin-left: 5px;
}
.trait_boost {
display: flex;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.trait_boost .trait_boost-value {
width: 30px;
height: 30px;
background-color: transparent;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.trait_boost .trait_boost-value p {
font-size: 16px;
color: #2d9cdb;
}
.remaining-traits {
text-transform: none;
font-weight: bold;
margin-top: 10px;
display: block;
}
`
}
public updated(changedProperties: Map<string, string>) {
// Assumption: If the traitData gets updated we should rebuild the
// traits object that populates UI
// Assumption: This will ONLY get called once per refresh
changedProperties.forEach(async (_oldValue: string, propName: string) => {
if (propName === 'traitData') {
this.buildTraits(this.traitData)
// We got the data so we are done loading
this.loading = false
// Tell the component to update with new state
await this.requestUpdate()
}
})
if (this.shadowRoot) {
const el: HTMLElement = this.shadowRoot.firstElementChild as HTMLElement
this.cardHeight = el.offsetHeight
this.cardWidth = el.offsetWidth
const cardStyles = window.getComputedStyle(el)
const paddingBottom = +cardStyles.paddingBottom.slice(0, -2)
const paddingTop = +cardStyles.paddingTop.slice(0, -2)
this.cardInnerHeight = this.cardHeight - (paddingBottom + paddingTop)
}
}
public getContainerHeight() {
let containerHeight
const traitHeaderHeight = TRAIT_HEADER_HEIGHT + TRAIT_HEADER_MARGIN_BOTTOM
if (this.horizontal) {
containerHeight = this.cardHeight - traitHeaderHeight
} else {
// We only render 3 types of traits at a time so we must substract the heights of
// 3 trait headers
containerHeight = this.cardInnerHeight
? (this.cardInnerHeight - traitHeaderHeight * 3) / 3
: 100 // default container height
}
return containerHeight
}
public getRenderNumber(traitType: TraitType, numberOfTraits: number) {
const containerHeight = this.getContainerHeight()
const numRender = Math.round(containerHeight / traitHeight[traitType]) - 1
const numRemaining = numberOfTraits - numRender
return {
numRender,
numRemaining,
}
}
public getBoostsTemplate(boosts: Trait[]) {
if (boosts.length <= 0) {
return undefined // Don't render if empty array
}
const { numRender, numRemaining } = this.getRenderNumber(
TraitType.Boost,
boosts.length
)
return html`
<div class="trait-header trait-header-stats">
<div class="trait-icon">
<svg
width="10"
height="100%"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.666656 0.333336V7.66667H2.66666V13.6667L7.33332 5.66667H4.66666L7.33332 0.333336H0.666656Z"
fill="#1C1F27"
/>
</svg>
</div>
<p class="attribute-title">Boosts</p>
</div>
${boosts.slice(0, numRender).map(
({ trait_type, value }) => html`
<div class="trait_boost" style=${styleMap(boostStyle)}>
<div class="trait_boost-value">
<p>+${value}</p>
</div>
<div class="trait_boost-name">${formatTraitType(trait_type)}</div>
</div>
`
)}
${this.viewMoreTemplate(numRemaining)}
`
}
public getStatsTemplate(stats: Trait[]) {
if (stats.length <= 0) {
return undefined // Don't render if empty array
}
const { numRender, numRemaining } = this.getRenderNumber(
TraitType.Stat,
stats.length
)
return html`
<div class="trait-header trait-header-stats">
<div class="trait-icon">
<svg
width="15"
height="100%"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.66666 11.3333H7.33332V0.666672H4.66666V11.3333ZM0.666656 11.3333H3.33332V6H0.666656V11.3333ZM8.66666 4V11.3333H11.3333V4H8.66666Z"
fill="black"
/>
</svg>
</div>
<p class="attribute-title">Stats</p>
</div>
${stats.slice(0, numRender).map(
(stat) =>
html`
<div class="stat" style=${styleMap(statStyle)}>
<div class="stat-value">${stat.value}</div>
<div class="stat-name">${formatTraitType(stat.trait_type)}</div>
</div>
`
)}
${this.viewMoreTemplate(numRemaining)}
`
}
public getRankingsTemplate(rankings: Trait[]) {
if (rankings.length <= 0) {
return undefined // Don't render if empty array
}
const { numRender, numRemaining } = this.getRenderNumber(
TraitType.Ranking,
rankings.length
)
return html`
<div class="trait-header">
<div class="trait-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="100%"
viewBox="0 0 24 24"
>
<path d="M0 0h24v24H0z" fill="none" />
<path
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"
/>
</svg>
</div>
<p class="attribute-title">Rankings</p>
</div>
${rankings.slice(0, numRender).map(
({ trait_type, value, max }) => html`
<div class="trait_ranking" style=${styleMap(rankStyle)}>
<div class="trait_ranking-header">
<div class="trait_ranking-header-name">
${formatTraitType(trait_type)}
</div>
<div class="trait_ranking-header-value">${value} of ${max}</div>
</div>
<div class="trait_ranking-bar">
<div
class="trait_ranking-bar-fill"
style=${styleMap({ width: `${(+value / +(max || 1)) * 100}%` })}
></div>
</div>
</div>
`
)}
${this.viewMoreTemplate(numRemaining)}
`
}
public getPropsTemplate(props: Trait[]) {
if (props.length <= 0) {
return undefined // Don't render if empty array
}
const { numRender, numRemaining } = this.getRenderNumber(
TraitType.Property,
props.length
)
return html`
<div class="trait-header">
<div class="trait-icon">
<svg
width="18"
height="100%"
viewBox="0 0 12 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 2.00001H9.33333V0.666672H0V2.00001ZM0 4.66667H9.33333V3.33334H0V4.66667ZM0 7.33334H9.33333V6H0V7.33334ZM10.6667 7.33334H12V6H10.6667V7.33334ZM10.6667 0.666672V2.00001H12V0.666672H10.6667ZM10.6667 4.66667H12V3.33334H10.6667V4.66667Z"
fill="#1C1F27"
/>
</svg>
</div>
<p class="attribute-title">Properties</p>
</div>
${props.slice(0, numRender).map(
({ trait_type, value }) =>
html`
<div class="trait_property" style="${styleMap(propStyle)}">
<p class="trait_property-type">${formatTraitType(trait_type)}</p>
<p class="trait_property-value">${value}</p>
</div>
`
)}
${this.viewMoreTemplate(numRemaining)}
`
}
public render() {
return html`
<div class="card-back ${classMap({ 'is-vertical': !this.horizontal, 'is-flipped': this.flippedCard })}">
<info-button
style="position: absolute; top: 5px; right: 5px"
@flip-event="${(_e: any) =>
this.dispatchEvent(
new CustomEvent('flip-event', { detail: { type: 'flip' } })
)}"
></info-button>
<div
class="card-back-inner ${classMap({
'is-vertical': !this.horizontal,
})}"
>
<div class="attribute-container attribute-properties">
${this.traits ? this.getPropsTemplate(this.traits.props) : ''}
</div>
<div class="attribute-container">
${this.traits
? this.traits.rankings.length > 0
? this.getRankingsTemplate(this.traits.rankings)
: this.getStatsTemplate(this.traits.stats)
: ''}
</div>
<div class="attribute-container attribute-boosts">
${this.traits ? this.getBoostsTemplate(this.traits.boosts) : ''}
</div>
</div>
</div>
`
}
private viewMoreTemplate(numRemaining: number) {
if (numRemaining <= 0) {
return null
} else {
return html`
<a class="remaining-traits" href="${this.openseaLink}" target="_blank"
>+${numRemaining} more</a
>
`
}
}
private buildTraits(traitData: TraitData) {
this.traits = {
props: [],
stats: [],
rankings: [],
boosts: [],
}
const { traits: assetTraits, collectionTraits } = traitData
for (const trait of assetTraits) {
const type = getTraitType(trait, collectionTraits)
const name = trait.trait_type
this.traits[type + 's'].push({
value: trait.value,
...(type === TraitType.Ranking
? { max: collectionTraits[name].max as unknown as number }
: {}),
trait_type: trait.trait_type,
})
}
}
}