@gudhub/ssg-web-components-library 1.0.24 → 1.0.26

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": "@gudhub/ssg-web-components-library",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -86,13 +86,17 @@ class ArticleComponent extends GHComponent {
86
86
  delete this.article.category;
87
87
 
88
88
  this.breadcrumbs = JSON.stringify([
89
+ {
90
+ "title": "Головна",
91
+ "link": "/blog/"
92
+ },
89
93
  {
90
94
  "title": "Блог",
91
- "slug": "/blog/"
95
+ "link": "/blog/"
92
96
  },
93
97
  {
94
98
  "title": this.article.categories[0].name,
95
- "slug": this.article.categories[0].slug
99
+ "link": this.article.categories[0].slug
96
100
  },
97
101
  {
98
102
  "title": this.article.h1
@@ -1,6 +1,6 @@
1
1
  <section class="author-page">
2
2
  <div class="container">
3
- <breadcrumbs-component data-items='[{"title": "Блог", "slug": "/blog/"}, {"title": "Автори", "slug": "/blog/authors/"}, {"title": "${author.h1}"}]'></breadcrumbs-component>
3
+ <breadcrumbs-component data-items='[{"title": "Головна", "link": "/"},{"title": "Блог", "link": "/blog/"}, {"title": "Автори", "link": "/blog/authors/"}, {"title": "${author.h1}"}]'></breadcrumbs-component>
4
4
  <div class="author">
5
5
  <div class="author_top">
6
6
  <div class="image">
@@ -1,6 +1,6 @@
1
1
  <section class="authors-list-section">
2
2
  <div class="container">
3
- <breadcrumbs-component data-items='[{"title": "Блог", "slug": "/blog/"}, {"title": "Автори"}]'></breadcrumbs-component>
3
+ <breadcrumbs-component data-items='[{"title": "Головна", "link": "/"},{"title": "Блог", "link": "/blog/"}, {"title": "Автори"}]'></breadcrumbs-component>
4
4
  <h1 gh-id="${ghId}">Authors</h1>
5
5
  <div class="authors-list">
6
6
  ${
@@ -39,7 +39,15 @@ class BlogBanner extends GHComponent {
39
39
  let breadcrumbsTitle = document.createElement('div')
40
40
  breadcrumbsTitle.innerHTML = this.json.title;
41
41
 
42
- this.breadcrumbs = JSON.stringify([{"title": breadcrumbsTitle.innerText}]);
42
+ this.breadcrumbs = JSON.stringify([
43
+ {
44
+ title: 'Головна',
45
+ link: '/'
46
+ },
47
+ {
48
+ title: breadcrumbsTitle.innerText
49
+ }
50
+ ]);
43
51
 
44
52
  this.image = this.json.image || false;
45
53
  }
@@ -40,7 +40,19 @@ class CategoryBanner extends GHComponent {
40
40
  this.description = div.querySelector('div').innerText;
41
41
  this.title = item.fields.find(field => field.field_id == window.getConfig().chapters.blog.heading_field_id).field_value;
42
42
 
43
- this.breadcrumbs = JSON.stringify([{"title": this.config.breadcrumbs.blog, "slug": "/blog/"},{"title": this.title}]);
43
+ this.breadcrumbs = JSON.stringify([
44
+ {
45
+ "title": "Головна",
46
+ "link": "/"
47
+ },
48
+ {
49
+ "title": this.config.breadcrumbs.blog,
50
+ "link": "/blog/"
51
+ },
52
+ {
53
+ "title": this.title
54
+ }
55
+ ]);
44
56
 
45
57
  super.render(html);
46
58
  }
@@ -7,13 +7,21 @@ class BreadcrumbsComponent extends GHComponent {
7
7
  }
8
8
 
9
9
  async onServerRender() {
10
- let currentUrl = new URL(window.location.href);
11
- currentUrl = currentUrl.searchParams.get('path');
12
-
13
- this.breadcrumbsConfig = window.getConfig().componentsConfigs.breadcrumbsConfig;
14
- this.initialRoute = this.breadcrumbsConfig[0].routesTree;
10
+ if (this.hasAttribute('data-items')) {
11
+ //manual mode when you passing items in JSON format through attribute "data-items"
12
+ try {
13
+ this.items = JSON.parse(this.getAttribute('data-items'));
14
+ } catch (error) {
15
+ console.error(error);
16
+ }
17
+ }
18
+ else {
19
+ this.breadcrumbsConfig = window.getConfig().componentsConfigs.breadcrumbsConfig;
20
+ this.initialRoute = this.breadcrumbsConfig[0].routesTree;
15
21
 
16
- this.items = this.generateBreadcrumbs(this.initialRoute, currentUrl);
22
+ let currentUrl = new URL(window.location.href);
23
+ currentUrl = currentUrl.searchParams.get('path');
24
+ }
17
25
 
18
26
  this.items === null ? console.error(`Didn't find current route in config, current URL: ${currentUrl}`) : null;
19
27
 
@@ -5,7 +5,7 @@
5
5
  <li>
6
6
  ${item.image
7
7
  ? ` <a href="${item.link}" aria-label="${item.title}">
8
- <image-component lazyload src="${item.image.src}" alt="${item.image.alt}" title="${item.image.title}"></image-component>
8
+ <img src="${item.image.src}" alt="${item.image.alt}" title="${item.image.title}"></img>
9
9
  </a>
10
10
  ` : `
11
11
  <a href="${item.link}">${item.title}</a>
@@ -21,14 +21,18 @@
21
21
  <div class="bold">${config.titleSuccess ? config.titleSuccess : 'Successfull'}</div>
22
22
  <div>${config.subtitleSuccess ? config.subtitleSuccess : 'Your form has been succesfully submitted! Please, check if info you provided is correct:'}</div>
23
23
  <div class="check">
24
- <div class="check_entity">
25
- <span>Email:</span>
26
- <span class="email"></span>
27
- </div>
28
- <div class="check_entity phone_entity">
29
- <span>Phone:</span>
30
- <span class="phone"></span>
31
- </div>
24
+ ${config.inputs.find(input => input.name === "email") ? `
25
+ <div class="check_entity">
26
+ <span>Email:</span>
27
+ <span class="email"></span>
28
+ </div>
29
+ ` : ''}
30
+ ${config.inputs.find(input => input.name === "phone") ? `
31
+ <div class="check_entity phone_entity">
32
+ <span>Phone:</span>
33
+ <span class="phone"></span>
34
+ </div>
35
+ ` : ''}
32
36
  </div>
33
37
  </div>
34
38
  </div>
@@ -1,10 +1,10 @@
1
1
  import html from './get-in-touch-form.html';
2
2
  import './get-in-touch-form.scss';
3
- import { sendEmail } from './sendEmail.js';
4
3
  import defaultConfigs from './get-in-touch-form-data.json';
4
+ import { validationCallbacks } from './validationCallbacks.js';
5
5
 
6
6
  class GetInTouchForm extends GHComponent {
7
-
7
+
8
8
  constructor() {
9
9
  super();
10
10
  this.formId = this.getAttribute("data-form-id");
@@ -21,7 +21,7 @@ class GetInTouchForm extends GHComponent {
21
21
  if (this.hasAttribute('data-in-popup')) {
22
22
  return;
23
23
  }
24
-
24
+
25
25
  this.initConfig(this.config);
26
26
  super.render(html);
27
27
  }
@@ -40,7 +40,7 @@ class GetInTouchForm extends GHComponent {
40
40
  }
41
41
 
42
42
  attachEventListeners() {
43
- this.getElementsByTagName('form')[0].addEventListener('submit', (e) => this.handleSubmit(e.target));
43
+ this.getElementsByTagName('form')[0].addEventListener('submit', (e) => this.handleSubmit(e));
44
44
  this.getElementsByClassName('restart_button')[0].addEventListener('click', (e) => this.hideFail());
45
45
  }
46
46
 
@@ -55,98 +55,106 @@ class GetInTouchForm extends GHComponent {
55
55
 
56
56
  initConfig(formConfigs) {
57
57
  try {
58
- this.config = formConfigs.find(({id}) => id === this.formId);
58
+ this.config = formConfigs.find(({ id }) => id === this.formId);
59
59
  if (!this.config) {
60
- throw e;
60
+ throw new Error("Config not found");
61
61
  }
62
62
  } catch (error) {
63
63
  const defaultId = this.isInPopup ? 'default popup' : 'default';
64
- this.config = defaultConfigs.find(({id}) => id === defaultId);
64
+ this.config = defaultConfigs.find(({ id }) => id === defaultId);
65
65
  }
66
+
66
67
  this.titleName = this.hasAttribute('data-form-title') ? this.getAttribute('data-form-title') : this.config.title;
67
68
  this.subtitleName = this.hasAttribute('data-form-subtitle') ? this.getAttribute('data-form-subtitle') : this.config.subtitle;
68
69
  this.placement = this.hasAttribute('data-form-placement') ? this.getAttribute('data-form-placement') : "main";
69
70
  this.buttonText = this.hasAttribute('data-form-button-text') ? this.getAttribute('data-form-button-text') : this.config.button_text;
70
- }
71
+ };
72
+
73
+ checkInputsValidations = (inputs) => {
74
+ const result = inputs.map((input) => {
75
+ const validationCallback = validationCallbacks[input.name];
76
+ if (!validationCallback) return {
77
+ input,
78
+ isValid: true
79
+ };
71
80
 
72
- async handleSubmit(element) {
73
- event.preventDefault();
81
+ return {
82
+ input,
83
+ isValid: validationCallback(input)
84
+ };
85
+ });
86
+
87
+ return result;
88
+ };
74
89
 
90
+ async handleSubmit(event) {
91
+ event.preventDefault();
92
+ const form = event.target;
93
+
94
+ const inputs = Array.from(form.querySelectorAll('input'));
75
95
  const emailInput = this.querySelector('[name="email"]');
76
- const email = emailInput ? emailInput.value : '';
77
-
78
96
  const phoneInput = this.querySelector('[name="phone"]');
79
- const phone = phoneInput ? phoneInput.value || '' : '';
80
-
81
- const isValidFields = this.validation(email, phone);
82
-
83
- if (isValidFields.phoneValid && isValidFields.emailValid) {
84
- this.addLoader();
85
97
 
86
- const TIMEOUT_DURATION = 5000;
98
+ const validationResults = this.checkInputsValidations(inputs);
87
99
 
88
- const sendEmailWithTimeout = (element, config, placement) => {
89
- return new Promise((resolve, reject) => {
90
- const timer = setTimeout(() => {
91
- reject();
100
+ if (validationResults.every((res) => res.isValid)) {
101
+ this.addLoader();
102
+ try {
103
+ const res = await new Promise((resolve, reject) => {
104
+ const TIMEOUT_DURATION = 2000;
105
+
106
+ setTimeout(() => {
107
+ const isSuccess = true;
108
+ if (isSuccess) {
109
+ resolve(true);
110
+ } else {
111
+ reject(new Error('Failed to send email'));
112
+ }
92
113
  }, TIMEOUT_DURATION);
93
-
94
- sendEmail(element, config, placement)
95
- .then((res) => {
96
- clearTimeout(timer);
97
- resolve(res);
98
- })
99
- .catch((error) => {
100
- clearTimeout(timer);
101
- reject(error);
102
- });
103
114
  });
104
- };
105
-
106
- try {
107
- const res = await sendEmailWithTimeout(element, this.config, this.placement);
108
- this.removeLoader(element);
115
+
116
+ this.removeLoader(form);
117
+
109
118
  if (res) {
110
- this.showSuccess({ email, phone });
119
+ this.showSuccess({ email: emailInput ? emailInput.value : '', phone: phoneInput ? phoneInput.value : '' });
111
120
  } else {
112
121
  this.showFail();
113
122
  }
114
123
  } catch (error) {
115
- this.removeLoader(element);
124
+ this.removeLoader(form);
116
125
  this.showFail();
117
126
  }
118
127
  this.isFormSubmitted = true;
119
-
120
128
  } else {
121
- this.toggleErrors(isValidFields, emailInput, phoneInput);
129
+ const validationResultsForErrors = validationResults.filter(item => typeof item === 'object')
130
+ validationResultsForErrors.forEach(({ input, isValid }) => this.toggleError(input, isValid));
122
131
  }
123
- }
124
132
 
125
- toggleErrors(isValidFields, emailInput, phoneInput) {
126
- isValidFields.emailValid ? emailInput.classList.remove('error') : emailInput.classList.add('error');
127
-
128
- isValidFields.phoneValid ? phoneInput.classList.remove('error') : phoneInput.classList.add('error');
133
+ this.createDataObject(form, this.config, this.placement)
129
134
  }
130
-
131
- validation (email = '', phone = '') {
132
- const isPhoneRequired = this.config.inputs.find(input => input.name === "phone").required;
133
- const isEmailRequired = this.config.inputs.find(input => input.name === "email").required;
134
-
135
- let emailValid;
136
- if (email.length === 0 && isEmailRequired == 'false') {
137
- emailValid = true;
138
- } else {
139
- emailValid = /\S+@\S+\.\S+/.test(email);
140
- }
141
-
142
- let phoneValid;
143
- if (phone.length === 0 && isPhoneRequired == 'false') {
144
- phoneValid = true;
145
- } else {
146
- phoneValid = /^\+?[\d()-\s]+$/.test(phone);
135
+
136
+ async createDataObject(form, formId, placement) {
137
+ const formData = {};
138
+ for (const [name, value] of (new FormData(form)).entries()) {
139
+ formData[name] = value;
147
140
  }
148
141
 
149
- return { phoneValid, emailValid };
142
+ const formDataObj = {
143
+ Website: window.location.hostname,
144
+ Url: window.location.pathname,
145
+ FormId: formId,
146
+ FormPlacement: placement,
147
+ FormData: {
148
+ ...formData
149
+ },
150
+ Referrer: localStorage.getItem('referrer'),
151
+ };
152
+
153
+ window.dispatchEvent(new CustomEvent('submitForm', { detail: { formDataObj } }));
154
+ }
155
+ toggleError(input, isValid) {
156
+ input.classList[isValid ? 'remove' : 'add']('error');
157
+ input.parentElement.classList[isValid ? 'remove' : 'add']('error-input');
150
158
  }
151
159
  async addLoader() {
152
160
  this.classList.add('loading');
@@ -160,7 +168,11 @@ class GetInTouchForm extends GHComponent {
160
168
  }, 500);
161
169
  }
162
170
  async showSuccess({email, phone}) {
163
- this.getElementsByClassName('email')[0].innerText = email;
171
+ if (email) {
172
+ this.querySelector('.check_entity').classList.add('provided');
173
+ this.getElementsByClassName('email')[0].innerText = email;
174
+ }
175
+ this.classList.add('success');
164
176
  if (phone) {
165
177
  this.querySelector('.check_entity.phone_entity').classList.add('provided');
166
178
  this.getElementsByClassName('phone')[0].innerText = phone;
@@ -184,7 +196,6 @@ class GetInTouchForm extends GHComponent {
184
196
  overflowFail.style.opacity = '';
185
197
  }, 500);
186
198
  }
187
-
188
199
  generateInput(config) {
189
200
  return config.inputs.reduce((acc, input) => {
190
201
  const maxSymbols = {
@@ -198,13 +209,14 @@ class GetInTouchForm extends GHComponent {
198
209
  const maxLength = tag === 'textarea' ? maxSymbols.long : maxSymbols[input.type];
199
210
  return acc + `
200
211
  <div class="input-wrap col-${input.width}">
201
- <${tag} type="text" name=${input.name} placeholder="${input.placeholder}" ${JSON.parse(input.required) ? 'required' : ''} ${ maxLength ? `maxlength=${maxLength}` : ''}></${tag}>
212
+ <${tag} type="text" name=${input.name} placeholder="${input.placeholder}" ${JSON.parse(input.required) ? 'required' : ''} ${maxLength ? `maxlength=${maxLength}` : ''}></${tag}>
202
213
  ${input.type === 'email' || input.type === 'phone' ? `<span class="${input.type}-error">${input.errorText}</span>` : ''}
203
214
  </div>
204
- `}, '');
215
+ `;
216
+ }, '');
205
217
  }
206
218
  }
207
219
 
208
- if(!customElements.get('get-in-touch-form')) {
220
+ if (!customElements.get('get-in-touch-form')) {
209
221
  customElements.define('get-in-touch-form', GetInTouchForm);
210
222
  }
@@ -1,18 +1,24 @@
1
- # Config:
1
+ # Config:
2
+
2
3
  The form field settings are defined in the site's config.mjs, when rendered on the server it is stored in "window.getConfig().componentsConfigs.form_config", and this config is also defined in the client's "window.getConfig().componentsConfigs.formConfig" and is used when rendering the form on the client. If the config in "window" is not available, then the config is taken from "get-in-touch-form-data.json". There can be several settings for forms, the form selects it by id (it is defined in the "data-form-id" attribute of the form component), this allows you to customize the fields of several forms in different ways (for example, on the page the form has all the fields, and in the popup the form has other types of fields)
3
4
 
4
5
  # Form in popup:
6
+
5
7
  if form will be rendered in popup we need to define attribute "data-in-popup", that will change some styles of form to fit the popup styles.
6
8
 
7
9
  # Placement:
10
+
8
11
  The "placement" variable is needed to track conversions. If the form is in a popup, then "placement" determined by the button that opened the popup with the form. If the form is already on the page, this value is defined in the form constructor (currently "this.placement = 'main' ").
9
12
 
10
13
  # Data-attributes:
14
+
11
15
  data-in-popup: if form is on popup
12
16
  data-form-id="form-id": determine id of config that will be applyed to form
13
17
 
14
- # Config object:
18
+ # Config object:
19
+
15
20
  ("?" means "unnecessary")
21
+
16
22
  ```json
17
23
  {
18
24
  "id": "string",
@@ -26,6 +32,7 @@ data-form-id="form-id": determine id of config that will be applyed to form
26
32
  "from": "string",
27
33
  "subject": "string"
28
34
  },
35
+ "endpointForEmails": "some fancy url for gudhub API",
29
36
  "inputs": [
30
37
  {
31
38
  "name": "string",
@@ -37,7 +44,9 @@ data-form-id="form-id": determine id of config that will be applyed to form
37
44
  ]
38
45
  },
39
46
  ```
47
+
40
48
  ## Types description:
49
+
41
50
  email: will be checked by email rules;
42
51
  phone: will be checked by phone number rules;
43
52
  short: max length 64 symbols;
@@ -45,4 +54,50 @@ long: max length 128 symbols;
45
54
  textarea: tag "textarea";
46
55
 
47
56
  ## Width:
48
- defines the width of the field in the row. The total width of the row is 12. That is, if 2 fields have a width of 6, then they will take up half of the line each
57
+
58
+ defines the width of the field in the row. The total width of the row is 12. That is, if 2 fields have a width of 6, then they will take up half of the line each
59
+
60
+ ## Correct way to handle submit in your project
61
+
62
+ ```js
63
+ // by default this event handled in your main.js
64
+ window.addEventListener("submitForm", async (event) => {
65
+ const { formDataObj } = event.detail;
66
+
67
+ // add other code here
68
+ });
69
+ ```
70
+
71
+ ### What data contains in formDataObj
72
+
73
+ ```js
74
+ {
75
+ FormData :
76
+ // all data from your form inputs on website
77
+ name : "name"
78
+ phone: "phhone"
79
+ email: 'email'
80
+ FormId:
81
+ // data from your form-config
82
+ button_text:"text"
83
+ defaultLang: "true or false"
84
+ id: "text"
85
+ inputs: Array()
86
+ 0: {name: 'name', type: 'type', required: 'required', placeholder: "placeholder", width: 12}
87
+ 1: {name: 'name', type: 'type', required: 'required', placeholder: 'placeholder *', errorText: 'errorText', …}
88
+ langCode: "text"
89
+ mailConfig:
90
+ from: "from"
91
+ subject: "subject"
92
+ to: "to"
93
+ subtitle: "subtitle"
94
+ subtitleSuccess: "subtitleSuccess"
95
+ title: "title"
96
+ titleFail:"titleFail"
97
+ titleSuccess: "titleSuccess"
98
+ FormPlacement: "FormPlacement"
99
+ Referrer: "Referrer"
100
+ Url: "/"
101
+ Website: "Website"
102
+ }
103
+ ```
@@ -0,0 +1,36 @@
1
+ const emailValidation = (input) => {
2
+ const regex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
3
+
4
+ let isValid = regex.test(input.value);
5
+
6
+ if (!isValid) {
7
+ input.classList.add('error');
8
+ input.parentElement.classList.add('error-input');
9
+ } else {
10
+ input.classList.remove('error');
11
+ input.parentElement.classList.remove('error-input');
12
+ }
13
+
14
+ return isValid;
15
+ };
16
+
17
+ const phoneValidation = (input) => {
18
+ const regex = /^\d{10}$/;
19
+
20
+ let isValid = regex.test(input.value);
21
+
22
+ if (!isValid) {
23
+ input.classList.add('error');
24
+ input.parentElement.classList.add('error-input');
25
+ } else {
26
+ input.classList.remove('error');
27
+ input.parentElement.classList.remove('error-input');
28
+ }
29
+
30
+ return isValid;
31
+ };
32
+
33
+ export const validationCallbacks = {
34
+ email: emailValidation,
35
+ phone: phoneValidation
36
+ };
@@ -53,26 +53,47 @@ class ImageComponent extends GHComponent {
53
53
  }
54
54
  })
55
55
 
56
- await new Promise(async (resolve) => {
57
- // Use new Image to have ability get params like from real image, for example naturalWidth
58
- this.image = new Image();
59
-
60
- this.image.addEventListener('load', () => {
61
- const srcHasParams = this.image.getAttribute('src').indexOf('?') !== -1;
62
- let src = srcHasParams ? this.image.getAttribute('src').substring(0, image.getAttribute('src').indexOf('?')) : this.image.getAttribute('src');
63
- if(src.indexOf('&') !== -1) {
64
- src = src.substring(0, src.indexOf('&'))
65
- }
66
- this.extension = src.substring(src.lastIndexOf('.'), src.length);
67
- this.path = src.substring(0, src.length - this.extension.length);
68
-
69
- this.imageWidth = this.image.naturalWidth;
70
- resolve();
71
- });
72
-
73
- this.image.setAttribute('src', this.src);
74
- })
75
- super.render(html);
56
+ let attempts = 0;
57
+ let imageLoaded = false;
58
+
59
+ while (!imageLoaded && attempts < 5) {
60
+ try {
61
+ await new Promise(async (resolve, reject) => {
62
+ // Use new Image to have ability get params like from real image, for example naturalWidth
63
+ this.image = new Image();
64
+
65
+ this.image.addEventListener('load', () => {
66
+ const srcHasParams = this.image.getAttribute('src').indexOf('?') !== -1;
67
+ let src = srcHasParams ? this.image.getAttribute('src').substring(0, this.image.getAttribute('src').indexOf('?')) : this.image.getAttribute('src');
68
+ if (src.indexOf('&') !== -1) {
69
+ src = src.substring(0, src.indexOf('&'))
70
+ }
71
+ this.extension = src.substring(src.lastIndexOf('.'), src.length);
72
+ this.path = src.substring(0, src.length - this.extension.length);
73
+
74
+ this.imageWidth = this.image.naturalWidth;
75
+
76
+ imageLoaded = true;
77
+
78
+ resolve();
79
+ });
80
+
81
+ this.image.onerror = () => {
82
+ reject();
83
+ };
84
+
85
+ this.image.setAttribute('src', this.src);
86
+ });
87
+ } catch (error) {
88
+ console.error(`11111111111111111 Image load failed "${this.src}". Attempt "${attempts + 1}"`);
89
+ attempts++;
90
+ }
91
+ }
92
+ if (imageLoaded) {
93
+ super.render(html);
94
+ } else {
95
+ console.error(`11111111111111111 Image load failed "${this.src}".`);
96
+ }
76
97
  // caller == 'client' ? this.clientRender() : super.render(html);
77
98
  }
