Skip to content

Commit e893ad6

Browse files
committed
feat: add CheckoutCustomizer component and integrate it into the theme index
1 parent a11de8e commit e893ad6

2 files changed

Lines changed: 401 additions & 1 deletion

File tree

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
import { useLang } from '@rspress/core/runtime';
2+
import { memo, useCallback, useEffect, useState } from 'react';
3+
4+
interface CheckoutConfig {
5+
amount: number;
6+
currency: string;
7+
lang: string;
8+
primaryColor: string;
9+
borderRadius: string;
10+
requireEmail: boolean;
11+
}
12+
13+
interface PaymentMessage {
14+
type: 'payment-result' | 'payment-error';
15+
data?: Record<string, unknown>;
16+
error?: string;
17+
}
18+
19+
export const CheckoutCustomizer: React.FC = memo(() => {
20+
const currentLang = useLang();
21+
const isEn = currentLang === 'en';
22+
23+
const [config, setConfig] = useState<CheckoutConfig>({
24+
amount: 50,
25+
currency: 'USD',
26+
lang: currentLang === 'en' ? 'en' : 'es',
27+
primaryColor: '#4f46e5',
28+
borderRadius: '8px',
29+
requireEmail: true,
30+
});
31+
32+
const [iframeUrl, setIframeUrl] = useState('');
33+
const [copied, setCopied] = useState(false);
34+
const [result, setResult] = useState<{
35+
show: boolean;
36+
isError: boolean;
37+
content: string;
38+
}>({
39+
show: false,
40+
isError: false,
41+
content: '',
42+
});
43+
44+
const buildUrl = useCallback((cfg: CheckoutConfig) => {
45+
const baseUrl = 'https://payments.bloque.app/checkout';
46+
const params = new URLSearchParams({
47+
preview: 'true',
48+
amount: cfg.amount.toString(),
49+
currency: cfg.currency,
50+
lang: cfg.lang,
51+
primaryColor: cfg.primaryColor,
52+
borderRadius: cfg.borderRadius,
53+
requireEmail: cfg.requireEmail ? 'true' : 'false',
54+
});
55+
return `${baseUrl}?${params.toString()}`;
56+
}, []);
57+
58+
const updateIframe = useCallback(() => {
59+
const url = buildUrl(config);
60+
setIframeUrl(url);
61+
setResult({ show: false, isError: false, content: '' });
62+
}, [config, buildUrl]);
63+
64+
// biome-ignore lint/correctness/useExhaustiveDependencies: run only on mount
65+
useEffect(() => {
66+
updateIframe();
67+
}, []);
68+
69+
useEffect(() => {
70+
const handleMessage = (event: MessageEvent<PaymentMessage>) => {
71+
if (event.data.type === 'payment-result') {
72+
setResult({
73+
show: true,
74+
isError: false,
75+
content: JSON.stringify(event.data.data, null, 2),
76+
});
77+
}
78+
79+
if (event.data.type === 'payment-error') {
80+
setResult({
81+
show: true,
82+
isError: true,
83+
content: `Error: ${event.data.error}`,
84+
});
85+
}
86+
};
87+
88+
window.addEventListener('message', handleMessage);
89+
return () => window.removeEventListener('message', handleMessage);
90+
}, []);
91+
92+
const handleInputChange = (
93+
field: keyof CheckoutConfig,
94+
value: string | number | boolean,
95+
) => {
96+
setConfig((prev) => ({ ...prev, [field]: value }));
97+
};
98+
99+
const generateCodeSnippet = useCallback(
100+
(cfg: CheckoutConfig) => {
101+
const successComment = isEn ? 'Payment successful!' : 'Pago exitoso!';
102+
const errorComment = isEn ? 'Payment error:' : 'Error en pago:';
103+
104+
return `import { BloqueCheckout } from '@bloque/payments-react';
105+
106+
function CheckoutPage({ checkoutId }: { checkoutId: string }) {
107+
return (
108+
<BloqueCheckout
109+
checkoutId={checkoutId}
110+
lang="${cfg.lang}"
111+
appearance={{
112+
primaryColor: '${cfg.primaryColor}',
113+
borderRadius: '${cfg.borderRadius}',
114+
}}
115+
onSuccess={(data) => {
116+
console.log('${successComment}', data.payment_id);
117+
}}
118+
onError={(error) => {
119+
console.error('${errorComment}', error);
120+
}}
121+
/>
122+
);
123+
}`;
124+
},
125+
[isEn],
126+
);
127+
128+
const copyToClipboard = useCallback(async () => {
129+
const code = generateCodeSnippet(config);
130+
try {
131+
await navigator.clipboard.writeText(code);
132+
setCopied(true);
133+
setTimeout(() => setCopied(false), 2000);
134+
} catch (err) {
135+
console.error('Failed to copy:', err);
136+
}
137+
}, [config, generateCodeSnippet]);
138+
139+
const texts = {
140+
title: isEn ? 'Checkout Configuration' : 'Configuracion del Checkout',
141+
amount: isEn ? 'Amount' : 'Monto',
142+
currency: isEn ? 'Currency' : 'Moneda',
143+
language: isEn ? 'Language' : 'Idioma',
144+
primaryColor: isEn ? 'Primary Color' : 'Color Primario',
145+
borderRadius: 'Border Radius',
146+
requireEmail: isEn ? 'Require Email' : 'Requerir Email',
147+
updateButton: isEn ? 'Update Checkout' : 'Actualizar Checkout',
148+
previewTitle: isEn ? 'Checkout Preview' : 'Vista Previa del Checkout',
149+
paymentResult: isEn ? 'Payment Result:' : 'Resultado del Pago:',
150+
currencies: {
151+
USD: isEn ? 'USD - US Dollar' : 'USD - Dolar Estadounidense',
152+
COP: isEn ? 'COP - Colombian Peso' : 'COP - Peso Colombiano',
153+
},
154+
languages: {
155+
es: isEn ? 'Spanish' : 'Espanol',
156+
en: isEn ? 'English' : 'Ingles',
157+
},
158+
codeTitle: isEn ? 'Code Snippet' : 'Codigo',
159+
copyButton: isEn ? 'Copy' : 'Copiar',
160+
copiedButton: isEn ? 'Copied!' : 'Copiado!',
161+
};
162+
163+
return (
164+
<div className="w-full my-8">
165+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-start">
166+
{/* Config Panel */}
167+
<div className="bg-white dark:bg-[#1a1a2e] rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
168+
<h3 className="text-xl font-semibold mb-5 text-gray-800 dark:text-white flex items-center gap-2">
169+
<span>&#128736;</span> {texts.title}
170+
</h3>
171+
172+
<div className="mb-4">
173+
<label
174+
htmlFor="checkout-amount"
175+
className="block mb-1.5 font-medium text-gray-600 dark:text-gray-300 text-sm"
176+
>
177+
{texts.amount}
178+
</label>
179+
<input
180+
id="checkout-amount"
181+
type="number"
182+
value={config.amount}
183+
onChange={(e) =>
184+
handleInputChange('amount', Number(e.target.value))
185+
}
186+
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-[#252540] text-gray-800 dark:text-white focus:outline-none focus:border-indigo-500"
187+
/>
188+
</div>
189+
190+
<div className="mb-4">
191+
<label
192+
htmlFor="checkout-currency"
193+
className="block mb-1.5 font-medium text-gray-600 dark:text-gray-300 text-sm"
194+
>
195+
{texts.currency}
196+
</label>
197+
<select
198+
id="checkout-currency"
199+
value={config.currency}
200+
onChange={(e) => handleInputChange('currency', e.target.value)}
201+
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-[#252540] text-gray-800 dark:text-white focus:outline-none focus:border-indigo-500"
202+
>
203+
<option value="USD">{texts.currencies.USD}</option>
204+
<option value="COP">{texts.currencies.COP}</option>
205+
</select>
206+
</div>
207+
208+
<div className="mb-4">
209+
<label
210+
htmlFor="checkout-lang"
211+
className="block mb-1.5 font-medium text-gray-600 dark:text-gray-300 text-sm"
212+
>
213+
{texts.language}
214+
</label>
215+
<select
216+
id="checkout-lang"
217+
value={config.lang}
218+
onChange={(e) => handleInputChange('lang', e.target.value)}
219+
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-[#252540] text-gray-800 dark:text-white focus:outline-none focus:border-indigo-500"
220+
>
221+
<option value="es">{texts.languages.es}</option>
222+
<option value="en">{texts.languages.en}</option>
223+
</select>
224+
</div>
225+
226+
<div className="mb-4">
227+
<label
228+
htmlFor="checkout-primaryColor"
229+
className="block mb-1.5 font-medium text-gray-600 dark:text-gray-300 text-sm"
230+
>
231+
{texts.primaryColor}
232+
</label>
233+
<div className="flex gap-2">
234+
<input
235+
type="color"
236+
value={config.primaryColor}
237+
onChange={(e) =>
238+
handleInputChange('primaryColor', e.target.value)
239+
}
240+
className="w-12 h-10 p-1 border border-gray-300 dark:border-gray-600 rounded-md cursor-pointer"
241+
aria-label={texts.primaryColor}
242+
/>
243+
<input
244+
id="checkout-primaryColor"
245+
type="text"
246+
value={config.primaryColor}
247+
onChange={(e) =>
248+
handleInputChange('primaryColor', e.target.value)
249+
}
250+
className="flex-1 px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-[#252540] text-gray-800 dark:text-white focus:outline-none focus:border-indigo-500"
251+
/>
252+
</div>
253+
</div>
254+
255+
<div className="mb-4">
256+
<label
257+
htmlFor="checkout-borderRadius"
258+
className="block mb-1.5 font-medium text-gray-600 dark:text-gray-300 text-sm"
259+
>
260+
{texts.borderRadius}
261+
</label>
262+
<input
263+
id="checkout-borderRadius"
264+
type="text"
265+
value={config.borderRadius}
266+
onChange={(e) =>
267+
handleInputChange('borderRadius', e.target.value)
268+
}
269+
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-[#252540] text-gray-800 dark:text-white focus:outline-none focus:border-indigo-500"
270+
/>
271+
</div>
272+
273+
<div className="mb-4 flex items-center gap-2">
274+
<input
275+
type="checkbox"
276+
id="checkout-requireEmail"
277+
checked={config.requireEmail}
278+
onChange={(e) =>
279+
handleInputChange('requireEmail', e.target.checked)
280+
}
281+
className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
282+
/>
283+
<label
284+
htmlFor="checkout-requireEmail"
285+
className="font-medium text-gray-600 dark:text-gray-300 text-sm cursor-pointer"
286+
>
287+
{texts.requireEmail}
288+
</label>
289+
</div>
290+
291+
<button
292+
type="button"
293+
onClick={updateIframe}
294+
className="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-md text-base transition-colors mt-2"
295+
>
296+
&#128260; {texts.updateButton}
297+
</button>
298+
299+
{iframeUrl && (
300+
<div className="mt-4 p-3 bg-gray-100 dark:bg-[#252540] rounded-md text-xs text-gray-600 dark:text-gray-400 break-all font-mono">
301+
{iframeUrl}
302+
</div>
303+
)}
304+
305+
{result.show && (
306+
<div
307+
className={`mt-4 p-3 rounded-md border ${
308+
result.isError
309+
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
310+
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
311+
}`}
312+
>
313+
<strong className="text-sm text-gray-700 dark:text-gray-300">
314+
{texts.paymentResult}
315+
</strong>
316+
<pre className="mt-2 text-xs whitespace-pre-wrap m-0 text-gray-600 dark:text-gray-400">
317+
{result.content}
318+
</pre>
319+
</div>
320+
)}
321+
</div>
322+
323+
<div className="bg-white dark:bg-[#1a1a2e] rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700 min-h-[600px]">
324+
<h3 className="text-xl font-semibold mb-4 text-gray-800 dark:text-white flex items-center gap-2">
325+
<span>&#128179;</span> {texts.previewTitle}
326+
</h3>
327+
{iframeUrl && (
328+
<iframe
329+
src={iframeUrl}
330+
title="Checkout Preview"
331+
className="w-full h-[550px] border-0 rounded-lg bg-gray-50 dark:bg-[#252540]"
332+
/>
333+
)}
334+
</div>
335+
</div>
336+
337+
{/* Code Snippet Section */}
338+
<div className="mt-5 bg-white dark:bg-[#1a1a2e] rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
339+
<div className="flex items-center justify-between mb-4">
340+
<h3 className="text-xl font-semibold text-gray-800 dark:text-white flex items-center gap-2">
341+
<span>&#128187;</span> {texts.codeTitle}
342+
</h3>
343+
<button
344+
type="button"
345+
onClick={copyToClipboard}
346+
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
347+
copied
348+
? 'bg-green-500 text-white'
349+
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
350+
}`}
351+
>
352+
{copied ? (
353+
<span className="flex items-center gap-1">
354+
<svg
355+
className="w-4 h-4"
356+
fill="none"
357+
stroke="currentColor"
358+
viewBox="0 0 24 24"
359+
aria-hidden="true"
360+
>
361+
<path
362+
strokeLinecap="round"
363+
strokeLinejoin="round"
364+
strokeWidth={2}
365+
d="M5 13l4 4L19 7"
366+
/>
367+
</svg>
368+
{texts.copiedButton}
369+
</span>
370+
) : (
371+
<span className="flex items-center gap-1">
372+
<svg
373+
className="w-4 h-4"
374+
fill="none"
375+
stroke="currentColor"
376+
viewBox="0 0 24 24"
377+
aria-hidden="true"
378+
>
379+
<path
380+
strokeLinecap="round"
381+
strokeLinejoin="round"
382+
strokeWidth={2}
383+
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
384+
/>
385+
</svg>
386+
{texts.copyButton}
387+
</span>
388+
)}
389+
</button>
390+
</div>
391+
<pre className="p-4 bg-gray-900 rounded-lg overflow-x-auto text-sm">
392+
<code className="text-gray-100 whitespace-pre">
393+
{generateCodeSnippet(config)}
394+
</code>
395+
</pre>
396+
</div>
397+
</div>
398+
);
399+
});

0 commit comments

Comments
 (0)