@product7/feedback-sdk 1.5.2 → 1.5.4

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.
@@ -13,6 +13,7 @@ export class SurveyWidget extends BaseWidget {
13
13
  lowLabel: options.lowLabel || null,
14
14
  highLabel: options.highLabel || null,
15
15
  customQuestions: options.customQuestions || [],
16
+ pages: Array.isArray(options.pages) ? options.pages : [],
16
17
  respondentId: options.respondentId || null,
17
18
  email: options.email || null,
18
19
  onSubmit: options.onSubmit || null,
@@ -23,6 +24,8 @@ export class SurveyWidget extends BaseWidget {
23
24
  score: null,
24
25
  feedback: '',
25
26
  customAnswers: {},
27
+ pageAnswers: {},
28
+ currentPageIndex: 0,
26
29
  isVisible: false,
27
30
  };
28
31
  }
@@ -53,9 +56,19 @@ export class SurveyWidget extends BaseWidget {
53
56
  }
54
57
 
55
58
  _renderSurvey() {
56
- this._closeSurvey();
59
+ this._closeSurvey(false);
57
60
 
58
61
  const config = this._getSurveyConfig();
62
+ const isMultiPage = this._isMultiPageSurvey();
63
+ const isLastPage = this._isLastPage();
64
+ const pageProgress = isMultiPage
65
+ ? `Page ${this.surveyState.currentPageIndex + 1} of ${this.surveyOptions.pages.length}`
66
+ : '';
67
+ const submitLabel = isMultiPage && !isLastPage ? 'Next' : 'Submit';
68
+ const backButton =
69
+ isMultiPage && this.surveyState.currentPageIndex > 0
70
+ ? '<button class="feedback-survey-back">Back</button>'
71
+ : '';
59
72
 
60
73
  if (this.surveyOptions.position === 'center') {
61
74
  this.backdropElement = document.createElement('div');
@@ -73,11 +86,15 @@ export class SurveyWidget extends BaseWidget {
73
86
  <button class="feedback-survey-close">&times;</button>
74
87
  <h3 class="feedback-survey-title">${config.title}</h3>
75
88
  <p class="feedback-survey-description">${config.description}</p>
89
+ ${isMultiPage ? `<div class="feedback-survey-progress">${pageProgress}</div>` : ''}
76
90
  <div class="feedback-survey-content">${config.html}</div>
77
91
  <div class="feedback-survey-feedback">
78
- <textarea class="feedback-survey-textarea" placeholder="Any additional feedback? (optional)"></textarea>
92
+ <textarea class="feedback-survey-textarea" placeholder="Any additional feedback? (optional)">${this.surveyState.feedback || ''}</textarea>
93
+ </div>
94
+ <div class="feedback-survey-actions">
95
+ ${backButton}
96
+ <button class="feedback-survey-submit">${submitLabel}</button>
79
97
  </div>
80
- <button class="feedback-survey-submit">Submit</button>
81
98
  `;
82
99
 
83
100
  document.body.appendChild(this.surveyElement);
@@ -93,6 +110,10 @@ export class SurveyWidget extends BaseWidget {
93
110
  }
94
111
 
95
112
  _getSurveyConfig() {
113
+ if (this._isMultiPageSurvey()) {
114
+ return this._getCurrentPageConfig();
115
+ }
116
+
96
117
  const configs = {
97
118
  nps: {
98
119
  title:
@@ -166,6 +187,166 @@ export class SurveyWidget extends BaseWidget {
166
187
  return configs[this.surveyOptions.surveyType] || configs.nps;
167
188
  }
168
189
 
190
+ _isMultiPageSurvey() {
191
+ return (
192
+ Array.isArray(this.surveyOptions.pages) &&
193
+ this.surveyOptions.pages.length > 0
194
+ );
195
+ }
196
+
197
+ _getCurrentPage() {
198
+ if (!this._isMultiPageSurvey()) return null;
199
+ return this.surveyOptions.pages[this.surveyState.currentPageIndex] || null;
200
+ }
201
+
202
+ _isLastPage() {
203
+ if (!this._isMultiPageSurvey()) return true;
204
+ return this.surveyState.currentPageIndex >= this.surveyOptions.pages.length - 1;
205
+ }
206
+
207
+ _getCurrentPageConfig() {
208
+ const page = this._getCurrentPage();
209
+ if (!page) {
210
+ return this._getFallbackSurveyConfig();
211
+ }
212
+
213
+ return {
214
+ title: page.title || this.surveyOptions.title || 'Quick Feedback',
215
+ description:
216
+ page.description ||
217
+ this.surveyOptions.description ||
218
+ 'Help us improve by answering this question.',
219
+ html: this._renderSurveyPage(page),
220
+ };
221
+ }
222
+
223
+ _getFallbackSurveyConfig() {
224
+ return {
225
+ title: this.surveyOptions.title || 'Quick Feedback',
226
+ description:
227
+ this.surveyOptions.description ||
228
+ 'Help us improve by answering a few questions.',
229
+ html: this._renderCustomQuestions(),
230
+ };
231
+ }
232
+
233
+ _renderSurveyPage(page) {
234
+ switch (page.type) {
235
+ case 'rating':
236
+ return this._renderRatingPage(page);
237
+ case 'multiple_choice':
238
+ return this._renderMultipleChoicePage(page);
239
+ case 'text':
240
+ return this._renderTextPage(page);
241
+ default:
242
+ return this._renderTextPage(page);
243
+ }
244
+ }
245
+
246
+ _renderRatingPage(page) {
247
+ const pageId = page.id || `page_${this.surveyState.currentPageIndex}`;
248
+ const config = page.ratingConfig || page.rating_config || {};
249
+ const scale = Number(config.scale) || 5;
250
+ const ratingType = config.survey_type || this.surveyOptions.surveyType || 'csat';
251
+ const pageAnswer = this.surveyState.pageAnswers[pageId] || {};
252
+ const currentRating = pageAnswer.rating;
253
+
254
+ const labels = `
255
+ <div class="feedback-survey-labels">
256
+ <span>${config.low_label || this.surveyOptions.lowLabel || ''}</span>
257
+ <span>${config.high_label || this.surveyOptions.highLabel || ''}</span>
258
+ </div>
259
+ `;
260
+
261
+ if (scale === 11 || ratingType === 'nps') {
262
+ return `
263
+ <div class="feedback-survey-nps" data-page-id="${pageId}">
264
+ ${[...Array(11).keys()]
265
+ .map((n) => {
266
+ const selected = currentRating === n ? ' selected' : '';
267
+ return `<button class="feedback-survey-page-rating-btn feedback-survey-nps-btn${selected}" data-page-id="${pageId}" data-score="${n}">${n}</button>`;
268
+ })
269
+ .join('')}
270
+ </div>
271
+ ${labels}
272
+ `;
273
+ }
274
+
275
+ if (ratingType === 'emoji' && scale === 5) {
276
+ const emojis = [
277
+ '\uD83D\uDE1E',
278
+ '\uD83D\uDE15',
279
+ '\uD83D\uDE10',
280
+ '\uD83D\uDE42',
281
+ '\uD83D\uDE04',
282
+ ];
283
+ return `
284
+ <div class="feedback-survey-csat" data-page-id="${pageId}">
285
+ ${emojis
286
+ .map((emoji, i) => {
287
+ const score = i + 1;
288
+ const selected = currentRating === score ? ' selected' : '';
289
+ return `<button class="feedback-survey-page-rating-btn feedback-survey-csat-btn${selected}" data-page-id="${pageId}" data-score="${score}">${emoji}</button>`;
290
+ })
291
+ .join('')}
292
+ </div>
293
+ ${labels}
294
+ `;
295
+ }
296
+
297
+ return `
298
+ <div class="feedback-survey-ces" data-page-id="${pageId}">
299
+ ${[...Array(scale).keys()]
300
+ .map((i) => {
301
+ const score = i + 1;
302
+ const selected = currentRating === score ? ' selected' : '';
303
+ return `<button class="feedback-survey-page-rating-btn feedback-survey-ces-btn${selected}" data-page-id="${pageId}" data-score="${score}">${score}</button>`;
304
+ })
305
+ .join('')}
306
+ </div>
307
+ ${labels}
308
+ `;
309
+ }
310
+
311
+ _renderMultipleChoicePage(page) {
312
+ const pageId = page.id || `page_${this.surveyState.currentPageIndex}`;
313
+ const config = page.multipleChoiceConfig || page.multiple_choice_config || {};
314
+ const options = Array.isArray(config.options) ? config.options : [];
315
+ const allowMultiple = config.allow_multiple === true || config.multiple === true;
316
+ const pageAnswer = this.surveyState.pageAnswers[pageId] || {};
317
+ const selectedValues = Array.isArray(pageAnswer.values)
318
+ ? pageAnswer.values
319
+ : pageAnswer.value
320
+ ? [pageAnswer.value]
321
+ : [];
322
+
323
+ return `
324
+ <div class="feedback-survey-multiple-choice" data-page-id="${pageId}" data-multiple="${allowMultiple}">
325
+ ${options
326
+ .map((option, index) => {
327
+ const value =
328
+ option.value || option.id || option.key || `option_${index}`;
329
+ const label = option.label || option.text || String(value);
330
+ const selected = selectedValues.includes(value) ? ' selected' : '';
331
+ return `<button class="feedback-survey-page-choice-btn${selected}" data-page-id="${pageId}" data-value="${value}">${label}</button>`;
332
+ })
333
+ .join('')}
334
+ </div>
335
+ `;
336
+ }
337
+
338
+ _renderTextPage(page) {
339
+ const pageId = page.id || `page_${this.surveyState.currentPageIndex}`;
340
+ const pageAnswer = this.surveyState.pageAnswers[pageId] || {};
341
+ const value = pageAnswer.text || '';
342
+
343
+ return `
344
+ <div class="feedback-survey-text-page" data-page-id="${pageId}">
345
+ <textarea class="feedback-survey-page-textarea" data-page-id="${pageId}" placeholder="Type your answer...">${value}</textarea>
346
+ </div>
347
+ `;
348
+ }
349
+
169
350
  _renderCustomQuestions() {
170
351
  if (
171
352
  !this.surveyOptions.customQuestions ||
@@ -238,6 +419,11 @@ export class SurveyWidget extends BaseWidget {
238
419
  );
239
420
  submitBtn.addEventListener('click', () => this._handleSubmit());
240
421
 
422
+ const backBtn = this.surveyElement.querySelector('.feedback-survey-back');
423
+ if (backBtn) {
424
+ backBtn.addEventListener('click', () => this._handleBack());
425
+ }
426
+
241
427
  const textarea = this.surveyElement.querySelector(
242
428
  '.feedback-survey-textarea'
243
429
  );
@@ -256,6 +442,11 @@ export class SurveyWidget extends BaseWidget {
256
442
  }
257
443
 
258
444
  _attachTypeSpecificEvents() {
445
+ if (this._isMultiPageSurvey()) {
446
+ this._attachCurrentPageEvents();
447
+ return;
448
+ }
449
+
259
450
  const type = this.surveyOptions.surveyType;
260
451
 
261
452
  if (type === 'nps') {
@@ -317,6 +508,87 @@ export class SurveyWidget extends BaseWidget {
317
508
  }
318
509
  }
319
510
 
511
+ _attachCurrentPageEvents() {
512
+ const page = this._getCurrentPage();
513
+ if (!page || !this.surveyElement) return;
514
+ const pageId = page.id || `page_${this.surveyState.currentPageIndex}`;
515
+
516
+ if (page.type === 'rating') {
517
+ this.surveyElement
518
+ .querySelectorAll('.feedback-survey-page-rating-btn')
519
+ .forEach((btn) => {
520
+ btn.addEventListener('click', () => {
521
+ const score = parseInt(btn.dataset.score);
522
+ if (Number.isNaN(score)) return;
523
+ this._setPageAnswer(pageId, { rating: score });
524
+
525
+ this.surveyElement
526
+ .querySelectorAll('.feedback-survey-page-rating-btn')
527
+ .forEach((item) => item.classList.remove('selected'));
528
+ btn.classList.add('selected');
529
+ });
530
+ });
531
+ }
532
+
533
+ if (page.type === 'multiple_choice') {
534
+ const container = this.surveyElement.querySelector(
535
+ '.feedback-survey-multiple-choice'
536
+ );
537
+ const allowMultiple = container
538
+ ? container.dataset.multiple === 'true'
539
+ : false;
540
+
541
+ this.surveyElement
542
+ .querySelectorAll('.feedback-survey-page-choice-btn')
543
+ .forEach((btn) => {
544
+ btn.addEventListener('click', () => {
545
+ const value = btn.dataset.value;
546
+ if (!value) return;
547
+
548
+ const existing = this.surveyState.pageAnswers[pageId] || {};
549
+ const existingValues = Array.isArray(existing.values)
550
+ ? existing.values
551
+ : existing.value
552
+ ? [existing.value]
553
+ : [];
554
+
555
+ let nextValues = [];
556
+ if (allowMultiple) {
557
+ const hasValue = existingValues.includes(value);
558
+ nextValues = hasValue
559
+ ? existingValues.filter((v) => v !== value)
560
+ : [...existingValues, value];
561
+ } else {
562
+ nextValues = [value];
563
+ }
564
+
565
+ this._setPageAnswer(pageId, {
566
+ value: nextValues[0] || null,
567
+ values: nextValues,
568
+ });
569
+
570
+ if (!allowMultiple) {
571
+ this.surveyElement
572
+ .querySelectorAll('.feedback-survey-page-choice-btn')
573
+ .forEach((item) => item.classList.remove('selected'));
574
+ }
575
+ btn.classList.toggle('selected', nextValues.includes(value));
576
+ });
577
+ });
578
+ }
579
+
580
+ if (page.type === 'text') {
581
+ const textarea = this.surveyElement.querySelector(
582
+ '.feedback-survey-page-textarea'
583
+ );
584
+ if (textarea) {
585
+ textarea.addEventListener('input', (e) => {
586
+ this._setPageAnswer(pageId, { text: e.target.value });
587
+ });
588
+ }
589
+ }
590
+ }
591
+
320
592
  _selectNPS(score) {
321
593
  this.surveyState.score = score;
322
594
  this.surveyElement
@@ -375,7 +647,21 @@ export class SurveyWidget extends BaseWidget {
375
647
  async _handleSubmit() {
376
648
  const type = this.surveyOptions.surveyType;
377
649
 
650
+ if (this._isMultiPageSurvey()) {
651
+ if (!this._validateCurrentPage()) {
652
+ return;
653
+ }
654
+
655
+ const nextPageIndex = this._getNextPageIndex();
656
+ if (nextPageIndex !== -1 && nextPageIndex !== this.surveyState.currentPageIndex) {
657
+ this.surveyState.currentPageIndex = nextPageIndex;
658
+ this._renderSurvey();
659
+ return;
660
+ }
661
+ }
662
+
378
663
  if (
664
+ !this._isMultiPageSurvey() &&
379
665
  (type === 'nps' || type === 'csat' || type === 'ces') &&
380
666
  this.surveyState.score === null
381
667
  ) {
@@ -384,20 +670,28 @@ export class SurveyWidget extends BaseWidget {
384
670
  }
385
671
 
386
672
  const respondent = this._getRespondentContext();
673
+ const normalizedPageAnswers = this._normalizePageAnswersForSubmit();
674
+ const mergedAnswers = {
675
+ ...this.surveyState.customAnswers,
676
+ ...(Object.keys(normalizedPageAnswers).length > 0 && {
677
+ page_answers: normalizedPageAnswers,
678
+ }),
679
+ };
387
680
 
388
681
  const responseData = {
389
- rating: this.surveyState.score,
682
+ rating: this._getSubmissionRating(),
390
683
  feedback: this.surveyState.feedback,
391
- answers: this.surveyState.customAnswers,
684
+ answers: mergedAnswers,
392
685
  ...(respondent.respondentId && { respondentId: respondent.respondentId }),
393
686
  ...(respondent.email && { email: respondent.email }),
394
687
  };
395
688
 
396
689
  const response = {
397
690
  type: type,
398
- score: this.surveyState.score,
691
+ score: this._getSubmissionRating(),
399
692
  feedback: this.surveyState.feedback,
400
- customAnswers: this.surveyState.customAnswers,
693
+ customAnswers: mergedAnswers,
694
+ pageAnswers: normalizedPageAnswers,
401
695
  timestamp: new Date().toISOString(),
402
696
  };
403
697
 
@@ -419,6 +713,138 @@ export class SurveyWidget extends BaseWidget {
419
713
  this._showSuccessNotification();
420
714
  }
421
715
 
716
+ _handleBack() {
717
+ if (!this._isMultiPageSurvey()) return;
718
+ if (this.surveyState.currentPageIndex <= 0) return;
719
+ this.surveyState.currentPageIndex -= 1;
720
+ this._renderSurvey();
721
+ }
722
+
723
+ _setPageAnswer(pageId, data) {
724
+ if (!pageId) return;
725
+ this.surveyState.pageAnswers[pageId] = {
726
+ ...(this.surveyState.pageAnswers[pageId] || {}),
727
+ ...data,
728
+ };
729
+ }
730
+
731
+ _validateCurrentPage() {
732
+ const page = this._getCurrentPage();
733
+ if (!page || page.required === false) return true;
734
+ const pageId = page.id || `page_${this.surveyState.currentPageIndex}`;
735
+
736
+ const answer = this.surveyState.pageAnswers[pageId] || {};
737
+
738
+ if (page.type === 'rating') {
739
+ if (typeof answer.rating !== 'number') {
740
+ this._showError('Please select a rating');
741
+ return false;
742
+ }
743
+ }
744
+
745
+ if (page.type === 'multiple_choice') {
746
+ const hasSingle = Boolean(answer.value);
747
+ const hasMany = Array.isArray(answer.values) && answer.values.length > 0;
748
+ if (!hasSingle && !hasMany) {
749
+ this._showError('Please select an option');
750
+ return false;
751
+ }
752
+ }
753
+
754
+ if (page.type === 'text') {
755
+ if (!answer.text || !String(answer.text).trim()) {
756
+ this._showError('Please enter an answer');
757
+ return false;
758
+ }
759
+ }
760
+
761
+ return true;
762
+ }
763
+
764
+ _getNextPageIndex() {
765
+ if (!this._isMultiPageSurvey()) {
766
+ return -1;
767
+ }
768
+
769
+ const page = this._getCurrentPage();
770
+ const total = this.surveyOptions.pages.length;
771
+ const currentIndex = this.surveyState.currentPageIndex;
772
+ const fallbackNext = currentIndex + 1 < total ? currentIndex + 1 : -1;
773
+ const navigation = page ? page.afterThisPage || page.after_this_page : null;
774
+ if (!navigation) {
775
+ return fallbackNext;
776
+ }
777
+
778
+ const nextValue = navigation.default;
779
+ if (!nextValue) {
780
+ return fallbackNext;
781
+ }
782
+
783
+ if (nextValue === 'end_survey') {
784
+ return -1;
785
+ }
786
+ if (nextValue === 'next_page' || nextValue === 'next') {
787
+ return fallbackNext;
788
+ }
789
+
790
+ if (typeof nextValue === 'number') {
791
+ return nextValue >= 0 && nextValue < total ? nextValue : fallbackNext;
792
+ }
793
+
794
+ if (typeof nextValue === 'string') {
795
+ const normalizedId = nextValue.replace(/^page:/, '');
796
+ const pageIndex = this.surveyOptions.pages.findIndex(
797
+ (item) => item.id === normalizedId
798
+ );
799
+ return pageIndex >= 0 ? pageIndex : fallbackNext;
800
+ }
801
+
802
+ return fallbackNext;
803
+ }
804
+
805
+ _getSubmissionRating() {
806
+ if (typeof this.surveyState.score === 'number') {
807
+ return this.surveyState.score;
808
+ }
809
+
810
+ if (!this._isMultiPageSurvey()) {
811
+ return null;
812
+ }
813
+
814
+ for (const page of this.surveyOptions.pages) {
815
+ const pageId = page.id || `page_${this.surveyOptions.pages.indexOf(page)}`;
816
+ const answer = this.surveyState.pageAnswers[pageId];
817
+ if (answer && typeof answer.rating === 'number') {
818
+ return answer.rating;
819
+ }
820
+ }
821
+
822
+ return null;
823
+ }
824
+
825
+ _normalizePageAnswersForSubmit() {
826
+ const output = {};
827
+ for (const [pageId, answer] of Object.entries(this.surveyState.pageAnswers)) {
828
+ if (answer == null) continue;
829
+ if (Array.isArray(answer.values) && answer.values.length > 0) {
830
+ output[pageId] = answer.values;
831
+ continue;
832
+ }
833
+ if (answer.value != null && answer.value !== '') {
834
+ output[pageId] = answer.value;
835
+ continue;
836
+ }
837
+ if (typeof answer.rating === 'number') {
838
+ output[pageId] = answer.rating;
839
+ continue;
840
+ }
841
+ if (typeof answer.text === 'string' && answer.text.trim()) {
842
+ output[pageId] = answer.text.trim();
843
+ }
844
+ }
845
+ return output;
846
+ }
847
+
422
848
  _getRespondentContext() {
423
849
  const sdkUserContext =
424
850
  typeof this.sdk.getUserContext === 'function'
@@ -497,7 +923,7 @@ export class SurveyWidget extends BaseWidget {
497
923
  }, 3000);
498
924
  }
499
925
 
500
- _closeSurvey() {
926
+ _closeSurvey(resetState = true) {
501
927
  if (this._escapeHandler) {
502
928
  document.removeEventListener('keydown', this._escapeHandler);
503
929
  this._escapeHandler = null;
@@ -530,12 +956,16 @@ export class SurveyWidget extends BaseWidget {
530
956
  this.backdropElement = null;
531
957
  }
532
958
 
533
- this.surveyState = {
534
- score: null,
535
- feedback: '',
536
- customAnswers: {},
537
- isVisible: false,
538
- };
959
+ if (resetState) {
960
+ this.surveyState = {
961
+ score: null,
962
+ feedback: '',
963
+ customAnswers: {},
964
+ pageAnswers: {},
965
+ currentPageIndex: 0,
966
+ isVisible: false,
967
+ };
968
+ }
539
969
  }
540
970
 
541
971
  destroy() {
package/types/index.d.ts CHANGED
@@ -61,11 +61,48 @@ declare module '@product7/feedback-sdk' {
61
61
  options?: Array<{ value: string; label: string }>;
62
62
  }
63
63
 
64
+ export interface SurveyRatingConfig {
65
+ survey_type?: string;
66
+ scale?: number;
67
+ low_label?: string | null;
68
+ high_label?: string | null;
69
+ }
70
+
71
+ export interface SurveyMultipleChoiceOption {
72
+ id?: string;
73
+ key?: string;
74
+ value?: string;
75
+ label?: string;
76
+ text?: string;
77
+ }
78
+
79
+ export interface SurveyMultipleChoiceConfig {
80
+ options?: SurveyMultipleChoiceOption[];
81
+ allow_multiple?: boolean;
82
+ multiple?: boolean;
83
+ }
84
+
85
+ export interface SurveyPage {
86
+ id?: string;
87
+ type?: 'rating' | 'multiple_choice' | 'text' | string;
88
+ title?: string;
89
+ description?: string;
90
+ required?: boolean;
91
+ position?: number;
92
+ ratingConfig?: SurveyRatingConfig | null;
93
+ rating_config?: SurveyRatingConfig | null;
94
+ multipleChoiceConfig?: SurveyMultipleChoiceConfig | null;
95
+ multiple_choice_config?: SurveyMultipleChoiceConfig | null;
96
+ afterThisPage?: { default?: string | number } | null;
97
+ after_this_page?: { default?: string | number } | null;
98
+ }
99
+
64
100
  export interface SurveyWidgetResponse {
65
101
  type: SurveyType;
66
102
  score: number | null;
67
103
  feedback: string;
68
104
  customAnswers: Record<string, any>;
105
+ pageAnswers?: Record<string, any>;
69
106
  timestamp: string;
70
107
  }
71
108
 
@@ -78,6 +115,7 @@ declare module '@product7/feedback-sdk' {
78
115
  lowLabel?: string | null;
79
116
  highLabel?: string | null;
80
117
  customQuestions?: SurveyQuestion[];
118
+ pages?: SurveyPage[];
81
119
  respondentId?: string | null;
82
120
  email?: string | null;
83
121
  theme?: 'light' | 'dark';