UNPKG

15.7 kBPlain TextView Raw
1import { css, customElement, html, LitElement, property } from 'lit-element'
2import { styleMap } from 'lit-html/directives/style-map'
3import { classMap } from 'lit-html/directives/class-map'
4
5import './info-button'
6import { Trait, TraitData, Traits, TraitType } from './types'
7
8import { formatTraitType, getTraitType } from './utils'
9
10const TRAIT_HEADER_HEIGHT = 42
11const TRAIT_HEADER_MARGIN_BOTTOM = 8
12
13const RANK_HEIGHT = 40
14const RANK_MARGIN = 10
15const rankStyle = {
16 height: RANK_HEIGHT + 'px',
17 marginBottom: RANK_MARGIN + 'px',
18}
19
20const PROP_HEIGHT = 50
21const PROP_MARGIN = RANK_MARGIN
22const propStyle = {
23 height: PROP_HEIGHT + 'px',
24 marginBottom: PROP_MARGIN + 'px',
25}
26
27const BOOST_HEIGHT = RANK_HEIGHT
28const BOOST_MARGIN = RANK_MARGIN
29const BOOST_PADDING = RANK_MARGIN
30const boostStyle = {
31 height: BOOST_HEIGHT + 'px',
32}
33
34const STAT_HEIGHT = PROP_HEIGHT
35const STAT_MARGIN = RANK_MARGIN
36const statStyle = {
37 height: RANK_HEIGHT + 'px',
38 marginBottom: RANK_MARGIN + 'px',
39}
40
41const traitHeight = {
42 prop: PROP_HEIGHT + PROP_MARGIN,
43 boost: BOOST_HEIGHT + BOOST_MARGIN + BOOST_PADDING,
44 ranking: RANK_HEIGHT + RANK_MARGIN,
45 stat: STAT_HEIGHT + STAT_MARGIN,
46}
47
48@customElement('nft-card-back')
49export class NftCardBackTemplate extends LitElement {
50 @property({ type: Object }) public traitData!: TraitData
51 @property({ type: Object }) public openseaLink?: string
52 @property({ type: Boolean }) public flippedCard: boolean = false
53 @property({ type: Boolean }) public loading = true
54 @property({ type: Boolean }) public horizontal!: boolean
55 @property({ type: Number }) public cardHeight!: number
56 @property({ type: Number }) public cardInnerHeight?: number
57 @property({ type: Number }) public cardWidth!: number
58
59 @property({ type: Object }) private traits?: Traits
60
61 static get styles() {
62 return css`
63 a {
64 text-decoration: none;
65 color: inherit;
66 }
67 .card-back.is-flipped {
68 transition-delay: 0.2s;
69 transition-property: visibility;
70 visibility: initial;
71 backface-visibility: initial;
72 }
73 .card-back {
74 position: absolute;
75 visibility: hidden;
76 backface-visibility: hidden;
77 width: 100%;
78 height: 100%;
79 transform: rotateY(180deg) translateZ(1px);
80 top: 0;
81 overflow: hidden;
82 padding: 16px 24px;
83 box-sizing: border-box;
84 font-size: 15px;
85 font-weight: 400;
86 }
87 .card-back p {
88 margin: 10px;
89 }
90 .card-back-inner {
91 display: grid;
92 grid-template-columns: repeat(3, minmax(auto, 33%));
93 column-gap: 10px;
94 height: 100%;
95 }
96 .is-vertical {
97 grid-template-columns: 1fr;
98 grid-template-rows: repeat(3, minmax(auto, 33%));
99 }
100 .attribute-container {
101 text-align: left;
102 text-transform: capitalize;
103 }
104 .is-vertical .attribute-container {
105 margin: 15px 0;
106 }
107 .trait-header {
108 display: flex;
109 color: rgba(0, 0, 0, 0.87);
110 font-weight: 700;
111 letter-spacing: 1px;
112 text-transform: uppercase;
113 border-bottom: 1px solid rgba(0, 0, 0, 0.1);
114 line-height: 20px;
115 margin-bottom: 10px;
116 }
117 .trait-header p {
118 margin: 0 0 10px 8px;
119 }
120 .trait-icon {
121 height: 100%;
122 }
123 .trait_property {
124 display: flex;
125 flex-flow: column;
126 justify-content: space-between;
127 background: #edfbff;
128 border: 1px solid #2d9cdb;
129 border-radius: 5px;
130 width: 100%;
131 box-sizing: border-box;
132 text-align: center;
133 border: 1px solid #2d9cdb;
134 background-color: #edfbff;
135 border-radius: 6px;
136 padding: 8px;
137 }
138 .trait_property p {
139 margin: 7px 0;
140 font-weight: 400;
141 color: rgba(0, 0, 0, 0.87);
142 }
143 .trait_property .trait_property-type {
144 margin: 0;
145 font-size: 11px;
146 text-transform: uppercase;
147 font-weight: 500;
148 color: #2d9cdb;
149 opacity: 0.8;
150 }
151 .trait_property .trait_property-value {
152 overflow: hidden;
153 white-space: nowrap;
154 text-overflow: ellipsis;
155 margin: 0;
156 color: rgba(0, 0, 0, 0.87);
157 }
158 .trait_ranking {
159 margin-bottom: 16px;
160 cursor: pointer;
161 }
162 .trait_ranking .trait_ranking-header {
163 display: flex;
164 justify-content: space-between;
165 align-items: center;
166 }
167 .trait_ranking .trait_ranking-header .trait_ranking-header-name {
168 color: rgba(0, 0, 0, 0.87);
169 font-size: 14px;
170 }
171
172 .trait_ranking .trait_ranking-header .trait_ranking-header-value {
173 color: #9e9e9e;
174 font-size: 11px;
175 text-transform: none;
176 }
177 .trait_ranking .trait_ranking-bar {
178 width: 100%;
179 height: 6px;
180 border-radius: 14px;
181 box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
182 position: relative;
183 background: #f3f3f3;
184 margin-top: 4px;
185 }
186
187 .trait_ranking .trait_ranking-bar .trait_ranking-bar-fill {
188 position: absolute;
189 left: 1px;
190 top: 1px;
191 height: 4px;
192 background: #3291e9;
193 border-radius: 14px;
194 max-width: calc(100% - 2px);
195 }
196 .trait-header-stats {
197 margin-bottom: 0;
198 }
199 .stat {
200 display: grid;
201 grid-template-columns: 1fr 4fr;
202 justify-items: left;
203 align-items: center;
204 border-bottom: 1px solid rgba(0, 0, 0, 0.1);
205 }
206 .stat-name {
207 text-transform: capitalize;
208 margin-left: 5px;
209 }
210 .stat-value {
211 color: #2d9cdb;
212 font-size: 25px;
213 font-weight: 300;
214 margin-left: 5px;
215 }
216 .trait_boost {
217 display: flex;
218 align-items: center;
219 border-bottom: 1px solid rgba(0, 0, 0, 0.1);
220 }
221 .trait_boost .trait_boost-value {
222 width: 30px;
223 height: 30px;
224 background-color: transparent;
225 border-radius: 50%;
226 display: flex;
227 align-items: center;
228 justify-content: center;
229 margin-right: 10px;
230 }
231 .trait_boost .trait_boost-value p {
232 font-size: 16px;
233 color: #2d9cdb;
234 }
235 .remaining-traits {
236 text-transform: none;
237 font-weight: bold;
238 margin-top: 10px;
239 display: block;
240 }
241 `
242 }
243
244 public updated(changedProperties: Map<string, string>) {
245 // Assumption: If the traitData gets updated we should rebuild the
246 // traits object that populates UI
247 // Assumption: This will ONLY get called once per refresh
248 changedProperties.forEach(async (_oldValue: string, propName: string) => {
249 if (propName === 'traitData') {
250 this.buildTraits(this.traitData)
251
252 // We got the data so we are done loading
253 this.loading = false
254
255 // Tell the component to update with new state
256 await this.requestUpdate()
257 }
258 })
259
260 if (this.shadowRoot) {
261 const el: HTMLElement = this.shadowRoot.firstElementChild as HTMLElement
262 this.cardHeight = el.offsetHeight
263 this.cardWidth = el.offsetWidth
264
265 const cardStyles = window.getComputedStyle(el)
266 const paddingBottom = +cardStyles.paddingBottom.slice(0, -2)
267 const paddingTop = +cardStyles.paddingTop.slice(0, -2)
268
269 this.cardInnerHeight = this.cardHeight - (paddingBottom + paddingTop)
270 }
271 }
272
273 public getContainerHeight() {
274 let containerHeight
275 const traitHeaderHeight = TRAIT_HEADER_HEIGHT + TRAIT_HEADER_MARGIN_BOTTOM
276 if (this.horizontal) {
277 containerHeight = this.cardHeight - traitHeaderHeight
278 } else {
279 // We only render 3 types of traits at a time so we must substract the heights of
280 // 3 trait headers
281 containerHeight = this.cardInnerHeight
282 ? (this.cardInnerHeight - traitHeaderHeight * 3) / 3
283 : 100 // default container height
284 }
285 return containerHeight
286 }
287
288 public getRenderNumber(traitType: TraitType, numberOfTraits: number) {
289 const containerHeight = this.getContainerHeight()
290 const numRender = Math.round(containerHeight / traitHeight[traitType]) - 1
291 const numRemaining = numberOfTraits - numRender
292 return {
293 numRender,
294 numRemaining,
295 }
296 }
297
298 public getBoostsTemplate(boosts: Trait[]) {
299 if (boosts.length <= 0) {
300 return undefined // Don't render if empty array
301 }
302
303 const { numRender, numRemaining } = this.getRenderNumber(
304 TraitType.Boost,
305 boosts.length
306 )
307
308 return html`
309 <div class="trait-header trait-header-stats">
310 <div class="trait-icon">
311 <svg
312 width="10"
313 height="100%"
314 viewBox="0 0 8 14"
315 fill="none"
316 xmlns="http://www.w3.org/2000/svg"
317 >
318 <path
319 d="M0.666656 0.333336V7.66667H2.66666V13.6667L7.33332 5.66667H4.66666L7.33332 0.333336H0.666656Z"
320 fill="#1C1F27"
321 />
322 </svg>
323 </div>
324 <p class="attribute-title">Boosts</p>
325 </div>
326 ${boosts.slice(0, numRender).map(
327 ({ trait_type, value }) => html`
328 <div class="trait_boost" style=${styleMap(boostStyle)}>
329 <div class="trait_boost-value">
330 <p>+${value}</p>
331 </div>
332 <div class="trait_boost-name">${formatTraitType(trait_type)}</div>
333 </div>
334 `
335 )}
336 ${this.viewMoreTemplate(numRemaining)}
337 `
338 }
339
340 public getStatsTemplate(stats: Trait[]) {
341 if (stats.length <= 0) {
342 return undefined // Don't render if empty array
343 }
344 const { numRender, numRemaining } = this.getRenderNumber(
345 TraitType.Stat,
346 stats.length
347 )
348
349 return html`
350 <div class="trait-header trait-header-stats">
351 <div class="trait-icon">
352 <svg
353 width="15"
354 height="100%"
355 viewBox="0 0 12 12"
356 fill="none"
357 xmlns="http://www.w3.org/2000/svg"
358 >
359 <path
360 d="M4.66666 11.3333H7.33332V0.666672H4.66666V11.3333ZM0.666656 11.3333H3.33332V6H0.666656V11.3333ZM8.66666 4V11.3333H11.3333V4H8.66666Z"
361 fill="black"
362 />
363 </svg>
364 </div>
365 <p class="attribute-title">Stats</p>
366 </div>
367 ${stats.slice(0, numRender).map(
368 (stat) =>
369 html`
370 <div class="stat" style=${styleMap(statStyle)}>
371 <div class="stat-value">${stat.value}</div>
372 <div class="stat-name">${formatTraitType(stat.trait_type)}</div>
373 </div>
374 `
375 )}
376 ${this.viewMoreTemplate(numRemaining)}
377 `
378 }
379
380 public getRankingsTemplate(rankings: Trait[]) {
381 if (rankings.length <= 0) {
382 return undefined // Don't render if empty array
383 }
384 const { numRender, numRemaining } = this.getRenderNumber(
385 TraitType.Ranking,
386 rankings.length
387 )
388
389 return html`
390 <div class="trait-header">
391 <div class="trait-icon">
392 <svg
393 xmlns="http://www.w3.org/2000/svg"
394 width="20"
395 height="100%"
396 viewBox="0 0 24 24"
397 >
398 <path d="M0 0h24v24H0z" fill="none" />
399 <path
400 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"
401 />
402 </svg>
403 </div>
404 <p class="attribute-title">Rankings</p>
405 </div>
406 ${rankings.slice(0, numRender).map(
407 ({ trait_type, value, max }) => html`
408 <div class="trait_ranking" style=${styleMap(rankStyle)}>
409 <div class="trait_ranking-header">
410 <div class="trait_ranking-header-name">
411 ${formatTraitType(trait_type)}
412 </div>
413 <div class="trait_ranking-header-value">${value} of ${max}</div>
414 </div>
415 <div class="trait_ranking-bar">
416 <div
417 class="trait_ranking-bar-fill"
418 style=${styleMap({ width: `${(+value / +(max || 1)) * 100}%` })}
419 ></div>
420 </div>
421 </div>
422 `
423 )}
424 ${this.viewMoreTemplate(numRemaining)}
425 `
426 }
427
428 public getPropsTemplate(props: Trait[]) {
429 if (props.length <= 0) {
430 return undefined // Don't render if empty array
431 }
432
433 const { numRender, numRemaining } = this.getRenderNumber(
434 TraitType.Property,
435 props.length
436 )
437
438 return html`
439 <div class="trait-header">
440 <div class="trait-icon">
441 <svg
442 width="18"
443 height="100%"
444 viewBox="0 0 12 8"
445 fill="none"
446 xmlns="http://www.w3.org/2000/svg"
447 >
448 <path
449 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"
450 fill="#1C1F27"
451 />
452 </svg>
453 </div>
454 <p class="attribute-title">Properties</p>
455 </div>
456
457 ${props.slice(0, numRender).map(
458 ({ trait_type, value }) =>
459 html`
460 <div class="trait_property" style="${styleMap(propStyle)}">
461 <p class="trait_property-type">${formatTraitType(trait_type)}</p>
462 <p class="trait_property-value">${value}</p>
463 </div>
464 `
465 )}
466 ${this.viewMoreTemplate(numRemaining)}
467 `
468 }
469
470 public render() {
471 return html`
472 <div class="card-back ${classMap({ 'is-vertical': !this.horizontal, 'is-flipped': this.flippedCard })}">
473 <info-button
474 style="position: absolute; top: 5px; right: 5px"
475 @flip-event="${(_e: any) =>
476 this.dispatchEvent(
477 new CustomEvent('flip-event', { detail: { type: 'flip' } })
478 )}"
479 ></info-button>
480
481 <div
482 class="card-back-inner ${classMap({
483 'is-vertical': !this.horizontal,
484 })}"
485 >
486 <div class="attribute-container attribute-properties">
487 ${this.traits ? this.getPropsTemplate(this.traits.props) : ''}
488 </div>
489
490 <div class="attribute-container">
491 ${this.traits
492 ? this.traits.rankings.length > 0
493 ? this.getRankingsTemplate(this.traits.rankings)
494 : this.getStatsTemplate(this.traits.stats)
495 : ''}
496 </div>
497 <div class="attribute-container attribute-boosts">
498 ${this.traits ? this.getBoostsTemplate(this.traits.boosts) : ''}
499 </div>
500 </div>
501 </div>
502 `
503 }
504
505 private viewMoreTemplate(numRemaining: number) {
506 if (numRemaining <= 0) {
507 return null
508 } else {
509 return html`
510 <a class="remaining-traits" href="${this.openseaLink}" target="_blank"
511 >+${numRemaining} more</a
512 >
513 `
514 }
515 }
516
517 private buildTraits(traitData: TraitData) {
518 this.traits = {
519 props: [],
520 stats: [],
521 rankings: [],
522 boosts: [],
523 }
524 const { traits: assetTraits, collectionTraits } = traitData
525
526 for (const trait of assetTraits) {
527 const type = getTraitType(trait, collectionTraits)
528
529 const name = trait.trait_type
530
531 this.traits[type + 's'].push({
532 value: trait.value,
533 ...(type === TraitType.Ranking
534 ? { max: collectionTraits[name].max as unknown as number }
535 : {}),
536 trait_type: trait.trait_type,
537 })
538 }
539 }
540}