tonder-web-sdk 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tonder-web-sdk",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "tonder sdk for integrations",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -1,4 +1,4 @@
1
- import { cardTemplate } from '../helpers/template.js'
1
+ import { cardItemsTemplate, cardTemplate } from '../helpers/template.js'
2
2
  import { cardTemplateSkeleton } from '../helpers/template-skeleton.js'
3
3
  import {
4
4
  getBusiness,
@@ -6,11 +6,16 @@ import {
6
6
  createOrder,
7
7
  createPayment,
8
8
  startCheckoutRouter,
9
- getOpenpayDeviceSessionID
9
+ getOpenpayDeviceSessionID,
10
+ getCustomerCards,
11
+ registerCard,
12
+ deleteCustomerCard
10
13
  } from '../data/api';
11
14
  import {
12
15
  showError,
13
16
  getBrowserInfo,
17
+ mapCards,
18
+ showMessage,
14
19
  } from '../helpers/utils';
15
20
  import { initSkyflow } from '../helpers/skyflow'
16
21
  import { ThreeDSHandler } from './3dsHandler.js';
@@ -18,6 +23,7 @@ import { ThreeDSHandler } from './3dsHandler.js';
18
23
 
19
24
  export class InlineCheckout {
20
25
  static injected = false;
26
+ static cardsInjected = false
21
27
  customer = {}
22
28
  items = []
23
29
  baseUrl = null
@@ -26,6 +32,18 @@ export class InlineCheckout {
26
32
  cartTotal = null
27
33
  metadata = {}
28
34
  card = {}
35
+ collectorIds = {
36
+ cardsListContainer: "cardsListContainer",
37
+ holderName: "collectCardholderName",
38
+ cardNumber: "collectCardNumber",
39
+ expirationMonth: "collectExpirationMonth",
40
+ expirationYear: "collectExpirationYear",
41
+ cvv: "collectCvv",
42
+ tonderPayButton: "tonderPayButton",
43
+ msgError: "msgError",
44
+ msgNotification: "msgNotification"
45
+
46
+ }
29
47
 
30
48
  constructor({
31
49
  mode = "stage",
@@ -34,7 +52,7 @@ export class InlineCheckout {
34
52
  successUrl,
35
53
  renderPaymentButton = false,
36
54
  callBack = () => { },
37
- styles,
55
+ styles
38
56
  }) {
39
57
  this.apiKeyTonder = apiKey;
40
58
  this.returnUrl = returnUrl;
@@ -172,6 +190,9 @@ export class InlineCheckout {
172
190
  this.cartItems = items
173
191
  }
174
192
 
193
+ setCustomerEmail (email) {
194
+ this.email = email
195
+ }
175
196
  setCartTotal(total) {
176
197
  console.log('total: ', total)
177
198
  this.cartTotal = total
@@ -215,13 +236,14 @@ export class InlineCheckout {
215
236
  let checkoutContainer = document.querySelector("#global-loader");
216
237
  if (checkoutContainer) {
217
238
  checkoutContainer.innerHTML = cardTemplateSkeleton;
239
+ checkoutContainer.style.display = 'block';
218
240
  }
219
241
  }
220
242
 
221
243
  #removeGlobalLoader() {
222
244
  const loader = document.querySelector('#global-loader');
223
245
  if (loader) {
224
- loader.remove();
246
+ loader.style.display = 'none';
225
247
  }
226
248
  }
227
249
 
@@ -245,13 +267,25 @@ export class InlineCheckout {
245
267
  return await customerRegister(this.baseUrl, this.apiKeyTonder, customer, signal);
246
268
  }
247
269
 
248
- async #mountTonder() {
270
+ async #mountTonder(getCards = true) {
249
271
  this.#mountPayButton()
250
272
  try{
251
273
  const {
252
274
  vault_id,
253
275
  vault_url,
254
276
  } = await this.#fetchMerchantData();
277
+ if(this.email && getCards){
278
+ const customerResponse = await this.getCustomer({email: this.email});
279
+ if("auth_token" in customerResponse) {
280
+ const { auth_token } = customerResponse
281
+ const cards = await getCustomerCards(this.baseUrl, auth_token);
282
+
283
+ if("cards" in cards) {
284
+ const cardsMapped = cards.cards.map(mapCards)
285
+ this.#loadCardsList(cardsMapped, auth_token)
286
+ }
287
+ }
288
+ }
255
289
 
256
290
  this.collectContainer = await initSkyflow(
257
291
  vault_id,
@@ -274,6 +308,7 @@ export class InlineCheckout {
274
308
 
275
309
  removeCheckout() {
276
310
  InlineCheckout.injected = false
311
+ InlineCheckout.cardsInjected = false
277
312
  // Cancel all requests
278
313
  this.abortController.abort();
279
314
  this.abortController = new AbortController();
@@ -285,7 +320,7 @@ export class InlineCheckout {
285
320
  async #getCardTokens() {
286
321
  if (this.card?.skyflow_id) return this.card
287
322
  try {
288
- const collectResponse = await this.collectContainer.collect();
323
+ const collectResponse = await this.collectContainer.container.collect();
289
324
  const cardTokens = await collectResponse["records"][0]["fields"];
290
325
  return cardTokens;
291
326
  } catch (error) {
@@ -319,7 +354,23 @@ export class InlineCheckout {
319
354
  this.customer,
320
355
  this.abortController.signal
321
356
  )
357
+ if(auth_token && this.email){
358
+ const saveCard = document.getElementById("save-checkout-card");
359
+ if(saveCard && "checked" in saveCard && saveCard.checked){
360
+ await registerCard(this.baseUrl, auth_token, { skyflow_id: cardTokens.skyflow_id });
361
+
362
+ this.cardsInjected = false;
363
+
364
+ const cards = await getCustomerCards(this.baseUrl, auth_token);
365
+ if("cards" in cards) {
366
+ const cardsMapped = cards.cards.map((card) => mapCards(card))
367
+ this.#loadCardsList(cardsMapped, auth_token)
368
+ }
322
369
 
370
+ showMessage("Tarjeta registrada con éxito", this.collectorIds.msgNotification);
371
+
372
+ }
373
+ }
323
374
  var orderItems = {
324
375
  business: this.apiKeyTonder,
325
376
  client: auth_token,
@@ -401,4 +452,81 @@ export class InlineCheckout {
401
452
  throw error;
402
453
  }
403
454
  };
455
+
456
+ #loadCardsList (cards, token) {
457
+ if(this.cardsInjected) return;
458
+ const injectInterval = setInterval(() => {
459
+ const queryElement = document.querySelector(`#${this.collectorIds.cardsListContainer}`);
460
+ if (queryElement && InlineCheckout.injected) {
461
+ queryElement.innerHTML = cardItemsTemplate(cards)
462
+ clearInterval(injectInterval)
463
+ this.#mountRadioButtons(token)
464
+ this.cardsInjected = true
465
+ }
466
+ }, 500);
467
+ }
468
+
469
+ #mountRadioButtons (token) {
470
+ const radioButtons = document.getElementsByName(`card_selected`);
471
+ for (const radio of radioButtons) {
472
+ radio.style.display = "block";
473
+ radio.onclick = async (event) => {
474
+ await this.#handleRadioButtonClick(radio);
475
+ };
476
+ }
477
+ const cardsButtons = document.getElementsByClassName("card-delete-button");
478
+ for (const cardButton of cardsButtons) {
479
+ cardButton.addEventListener("click", (event) => {
480
+ event.preventDefault();
481
+ this.#handleDeleteCardButtonClick(token, cardButton)
482
+ }, false);
483
+ }
484
+ }
485
+
486
+ async #handleRadioButtonClick (radio) {
487
+ if(radio.id === this.radioChecked || ( radio.id === "new" && this.radioChecked === undefined)) return;
488
+ const containerForm = document.querySelector(".container-form");
489
+ if(containerForm) {
490
+ containerForm.style.display = radio.id === "new" ? "block" : "none";
491
+ }
492
+ if(radio.id === "new") {
493
+ if(this.radioChecked !== radio.id) {
494
+ this.#addGlobalLoader()
495
+ this.#mountTonder(false);
496
+ InlineCheckout.injected = true;
497
+ }
498
+ } else {
499
+ this.#unmountForm();
500
+ }
501
+ this.radioChecked = radio.id;
502
+ }
503
+
504
+ async #handleDeleteCardButtonClick (customerToken, button) {
505
+ const id = button.attributes.getNamedItem("id")
506
+ const skyflow_id = id?.value?.split("_")?.[2]
507
+ if(skyflow_id) {
508
+ const cardClicked = document.querySelector(`#card_container-${skyflow_id}`);
509
+ if(cardClicked) {
510
+ cardClicked.style.display = "none"
511
+ }
512
+ await deleteCustomerCard(this.baseUrl, customerToken, skyflow_id)
513
+ this.cardsInjected = false
514
+ const cards = await getCustomerCards(this.baseUrl, customerToken)
515
+ if("cards" in cards) {
516
+ const cardsMapped = cards.cards.map(mapCards)
517
+ this.#loadCardsList(cardsMapped, customerToken)
518
+ }
519
+ }
520
+ }
521
+
522
+ #unmountForm () {
523
+ InlineCheckout.injected = false
524
+ if(this.collectContainer) {
525
+ if("unmount" in this.collectContainer.elements.cardHolderNameElement) this.collectContainer.elements.cardHolderNameElement.unmount()
526
+ if("unmount" in this.collectContainer.elements.cardNumberElement) this.collectContainer.elements.cardNumberElement.unmount()
527
+ if("unmount" in this.collectContainer.elements.expiryYearElement) this.collectContainer.elements.expiryYearElement.unmount()
528
+ if("unmount" in this.collectContainer.elements.expiryMonthElement) this.collectContainer.elements.expiryMonthElement.unmount()
529
+ if("unmount" in this.collectContainer.elements.cvvElement) this.collectContainer.elements.cvvElement.unmount()
530
+ }
531
+ }
404
532
  }
package/src/data/api.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { buildErrorResponse, buildErrorResponseFromCatch } from "../helpers/utils";
2
+
1
3
  export async function getOpenpayDeviceSessionID(merchant_id, public_key, signal) {
2
4
  let openpay = await window.OpenPay;
3
5
  openpay.setId(merchant_id);
@@ -107,3 +109,64 @@ export async function startCheckoutRouter(baseUrlTonder, apiKeyTonder, routerIte
107
109
  throw error
108
110
  }
109
111
  }
112
+
113
+ export async function registerCard(baseUrlTonder, customerToken, data) {
114
+ try {
115
+ const response = await fetch(`${baseUrlTonder}/api/v1/cards/`, {
116
+ method: 'POST',
117
+ headers: {
118
+ 'Authorization': `Token ${customerToken}`,
119
+ 'Content-Type': 'application/json'
120
+ },
121
+ body: JSON.stringify(data)
122
+ });
123
+
124
+ if (response.ok) return await response.json();
125
+ if (response.status === 409){
126
+ const res_json = await response.json()
127
+ if(res_json.error = 'Card number already exists.'){
128
+ return {
129
+ code: 200,
130
+ body: res_json,
131
+ name: '',
132
+ message: res_json.error,
133
+ }
134
+ }
135
+ }
136
+ throw await buildErrorResponse(response);
137
+ } catch (error) {
138
+ throw buildErrorResponseFromCatch(error);
139
+ }
140
+ }
141
+ export async function deleteCustomerCard(baseUrlTonder, customerToken, skyflowId = "") {
142
+ try {
143
+ const response = await fetch(`${baseUrlTonder}/api/v1/cards/${skyflowId}`, {
144
+ method: 'DELETE',
145
+ headers: {
146
+ 'Authorization': `Token ${customerToken}`,
147
+ 'Content-Type': 'application/json'
148
+ },
149
+ });
150
+
151
+ if (response.ok) return true;
152
+ throw await buildErrorResponse(response);
153
+ } catch (error) {
154
+ throw buildErrorResponseFromCatch(error);
155
+ }
156
+ }
157
+ export async function getCustomerCards(baseUrlTonder, customerToken, query = "") {
158
+ try {
159
+ const response = await fetch(`${baseUrlTonder}/api/v1/cards/${query}`, {
160
+ method: 'GET',
161
+ headers: {
162
+ 'Authorization': `Token ${customerToken}`,
163
+ 'Content-Type': 'application/json'
164
+ },
165
+ });
166
+
167
+ if (response.ok) return await response.json();
168
+ throw await buildErrorResponse(response);
169
+ } catch (error) {
170
+ throw buildErrorResponseFromCatch(error);
171
+ }
172
+ }
@@ -6,7 +6,8 @@ export async function initSkyflow(
6
6
  baseUrl,
7
7
  apiKey,
8
8
  signal,
9
- customStyles = {}
9
+ customStyles = {},
10
+ collectorIds,
10
11
  ) {
11
12
  const skyflow = await Skyflow.init({
12
13
  vaultID: vaultId,
@@ -121,7 +122,16 @@ export async function initSkyflow(
121
122
  cardHolderNameElement,
122
123
  )
123
124
 
124
- return collectContainer
125
+ return {
126
+ container: collectContainer,
127
+ elements: {
128
+ cardHolderNameElement,
129
+ cardNumberElement,
130
+ cvvElement,
131
+ expiryMonthElement,
132
+ expiryYearElement
133
+ }
134
+ }
125
135
  }
126
136
 
127
137
  async function mountElements(
@@ -1,14 +1,33 @@
1
+ import { getCardType } from "./utils";
2
+
1
3
  export const cardTemplate = `
2
4
  <div class="container-tonder">
5
+ <div id="cardsListContainer" class="cards-list-container"></div>
6
+ <div class="pay-new-card">
7
+ <input checked id="new" class="card_selected" name="card_selected" type="radio"/>
8
+ <label class="card-item-label-new" for="new">
9
+ <img class="card-image" src="${getCardType("XXXX")}" />
10
+ <div class="card-number">Pagar con tarjeta</div>
11
+ </label>
12
+ </div>
3
13
  <div id="global-loader" class="global-loader"></div>
4
- <div id="collectCardholderName" class="empty-div"></div>
5
- <div id="collectCardNumber" class="empty-div"></div>
6
- <div class="collect-row">
7
- <div id="collectExpirationMonth" class="empty-div"></div>
8
- <div id="collectExpirationYear" class="expiration-year"></div>
9
- <div id="collectCvv" class="empty-div"></div>
14
+ <div class="container-form">
15
+ <div id="collectCardholderName" class="empty-div"></div>
16
+ <div id="collectCardNumber" class="empty-div"></div>
17
+ <div class="collect-row">
18
+ <div id="collectExpirationMonth" class="empty-div"></div>
19
+ <div id="collectExpirationYear" class="expiration-year"></div>
20
+ <div id="collectCvv" class="empty-div"></div>
21
+ </div>
22
+ <div class="checkbox">
23
+ <input id="save-checkout-card" type="checkbox">
24
+ <label for="save-checkout-card">
25
+ Guardar tarjeta para futuros pagos
26
+ </label>
27
+ </div>
28
+ <div id="msgError"></div>
29
+ <div id="msgNotification"></div>
10
30
  </div>
11
- <div id="msgError"></div>
12
31
  <button id="tonderPayButton" class="pay-button">Pagar</button>
13
32
  </div>
14
33
 
@@ -57,6 +76,16 @@ export const cardTemplate = `
57
76
  text-align: left !important;
58
77
  }
59
78
 
79
+ .message-container{
80
+ color: #3bc635 !important;
81
+ background-color: #DAFCE4 !important;
82
+ margin-bottom: 13px !important;
83
+ font-size: 80% !important;
84
+ padding: 8px 10px !important;
85
+ border-radius: 10px !important;
86
+ text-align: left !important;
87
+ }
88
+
60
89
  .pay-button {
61
90
  font-size: 16px;
62
91
  font-weight: bold;
@@ -117,5 +146,297 @@ export const cardTemplate = `
117
146
  }
118
147
  }
119
148
 
149
+ .checkbox label {
150
+ margin-left: 10px;
151
+ font-size: '12px';
152
+ font-weight: '500';
153
+ color: #1D1D1D;
154
+ }
155
+
156
+ .checkbox {
157
+ margin-top: 10px;
158
+ margin-bottom: 20px;
159
+ text-align: left;
160
+ padding: 0 10px;
161
+ }
162
+
163
+ .cards-list-container {
164
+ display: flex;
165
+ flex-direction: column;
166
+ padding: 0px 10px 0px 10px;
167
+ gap: 33% 20px;
168
+ }
169
+
170
+ .pay-new-card {
171
+ display: flex;
172
+ justify-content: start;
173
+ align-items: center;
174
+ color: #1D1D1D;
175
+ gap: 33% 20px;
176
+ margin-top: 10px;
177
+ margin-bottom: 10px;
178
+ padding-left: 10px;
179
+ padding-right: 10px;
180
+ width: 90%;
181
+ }
182
+
183
+ .pay-new-card .card-number {
184
+ font-size: 16px;
185
+ }
186
+ .card-image {
187
+ width: 39px;
188
+ height: 24px;
189
+ text-align: left;
190
+ }
191
+
192
+ .card-item-label-new {
193
+ display: flex;
194
+ justify-content: start;
195
+ align-items: center;
196
+ color: #1D1D1D;
197
+ gap: 33% 20px;
198
+ margin-top: 10px;
199
+ margin-bottom: 10px;
200
+ padding-left: 10px;
201
+ padding-right: 10px;
202
+ width: 90%;
203
+ }
204
+
205
+ .card_selected {
206
+ position: relative;
207
+ width: 16px;
208
+ height: 16px;
209
+ appearance: none;
210
+ cursor: pointer;
211
+ border-radius: 100%;
212
+ border: 1px #3bc635 solid;
213
+ color: #3bc635;
214
+ transition-property: all;
215
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
216
+ transition-duration: 150ms;
217
+ }
218
+
219
+ .card_selected:before {
220
+ width: 8px;
221
+ height: 8px;
222
+ content: "";
223
+ position: absolute;
224
+ top: 50%;
225
+ left: 50%;
226
+ display: block;
227
+ transform: translate(-50%, -50%);
228
+ border-radius: 100%;
229
+ background-color: #3bc635;
230
+ opacity: 0;
231
+ transition-property: opacity;
232
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
233
+ transition-duration: 150ms;
234
+ }
235
+
236
+ .card_selected:checked {
237
+ border: 1px #3bc635 solid;
238
+ position: relative;
239
+ width: 16px;
240
+ height: 16px;
241
+ appearance: none;
242
+ cursor: pointer;
243
+ border-radius: 100%;
244
+ color: #3bc635;
245
+ transition-property: all;
246
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
247
+ transition-duration: 150ms;
248
+ }
249
+
250
+ .card_selected:checked:before {
251
+ content: "";
252
+ border: 1px #3bc635 solid;
253
+ width: 8px;
254
+ height: 8px;
255
+ position: absolute;
256
+ top: 50%;
257
+ left: 50%;
258
+ display: block;
259
+ transform: translate(-50%, -50%);
260
+ border-radius: 100%;
261
+ background-color: #3bc635;
262
+ opacity: 50;
263
+ transition-property: opacity;
264
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
265
+ transition-duration: 150ms;
266
+ }
267
+
268
+ .card_selected:hover:before {
269
+ width: 8px;
270
+ height: 8px;
271
+ content: "";
272
+ position: absolute;
273
+ top: 50%;
274
+ left: 50%;
275
+ display: block;
276
+ transform: translate(-50%, -50%);
277
+ border-radius: 100%;
278
+ background-color: #3bc635;
279
+ transition-property: opacity;
280
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
281
+ transition-duration: 150ms;
282
+ opacity: 10;
283
+ }
120
284
  </style>
121
285
  `
286
+
287
+ export const cardItemsTemplate = (cards) => {
288
+
289
+ const cardItemsHTML = cards.reduce((total, card) => {
290
+ return `${total}
291
+ <div class="card-item" id="card_container-${card.skyflow_id}">
292
+ <input id="${card.skyflow_id}" class="card_selected" name="card_selected" type="radio"/>
293
+ <label class="card-item-label" for="${card.skyflow_id}">
294
+ <img class="card-image" src="${getCardType(card.card_scheme)}" />
295
+ <div class="card-number">${card.card_number}</div>
296
+ <div class="card-expiration">Exp. ${card.expiration_month}/${card.expiration_year}</div>
297
+ <div class="card-delete-icon">
298
+ <button id="delete_button_${card.skyflow_id}" class="card-delete-button">
299
+ <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px">
300
+ <path fill="currentColor" d="M292.309-140.001q-30.308 0-51.308-21t-21-51.308V-720h-40v-59.999H360v-35.384h240v35.384h179.999V-720h-40v507.691q0 30.308-21 51.308t-51.308 21H292.309ZM376.155-280h59.999v-360h-59.999v360Zm147.691 0h59.999v-360h-59.999v360Z"/>
301
+ </svg>
302
+ </button>
303
+ </div>
304
+ </label>
305
+ </div>`
306
+ }, ``);
307
+
308
+ const cardItemStyle = `
309
+ <style>
310
+ .card-item-label {
311
+ display: flex;
312
+ justify-content: space-between;
313
+ align-items: center;
314
+ color: #1D1D1D;
315
+ gap: 33% 20px;
316
+ margin-top: 10px;
317
+ margin-bottom: 10px;
318
+ padding-left: 10px;
319
+ padding-right: 10px;
320
+ width: 90%;
321
+ }
322
+
323
+ .card-item {
324
+ display: flex;
325
+ justify-content: start;
326
+ align-items: center;
327
+ gap: 33% 20px;
328
+ }
329
+
330
+ .card-item .card-number {
331
+ font-size: 16px;
332
+ }
333
+
334
+ .card-item .card-expiration {
335
+ font-size: 16px;
336
+ }
337
+
338
+ .card-image {
339
+ width: 39px;
340
+ height: 24px;
341
+ text-align: left;
342
+ }
343
+
344
+ .card-delete-button {
345
+ background-color: transparent !important;
346
+ color: #000000 !important;
347
+ border: none;
348
+ }
349
+
350
+ .card-delete-button:hover {
351
+ background-color: transparent !important;
352
+ color: #D91C1C !important;
353
+ }
354
+
355
+ .card_selected {
356
+ position: relative;
357
+ width: 16px;
358
+ height: 16px;
359
+ appearance: none;
360
+ cursor: pointer;
361
+ border-radius: 100%;
362
+ border: 1px #3bc635 solid;
363
+ color: #3bc635;
364
+ transition-property: all;
365
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
366
+ transition-duration: 150ms;
367
+ }
368
+
369
+ .card_selected:before {
370
+ width: 8px;
371
+ height: 8px;
372
+ content: "";
373
+ position: absolute;
374
+ top: 50%;
375
+ left: 50%;
376
+ display: block;
377
+ transform: translate(-50%, -50%);
378
+ border-radius: 100%;
379
+ background-color: #3bc635;
380
+ opacity: 0;
381
+ transition-property: opacity;
382
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
383
+ transition-duration: 150ms;
384
+ }
385
+
386
+ .card_selected:checked {
387
+ border: 1px #3bc635 solid;
388
+ position: relative;
389
+ width: 16px;
390
+ height: 16px;
391
+ appearance: none;
392
+ cursor: pointer;
393
+ border-radius: 100%;
394
+ color: #3bc635;
395
+ transition-property: all;
396
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
397
+ transition-duration: 150ms;
398
+ }
399
+
400
+ .card_selected:checked:before {
401
+ content: "";
402
+ border: 1px #3bc635 solid;
403
+ width: 8px;
404
+ height: 8px;
405
+ position: absolute;
406
+ top: 50%;
407
+ left: 50%;
408
+ display: block;
409
+ transform: translate(-50%, -50%);
410
+ border-radius: 100%;
411
+ background-color: #3bc635;
412
+ opacity: 50;
413
+ transition-property: opacity;
414
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
415
+ transition-duration: 150ms;
416
+ }
417
+
418
+ .card_selected:hover:before {
419
+ width: 8px;
420
+ height: 8px;
421
+ content: "";
422
+ position: absolute;
423
+ top: 50%;
424
+ left: 50%;
425
+ display: block;
426
+ transform: translate(-50%, -50%);
427
+ border-radius: 100%;
428
+ background-color: #3bc635;
429
+ transition-property: opacity;
430
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
431
+ transition-duration: 150ms;
432
+ opacity: 10;
433
+ }
434
+
435
+ </style>
436
+ `
437
+ const cardItem = `
438
+ ${cardItemsHTML}
439
+ ${cardItemStyle}
440
+ `
441
+ return cardItem;
442
+ }