78
99
 
@@ -0,0 +1,5 @@
1
+ export const masonryGallery = {
2
+ tag: 'masonry-gallery',
3
+ src: '@gudhub/ssg-web-components-library/src/components/masonry-gallery/masonry-gallery.js',
4
+ serverOnly: false
5
+ }
@@ -0,0 +1,29 @@
1
+ <section>
2
+ <div class="container">
3
+ ${json.title ? `
4
+ <h2 class="h2" gh-id="${ghId}.title"></h2>
5
+ ` : ''}
6
+ ${json.subtitle ? `
7
+ <p class="subtitle" gh-id="${ghId}.subtitle"></p>
8
+ ` : ''}
9
+
10
+ <div class="masonry-grid"></div>
11
+
12
+ <div class="button-wrapper">
13
+ ${json.button ? `
14
+ <button id="grid-add-items" class="btn" gh-id="${ghId}.button"></button>
15
+ ` : ''}
16
+ </div>
17
+
18
+ <div id="modal">
19
+ <div class="modal-loader">
20
+ <svg viewBox="25 25 50 50">
21
+ <circle r="20" cy="50" cx="50"></circle>
22
+ </svg>
23
+ </div>
24
+ <div class="close-modal">
25
+ <span>&times;</span>
26
+ </div>
27
+ <img class="modal-img" src="">
28
+ </div>
29
+ </section>