Skip to content

Commit 62f4dfa

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

2 files changed

Lines changed: 395 additions & 1 deletion

File tree

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
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+
{result.show && (
300+
<div
301+
className={`mt-4 p-3 rounded-md border ${
302+
result.isError
303+
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
304+
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
305+
}`}
306+
>
307+
<strong className="text-sm text-gray-700 dark:text-gray-300">
308+
{texts.paymentResult}
309+
</strong>
310+
<pre className="mt-2 text-xs whitespace-pre-wrap m-0 text-gray-600 dark:text-gray-400">
311+
{result.content}
312+
</pre>
313+
</div>
314+
)}
315+
</div>
316+
317+
<div className="bg-white dark:bg-[#1a1a2e] rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700 min-h-[600px]">
318+
<h3 className="text-xl font-semibold mb-4 text-gray-800 dark:text-white flex items-center gap-2">
319+
<span>&#128179;</span> {texts.previewTitle}
320+
</h3>
321+
{iframeUrl && (
322+
<iframe
323+
src={iframeUrl}
324+
title="Checkout Preview"
325+
className="w-full h-[550px] border-0 rounded-lg bg-gray-50 dark:bg-[#252540]"
326+
/>
327+
)}
328+
</div>
329+
</div>
330+
331+
{/* Code Snippet Section */}
332+
<div className="mt-5 bg-white dark:bg-[#1a1a2e] rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
333+
<div className="flex items-center justify-between mb-4">
334+
<h3 className="text-xl font-semibold text-gray-800 dark:text-white flex items-center gap-2">
335+
<span>&#128187;</span> {texts.codeTitle}
336+
</h3>
337+
<button
338+
type="button"
339+
onClick={copyToClipboard}
340+
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
341+
copied
342+
? 'bg-green-500 text-white'
343+
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
344+
}`}
345+
>
346+
{copied ? (
347+
<span className="flex items-center gap-1">
348+
<svg
349+
className="w-4 h-4"
350+
fill="none"
351+
stroke="currentColor"
352+
viewBox="0 0 24 24"
353+
aria-hidden="true"
354+
>
355+
<path
356+
strokeLinecap="round"
357+
strokeLinejoin="round"
358+
strokeWidth={2}
359+
d="M5 13l4 4L19 7"
360+
/>
361+
</svg>
362+
{texts.copiedButton}
363+
</span>
364+
) : (
365+
<span className="flex items-center gap-1">
366+
<svg
367+
className="w-4 h-4"
368+
fill="none"
369+
stroke="currentColor"
370+
viewBox="0 0 24 24"
371+
aria-hidden="true"
372+
>
373+
<path
374+
strokeLinecap="round"
375+
strokeLinejoin="round"
376+
strokeWidth={2}
377+
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"
378+
/>
379+
</svg>
380+
{texts.copyButton}
381+
</span>
382+
)}
383+
</button>
384+
</div>
385+
<pre className="p-4 bg-gray-900 rounded-lg overflow-x-auto text-sm">
386+
<code className="text-gray-100 whitespace-pre">
387+
{generateCodeSnippet(config)}
388+
</code>
389+
</pre>
390+
</div>
391+
</div>
392+
);
393+
});

0 commit comments

Comments
 (0)