PluginProbe ʕ •ᴥ•ʔ
Presto Player / trunk
Presto Player vtrunk
4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / dashboard / pages / settings / integrations / EmailCapturePage.js
presto-player / src / admin / dashboard / pages / settings / integrations Last commit date
BunnyNetPage.js 1 month ago EmailCapturePage.js 1 month ago GoogleAnalyticsPage.js 1 month ago WebhooksPage.js 1 month ago YouTubePage.js 1 month ago
EmailCapturePage.js
568 lines
1 import { __ } from '@wordpress/i18n';
2 import { useEffect, useRef, useState } from 'react';
3 import apiFetch from '@wordpress/api-fetch';
4 import {
5 Accordion,
6 Badge,
7 Button,
8 Container,
9 Input,
10 Text,
11 Title,
12 Alert,
13 toast,
14 } from '@bsf/force-ui';
15 import { Download } from 'lucide-react';
16 import SettingsPageShell from '../shared/SettingsPageShell';
17 import SectionCard from '../shared/SectionCard';
18 import ProGate from '../shared/ProGate';
19 import LabelWithTooltip from '../shared/LabelWithTooltip';
20 import useSettingOption from '../../../hooks/useSettingOption';
21 import useRegisterActivePage from '../../../hooks/useRegisterActivePage';
22 import { OPTION_KEYS } from '../config';
23
24 const isPremium = window.prestoPlayer?.isPremium ?? false;
25
26 const isValidHttpUrl = ( value ) => {
27 const trimmed = ( value || '' ).trim();
28 if ( ! trimmed ) {
29 return false;
30 }
31 try {
32 const parsed = new URL( trimmed );
33 return parsed.protocol === 'http:' || parsed.protocol === 'https:';
34 } catch {
35 return false;
36 }
37 };
38
39 const IntegrationHeader = ( { title } ) => (
40 <span className="!text-base !font-semibold m-0 p-0" style={ { color: 'rgb(60, 67, 74)' } }>{ title }</span>
41 );
42
43 const IntegrationCard = ( {
44 title,
45 description,
46 connected,
47 onConnect,
48 onDisconnect,
49 isBusy,
50 isConnectDisabled,
51 error,
52 connectButtonText,
53 disconnectButtonText,
54 children,
55 } ) => {
56 const slug = title.toLowerCase().replace( /[^a-z0-9]+/g, '-' );
57
58 if ( connected ) {
59 return (
60 <Container
61 direction="column"
62 className="bg-background-primary border border-solid border-border-subtle rounded-xl shadow-sm p-5 gap-3"
63 >
64 <Container
65 direction="row"
66 align="center"
67 className="justify-between gap-3"
68 >
69 <Container
70 direction="row"
71 align="center"
72 className="gap-3"
73 >
74 <IntegrationHeader title={ title } />
75 <Badge
76 label={ __( 'Connected', 'presto-player' ) }
77 variant="green"
78 size="xs"
79 type="pill"
80 closable={ false }
81 />
82 </Container>
83 <Button
84 size="sm"
85 variant="outline"
86 onClick={ onDisconnect }
87 disabled={ isBusy }
88 loading={ isBusy }
89 >
90 { disconnectButtonText ||
91 __( 'Disconnect', 'presto-player' ) }
92 </Button>
93 </Container>
94 { error && <Alert variant="error" content={ error } /> }
95 </Container>
96 );
97 }
98
99 return (
100 <Accordion autoClose iconType="arrow" type="boxed">
101 <Accordion.Item
102 value={ slug }
103 className="bg-background-primary border border-solid border-border-subtle rounded-xl shadow-sm overflow-hidden"
104 >
105 <Accordion.Trigger
106 iconType="arrow"
107 className="p-5 hover:bg-transparent"
108 >
109 <IntegrationHeader title={ title } />
110 </Accordion.Trigger>
111 <Accordion.Content className="px-5 pb-5">
112 <Container direction="column" className="gap-4">
113 { error && (
114 <Alert variant="error" content={ error } />
115 ) }
116 { children }
117 { description && (
118 <Text size="sm" className="text-text-secondary">
119 { description }
120 </Text>
121 ) }
122 <Container.Item>
123 <Button
124 size="sm"
125 onClick={ onConnect }
126 disabled={ isBusy || isConnectDisabled }
127 loading={ isBusy }
128 >
129 { connectButtonText ||
130 __( 'Connect', 'presto-player' ) }
131 </Button>
132 </Container.Item>
133 </Container>
134 </Accordion.Content>
135 </Accordion.Item>
136 </Accordion>
137 );
138 };
139
140 // Server-driven paginated export: the backend returns the *next* step number
141 // with each chunk; we re-POST until it replies with 'done'. We deliberately
142 // don't drive this from a for-loop — each chunk is a separate request so the
143 // UI can show progress and the user can navigate away mid-export.
144 const CsvExportSection = () => {
145 const [ step, setStep ] = useState( 0 );
146 const [ progress, setProgress ] = useState( 0 );
147 const [ error, setError ] = useState( '' );
148 const [ url, setUrl ] = useState( '' );
149
150 // Guards against "setState after unmount" warnings if the user navigates
151 // away between chunks. Export may span many seconds for large lists.
152 const mountedRef = useRef( true );
153
154 useEffect(
155 () => () => {
156 mountedRef.current = false;
157 },
158 []
159 );
160
161 useEffect( () => {
162 if ( ! step || step === 'done' ) {
163 return;
164 }
165 ( async () => {
166 try {
167 const {
168 percentage,
169 step: currentStep,
170 url: fetchedURL,
171 } = await apiFetch( {
172 path: '/presto-player/v1/email/export',
173 method: 'POST',
174 data: { step },
175 } );
176 if ( ! mountedRef.current ) {
177 return;
178 }
179 setStep( currentStep );
180 setProgress( percentage );
181 setUrl( fetchedURL );
182 } catch ( e ) {
183 if ( ! mountedRef.current ) {
184 return;
185 }
186 setProgress( 0 );
187 setError( e?.message || __( 'Something went wrong', 'presto-player' ) );
188 }
189 } )();
190 }, [ step ] );
191
192 // `window.open()` is only reliable inside a user gesture — popup blockers
193 // suppress it when called after an async `await`. Two-step flow: first
194 // click kicks off the export; second click on the returned link downloads.
195
196 const startExport = () => {
197 setError( '' );
198 setUrl( '' );
199 setStep( 1 );
200 setProgress( 1 );
201 };
202
203 const isExporting = !! progress && progress < 100 && ! url;
204 const isReady = !! url;
205
206 return (
207 <SectionCard
208 title={ __( 'CSV Export', 'presto-player' ) }
209 titleSize="xs"
210 description={ __(
211 'Using a service not listed here? Export captured contacts to a CSV file and upload them manually.',
212 'presto-player'
213 ) }
214 >
215 <Container direction="column" className="gap-4">
216 { error && <Alert variant="error" content={ error } /> }
217 <Container align="center" className="gap-3">
218 { isReady ? (
219 <Button
220 tag="a"
221 href={ url }
222 target="_blank"
223 rel="noreferrer"
224 size="sm"
225 variant="primary"
226 icon={ <Download className="w-4 h-4" /> }
227 onClick={ () => {
228 setUrl( '' );
229 setProgress( 0 );
230 setStep( 0 );
231 } }
232 >
233 { __( 'Download CSV File', 'presto-player' ) }
234 </Button>
235 ) : (
236 <Button
237 size="sm"
238 variant="primary"
239 icon={ <Download className="w-4 h-4" /> }
240 disabled={ isExporting }
241 loading={ isExporting }
242 onClick={ startExport }
243 >
244 { isExporting
245 ? __( 'Preparing…', 'presto-player' )
246 : __( 'Export to CSV', 'presto-player' ) }
247 </Button>
248 ) }
249 { isExporting && (
250 <Text size="xs" className="text-text-secondary">
251 { progress }%
252 </Text>
253 ) }
254 </Container>
255 </Container>
256 </SectionCard>
257 );
258 };
259
260 const EmailCapturePage = ( { registerActivePage } ) => {
261 const mailchimp = useSettingOption( OPTION_KEYS.mailchimp, {
262 api_key: '',
263 connected: false,
264 } );
265 const mailerlite = useSettingOption( OPTION_KEYS.mailerlite, {
266 api_key: '',
267 connected: false,
268 } );
269 const activecampaign = useSettingOption( OPTION_KEYS.activecampaign, {
270 api_key: '',
271 url: '',
272 connected: false,
273 } );
274 const fluentcrm = useSettingOption( OPTION_KEYS.fluentcrm, {
275 connected: false,
276 } );
277
278 const [ busy, setBusy ] = useState( {} );
279 const [ errors, setErrors ] = useState( {} );
280
281 // Page has no dirty state: connect/disconnect commit server-side immediately.
282 // Registering isDirty:false lets the user navigate freely, while each card
283 // owns its own per-integration busy/error state below.
284 useRegisterActivePage( registerActivePage, {
285 isDirty: false,
286 save: null,
287 discard: null,
288 } );
289
290 // Connect/disconnect calls go to plugin-specific REST endpoints (not
291 // /wp/v2/settings). We still pipe the response through the provider via
292 // option.setData so any later read of the mailchimp/etc. settings sees
293 // the fresh `connected` state without a refetch.
294 const request = async ( key, path, data, option, successMsg ) => {
295 setErrors( ( p ) => ( { ...p, [ key ]: '' } ) );
296 setBusy( ( p ) => ( { ...p, [ key ]: true } ) );
297 try {
298 const response = await apiFetch( { path, method: 'POST', data } );
299 option.setData( ( prev ) => ( { ...( prev || {} ), ...response } ) );
300 toast.success( successMsg || __( 'Success', 'presto-player' ) );
301 } catch ( e ) {
302 const msg = e?.message || __( 'Request failed.', 'presto-player' );
303 setErrors( ( p ) => ( { ...p, [ key ]: msg } ) );
304 toast.error( msg );
305 } finally {
306 setBusy( ( p ) => ( { ...p, [ key ]: false } ) );
307 }
308 };
309
310 return (
311 <SettingsPageShell
312 title={ __( 'Email Capture', 'presto-player' ) }
313 isLoading={ mailchimp.isLoading }
314 canSave={ false }
315 >
316 <ProGate
317 enabled={ isPremium }
318 title={ __( 'Email Capture', 'presto-player' ) }
319 description={ __(
320 'Capture viewer emails straight into your email marketing platform.',
321 'presto-player'
322 ) }
323 >
324 <Container direction="column" className="gap-4">
325 <IntegrationCard
326 title="Mailchimp"
327 description={ __(
328 'Find your API key in your Mailchimp account under Profile → Extras → API keys.',
329 'presto-player'
330 ) }
331 connected={ !! mailchimp.data?.connected }
332 isBusy={ !! busy.mailchimp }
333 isConnectDisabled={
334 ! mailchimp.data?.api_key?.trim()
335 }
336 error={ errors.mailchimp }
337 onConnect={ () =>
338 request(
339 'mailchimp',
340 '/presto-player/v1/mailchimp/connect',
341 { api_key: mailchimp.data?.api_key },
342 mailchimp,
343 __( 'Connected', 'presto-player' )
344 )
345 }
346 onDisconnect={ () =>
347 request(
348 'mailchimp',
349 '/presto-player/v1/mailchimp/disconnect',
350 {},
351 mailchimp,
352 __( 'Disconnected', 'presto-player' )
353 )
354 }
355 >
356 <Container.Item className="flex flex-col gap-2">
357 <LabelWithTooltip
358 label={ __(
359 'Your Mailchimp API Key',
360 'presto-player'
361 ) }
362 tooltip={ __(
363 'Generate this in Mailchimp under Profile → Extras → API keys.',
364 'presto-player'
365 ) }
366 />
367 <Input
368 size="md"
369 type="password"
370 value={ mailchimp.data?.api_key || '' }
371 onChange={ ( val ) =>
372 mailchimp.setData( ( p ) => ( {
373 ...( p || {} ),
374 api_key: val,
375 } ) )
376 }
377 />
378 </Container.Item>
379 </IntegrationCard>
380
381 <IntegrationCard
382 title="MailerLite"
383 description={ __(
384 'Get your API key from MailerLite under Integrations → Developer API.',
385 'presto-player'
386 ) }
387 connected={ !! mailerlite.data?.connected }
388 isBusy={ !! busy.mailerlite }
389 isConnectDisabled={
390 ! mailerlite.data?.api_key?.trim()
391 }
392 error={ errors.mailerlite }
393 onConnect={ () =>
394 request(
395 'mailerlite',
396 '/presto-player/v1/mailerlite/connect',
397 { api_key: mailerlite.data?.api_key },
398 mailerlite,
399 __( 'Connected', 'presto-player' )
400 )
401 }
402 onDisconnect={ () =>
403 request(
404 'mailerlite',
405 '/presto-player/v1/mailerlite/disconnect',
406 {},
407 mailerlite,
408 __( 'Disconnected', 'presto-player' )
409 )
410 }
411 >
412 <Container.Item className="flex flex-col gap-2">
413 <LabelWithTooltip
414 label={ __(
415 'Your MailerLite API Key',
416 'presto-player'
417 ) }
418 tooltip={ __(
419 'Generate this from MailerLite under Integrations → Developer API.',
420 'presto-player'
421 ) }
422 />
423 <Input
424 size="md"
425 type="password"
426 value={ mailerlite.data?.api_key || '' }
427 onChange={ ( val ) =>
428 mailerlite.setData( ( p ) => ( {
429 ...( p || {} ),
430 api_key: val,
431 } ) )
432 }
433 />
434 </Container.Item>
435 </IntegrationCard>
436
437 <IntegrationCard
438 title="ActiveCampaign"
439 description={ __(
440 'Find your account URL and API key in ActiveCampaign under Settings → Developer.',
441 'presto-player'
442 ) }
443 connected={ !! activecampaign.data?.connected }
444 isBusy={ !! busy.activecampaign }
445 isConnectDisabled={
446 ! activecampaign.data?.api_key?.trim() ||
447 ! isValidHttpUrl( activecampaign.data?.url )
448 }
449 error={ errors.activecampaign }
450 onConnect={ () =>
451 request(
452 'activecampaign',
453 '/presto-player/v1/activecampaign/connect',
454 {
455 api_key: activecampaign.data?.api_key,
456 url: activecampaign.data?.url,
457 },
458 activecampaign,
459 __( 'Connected', 'presto-player' )
460 )
461 }
462 onDisconnect={ () =>
463 request(
464 'activecampaign',
465 '/presto-player/v1/activecampaign/disconnect',
466 {},
467 activecampaign,
468 __( 'Disconnected', 'presto-player' )
469 )
470 }
471 >
472 <Container direction="column" className="gap-4">
473 <Container.Item className="flex flex-col gap-2">
474 <LabelWithTooltip
475 label={ __(
476 'Account URL',
477 'presto-player'
478 ) }
479 tooltip={ __(
480 'Your ActiveCampaign API URL, e.g. https://youraccountname.api-us1.com',
481 'presto-player'
482 ) }
483 />
484 <Input
485 size="md"
486 type="url"
487 value={ activecampaign.data?.url || '' }
488 placeholder="https://youraccountname.api-us1.com"
489 onChange={ ( val ) =>
490 activecampaign.setData( ( p ) => ( {
491 ...( p || {} ),
492 url: val,
493 } ) )
494 }
495 />
496 </Container.Item>
497 <Container.Item className="flex flex-col gap-2">
498 <LabelWithTooltip
499 label={ __(
500 'Your ActiveCampaign API Key',
501 'presto-player'
502 ) }
503 tooltip={ __(
504 'Found in ActiveCampaign under Settings → Developer.',
505 'presto-player'
506 ) }
507 />
508 <Input
509 size="md"
510 type="password"
511 value={ activecampaign.data?.api_key || '' }
512 onChange={ ( val ) =>
513 activecampaign.setData( ( p ) => ( {
514 ...( p || {} ),
515 api_key: val,
516 } ) )
517 }
518 />
519 </Container.Item>
520 </Container>
521 </IntegrationCard>
522
523 <IntegrationCard
524 title="FluentCRM"
525 description={ __(
526 'Install and activate the free FluentCRM plugin to capture emails directly into your self-hosted CRM.',
527 'presto-player'
528 ) }
529 connected={ !! fluentcrm.data?.connected }
530 isBusy={ !! busy.fluentcrm }
531 error={ errors.fluentcrm }
532 connectButtonText={ __(
533 'Install FluentCRM Plugin',
534 'presto-player'
535 ) }
536 disconnectButtonText={ __(
537 'Deactivate FluentCRM Plugin',
538 'presto-player'
539 ) }
540 onConnect={ () =>
541 request(
542 'fluentcrm',
543 '/presto-player/v1/fluentcrm/connect',
544 {},
545 fluentcrm,
546 __( 'Installed and connected', 'presto-player' )
547 )
548 }
549 onDisconnect={ () =>
550 request(
551 'fluentcrm',
552 '/presto-player/v1/fluentcrm/disconnect',
553 {},
554 fluentcrm,
555 __( 'Deactivated', 'presto-player' )
556 )
557 }
558 />
559 </Container>
560 </ProGate>
561
562 { isPremium && <CsvExportSection /> }
563 </SettingsPageShell>
564 );
565 };
566
567 export default EmailCapturePage;
568