PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 2.8.17 All 260 releases
wpfunnels / admin / modules / setup-wizard / components / Content / ChooseTemplate.vue

ChooseTemplate.vue in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at admin/modules/setup-wizard/components/Content/ChooseTemplate.vue

383 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div class="wpfnl-wz-center wpfnl-wz-templates">
3 <h1 class="wpfnl-wz-h1">Setup your new store checkout</h1>
4 <p class="wpfnl-wz-sub">After setup you can change the text and color or even choose an entirely new store checkout design.</p>
5
6 <p v-if="errorMessage" class="wpfnl-wz-error" role="alert">{{ errorMessage }}</p>
7
8 <!-- Loading skeletons -->
9 <div v-if="loading" class="wpfnl-wz-template-grid" aria-busy="true">
10 <div v-for="n in 3" :key="n" class="wpfnl-wz-template-card is-skeleton">
11 <div class="wpfnl-wz-template-thumb"></div>
12 <div class="wpfnl-wz-skeleton-line"></div>
13 <div class="wpfnl-wz-skeleton-line is-short"></div>
14 </div>
15 </div>
16
17 <p v-else-if="! templates.length" class="wpfnl-wz-empty">
18 No templates are available for this builder yet. You can skip ahead and
19 build your funnel from scratch in the editor.
20 </p>
21
22 <div
23 v-else
24 class="wpfnl-wz-template-grid"
25 :class="{ 'is-busy': isGenerating }"
26 :aria-busy="isGenerating"
27 role="radiogroup"
28 aria-label="Funnel template"
29 >
30 <div
31 v-for="tpl in templates"
32 :key="tpl.ID || tpl.id"
33 class="wpfnl-wz-template-card"
34 :class="{
35 'is-active': selectedTemplateId === ( tpl.ID || tpl.id ),
36 'is-suggested': isSuggested( tpl ),
37 }"
38 role="radio"
39 :aria-checked="selectedTemplateId === ( tpl.ID || tpl.id )"
40 tabindex="0"
41 @click="selectTemplate( tpl )"
42 @keydown.enter.prevent="selectTemplate( tpl )"
43 @keydown.space.prevent="selectTemplate( tpl )"
44 >
45 <div class="wpfnl-wz-template-thumb">
46 <img :src="cardImage( tpl )" :alt="tpl.title" loading="lazy" />
47 <span v-if="isSuggested( tpl )" class="wpfnl-wz-template-badge">
48 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">>
49 <path d="M5 1L6.18 3.4L8.8 3.78L6.9 5.64L7.36 8.25L5 6.99L2.64 8.25L3.1 5.64L1.2 3.78L3.82 3.4L5 1Z" fill="currentColor" stroke="currentColor" stroke-width="0.5" stroke-linejoin="round" />
50 </svg>
51 Suggested
52 </span>
53 <button type="button" class="wpfnl-wz-template-preview" @click.stop="showPreview( tpl )">
54 Preview
55 </button>
56 </div>
57
58 <h2 class="wpfnl-wz-template-title">{{ tpl.title }}</h2>
59 <p class="wpfnl-wz-template-meta">{{ stepCount( tpl ) }} steps</p>
60 </div>
61 </div>
62
63 <PreviewTemplate
64 v-if="showPreviewModal && previewTemplate"
65 :template="previewTemplate"
66 :isStoreCheckout="true"
67 :isSalesFunnel="false"
68 @close="closePreview"
69 @import="importFromPreview"
70 />
71 </div>
72 </template>
73
74 <script>
75 import apiFetch from '@wordpress/api-fetch';
76 import { addQueryArgs } from '@wordpress/url';
77 import PreviewTemplate from './PreviewTemplate.vue';
78 import { generateFunnel } from '../../js/funnel-generator';
79
80 /**
81 * The checkout templates the wizard offers, in the order they appear.
82 *
83 * Matched as lowercase substrings of the template title, since the API returns
84 * titles rather than stable slugs. Anything not listed here is hidden — the
85 * wizard deliberately shows a short, curated set rather than the full library.
86 */
87 const OFFERED_TEMPLATES = [
88 'instant checkout',
89 'express checkout',
90 'store checkout',
91 ];
92
93 export default {
94 name: 'ChooseTemplate',
95 components: {
96 PreviewTemplate,
97 },
98 props: {
99 builder: {
100 type: String,
101 default: 'gutenberg',
102 },
103 prefetchedTemplates: {
104 type: Array,
105 default: () => [],
106 },
107 },
108 emits: [ 'nav', 'next-step' ],
109 data() {
110 return {
111 loading: true,
112 templates: [],
113 selectedTemplateId: null,
114 selectedTemplate: null,
115 showPreviewModal: false,
116 previewTemplate: null,
117 phase: 'select',
118 errorMessage: '',
119 };
120 },
121 computed: {
122 isGenerating() {
123 return 'generating' === this.phase;
124 },
125 },
126 watch: {
127 selectedTemplateId() {
128 this.publishNav();
129 },
130 templates( list ) {
131 // Default to the first (suggested) template so the step opens ready
132 // to proceed instead of with a disabled button.
133 if ( ! this.selectedTemplateId && list?.length ) {
134 this.selectTemplate( list[ 0 ] );
135 }
136 },
137 prefetchedTemplates( value ) {
138 // The prefetch may land after this step mounted.
139 if ( ! this.loading ) {
140 return;
141 }
142 if ( value?.length ) {
143 this.templates = this.filterTemplates( value );
144 this.loading = false;
145 this.publishNav();
146 }
147 },
148 },
149 mounted() {
150 if ( this.prefetchedTemplates?.length ) {
151 this.templates = this.filterTemplates( this.prefetchedTemplates );
152 this.loading = false;
153 } else {
154 this.fetchTemplates();
155 }
156 this.publishNav();
157 },
158 methods: {
159 publishNav() {
160 if ( 'generating' === this.phase ) {
161 this.$emit( 'nav', {
162 label: 'Building...',
163 canProceed: false,
164 busy: true,
165 showBack: false,
166 showNext: true,
167 } );
168 return;
169 }
170
171 this.$emit( 'nav', {
172 label: 'Import & Continue',
173 canProceed: !! this.selectedTemplateId,
174 busy: false,
175 showBack: true,
176 showNext: true,
177 showSkip: true,
178 } );
179 },
180
181 /**
182 * Resolve the builder slug the template API expects. Divi ships its
183 * templates under `divi-builder`, and "Others" has no templates of its
184 * own so it borrows Gutenberg's.
185 *
186 * @return {string} Builder slug.
187 */
188 builderParam() {
189 if ( 'divi' === this.builder ) {
190 return 'divi-builder';
191 }
192 if ( 'others' === this.builder ) {
193 return 'gutenberg';
194 }
195 return this.builder || 'gutenberg';
196 },
197
198 fetchTemplates() {
199 this.loading = true;
200 const builder = this.builderParam();
201
202 this.requestTemplates( builder )
203 .then( ( templates ) => {
204 if ( templates.length ) {
205 this.templates = this.filterTemplates( templates );
206 return null;
207 }
208 // Bricks and Oxygen have thin template libraries; fall back.
209 if ( 'bricks' === builder || 'oxygen' === builder ) {
210 return this.requestTemplates( 'gutenberg' );
211 }
212 return null;
213 } )
214 .then( ( fallback ) => {
215 if ( fallback?.length ) {
216 this.templates = this.filterTemplates( fallback );
217 }
218 } )
219 .catch( ( error ) => {
220 console.error( 'Error fetching templates:', error );
221 } )
222 .finally( () => {
223 this.loading = false;
224 this.publishNav();
225 } );
226 },
227
228 /**
229 * @param {string} builder Builder slug.
230 * @return {Promise<Array>} Templates, or an empty array.
231 */
232 requestTemplates( builder ) {
233 const path = addQueryArgs(
234 `${ window.setup_wizard_obj.rest_api_url }wpfunnels/v1/templates/get_templates`,
235 { builder, type: 'store_checkout' }
236 );
237
238 return apiFetch( { path } ).then( ( response ) =>
239 response.success && response.templates ? response.templates : []
240 );
241 },
242
243 /**
244 * Instant Checkout is the one we steer people towards, so it carries a
245 * badge and is pinned ahead of the ID-ordered rest.
246 *
247 * @param {Object} template Template to test.
248 * @return {boolean} Whether this is the suggested template.
249 */
250 isSuggested( template ) {
251 return ( template?.title || '' ).toLowerCase().includes( 'instant checkout' );
252 },
253
254 /**
255 * Position of a template in the curated list, or -1 if it isn't offered.
256 *
257 * @param {Object} template Template to look up.
258 * @return {number} Index in OFFERED_TEMPLATES.
259 */
260 offeredIndex( template ) {
261 const title = ( template?.title || '' ).toLowerCase();
262 return OFFERED_TEMPLATES.findIndex( ( name ) => title.includes( name ) );
263 },
264
265 /**
266 * Reduce to the curated templates, trimmed to their checkout and
267 * thank-you steps, in the order OFFERED_TEMPLATES lists them.
268 *
269 * @param {Array} templates Raw templates from the API.
270 * @return {Array} Curated, ordered templates.
271 */
272 filterTemplates( templates ) {
273 return templates
274 .filter( ( tpl ) => 'pro' !== tpl.templateType )
275 .filter( ( tpl ) => {
276 const types = ( tpl.steps || [] ).map( ( s ) => s.step_type );
277 return types.includes( 'checkout' ) && types.includes( 'thankyou' );
278 } )
279 .filter( ( tpl ) => this.offeredIndex( tpl ) !== -1 )
280 .map( ( tpl ) => ( {
281 ...tpl,
282 steps: [
283 tpl.steps.find( ( s ) => 'checkout' === s.step_type ),
284 tpl.steps.find( ( s ) => 'thankyou' === s.step_type ),
285 ].filter( Boolean ),
286 } ) )
287 .sort( ( a, b ) => this.offeredIndex( a ) - this.offeredIndex( b ) );
288 },
289
290 cardImage( template ) {
291 const checkout = ( template.steps || [] ).find( ( s ) => 'checkout' === s.step_type );
292 return checkout?.featured_image || template.featured_image;
293 },
294
295 stepCount( template ) {
296 return ( template.steps || [] ).length;
297 },
298
299 selectTemplate( template ) {
300 if ( ! template?.ID && ! template?.id ) {
301 return;
302 }
303 this.selectedTemplateId = template.ID || template.id;
304 this.selectedTemplate = template;
305 },
306
307 showPreview( template ) {
308 this.previewTemplate = template;
309 this.showPreviewModal = true;
310 },
311
312 closePreview() {
313 this.showPreviewModal = false;
314 this.previewTemplate = null;
315 },
316
317 importFromPreview( template ) {
318 this.closePreview();
319 this.selectTemplate( template );
320 this.submit();
321 },
322
323 /**
324 * Move on without importing a template.
325 *
326 * Reported as `skipped` rather than `abandoned` — the user saw the
327 * offer and declined it, which the telemetry treats as a distinct
328 * outcome from dropping out. No funnel is created, so the completion
329 * step adapts its copy off the absent funnel ID.
330 */
331 skip() {
332 const wizardObj = window.setup_wizard_obj || {};
333 const restApiUrl = wizardObj.rest_api_url || '';
334
335 if ( restApiUrl ) {
336 const base = restApiUrl.endsWith( '/' ) ? restApiUrl : `${ restApiUrl }/`;
337 apiFetch( {
338 url: `${ base }wpfunnels/v1/setup-wizard/track-step`,
339 method: 'POST',
340 data: {
341 event_type: 'skipped',
342 step_name: 'choose_template',
343 step_index: 3,
344 goal: 'improve-checkout',
345 time_on_step: 0,
346 total_steps: 4,
347 },
348 } ).catch( () => {} );
349 }
350
351 this.$emit( 'next-step' );
352 },
353
354 /**
355 * Generate the funnel, then hand the wizard the created funnel so the
356 * completion step can link straight to it.
357 */
358 async submit() {
359 if ( ! this.selectedTemplate || 'generating' === this.phase ) {
360 return;
361 }
362
363 this.phase = 'generating';
364 this.errorMessage = '';
365 this.publishNav();
366
367 try {
368 const { funnelId, firstStepLink } = await generateFunnel( {
369 builder: this.builder,
370 template: this.selectedTemplate,
371 } );
372
373 this.$emit( 'next-step', { funnelId, firstStepLink, template: this.selectedTemplate } );
374 } catch ( error ) {
375 this.phase = 'select';
376 this.errorMessage = error?.message || 'Unable to generate the funnel. Please try again.';
377 this.publishNav();
378 }
379 },
380 },
381 };
382 </script>
383