1 | import { css, customElement, html, LitElement, property } from 'lit-element'
|
2 | import { styleMap } from 'lit-html/directives/style-map'
|
3 | import { classMap } from 'lit-html/directives/class-map'
|
4 |
|
5 | import './info-button'
|
6 | import { Trait, TraitData, Traits, TraitType } from './types'
|
7 |
|
8 | import { formatTraitType, getTraitType } from './utils'
|
9 |
|
10 | const TRAIT_HEADER_HEIGHT = 42
|
11 | const TRAIT_HEADER_MARGIN_BOTTOM = 8
|
12 |
|
13 | const RANK_HEIGHT = 40
|
14 | const RANK_MARGIN = 10
|
15 | const rankStyle = {
|
16 | height: RANK_HEIGHT + 'px',
|
17 | marginBottom: RANK_MARGIN + 'px',
|
18 | }
|
19 |
|
20 | const PROP_HEIGHT = 50
|
21 | const PROP_MARGIN = RANK_MARGIN
|
22 | const propStyle = {
|
23 | height: PROP_HEIGHT + 'px',
|
24 | marginBottom: PROP_MARGIN + 'px',
|
25 | }
|
26 |
|
27 | const BOOST_HEIGHT = RANK_HEIGHT
|
28 | const BOOST_MARGIN = RANK_MARGIN
|
29 | const BOOST_PADDING = RANK_MARGIN
|
30 | const boostStyle = {
|
31 | height: BOOST_HEIGHT + 'px',
|
32 | }
|
33 |
|
34 | const STAT_HEIGHT = PROP_HEIGHT
|
35 | const STAT_MARGIN = RANK_MARGIN
|
36 | const statStyle = {
|
37 | height: RANK_HEIGHT + 'px',
|
38 | marginBottom: RANK_MARGIN + 'px',
|
39 | }
|
40 |
|
41 | const 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')
|
49 | export 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 |
|
246 |
|
247 |
|
248 | changedProperties.forEach(async (_oldValue: string, propName: string) => {
|
249 | if (propName === 'traitData') {
|
250 | this.buildTraits(this.traitData)
|
251 |
|
252 |
|
253 | this.loading = false
|
254 |
|
255 |
|
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 |
|
280 |
|
281 | containerHeight = this.cardInnerHeight
|
282 | ? (this.cardInnerHeight - traitHeaderHeight * 3) / 3
|
283 | : 100
|
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
|
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
|
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
|
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 | }
|