ok
This commit is contained in:
336
src/Checkout.js
336
src/Checkout.js
@@ -1,77 +1,277 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styles from './Checkout.module.css';
|
import styles from './Checkout.module.css';
|
||||||
|
|
||||||
const Checkout = () => {
|
import { QRCodeCanvas } from 'qrcode.react';
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '1rem',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}>
|
|
||||||
<div className={styles.checkoutCard}>
|
|
||||||
{/* Product List */}
|
|
||||||
<div className={styles.cartSection}>
|
|
||||||
<h2 className={styles.cartTitle}>Your Cart</h2>
|
|
||||||
<ul className={styles.cartList}>
|
|
||||||
<li className={styles.cartItem}>
|
|
||||||
<div className={styles.itemDetails}>
|
|
||||||
<img
|
|
||||||
src="https://via.placeholder.com/60"
|
|
||||||
alt="Product 1"
|
|
||||||
className={styles.productImage}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className={styles.itemText}>Pure Kit</p>
|
|
||||||
<p className={styles.itemPrice}>$65.00</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className={styles.removeBtn} aria-label="Remove Pure Kit">×</button>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li className={styles.cartItem}>
|
function parseJwt(token) {
|
||||||
<div className={styles.itemDetails}>
|
try {
|
||||||
<img
|
const base64Url = token.split('.')[1];
|
||||||
src="https://via.placeholder.com/60"
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
alt="Product 2"
|
const jsonPayload = decodeURIComponent(
|
||||||
className={styles.productImage}
|
atob(base64)
|
||||||
/>
|
.split('')
|
||||||
<div>
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
<p className={styles.itemText}>Energy Drink</p>
|
.join('')
|
||||||
<p className={styles.itemPrice}>$25.00</p>
|
);
|
||||||
</div>
|
return JSON.parse(jsonPayload);
|
||||||
</div>
|
} catch (e) {
|
||||||
<button className={styles.removeBtn} aria-label="Remove Energy Drink">×</button>
|
return null;
|
||||||
</li>
|
}
|
||||||
</ul>
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Checkout form */}
|
const Checkout = ({ socketId, transactionSuccess }) => {
|
||||||
<div className={styles.checkoutSection}>
|
const [products, setProducts] = useState([]);
|
||||||
<div>
|
const [itemIds, setItemIds] = useState(null);
|
||||||
<h2 className={styles.checkoutTitle}>Note / Request</h2>
|
const [token, setToken] = useState(null);
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
|
||||||
<input
|
const [qrisData, setQrisData] = useState(null);
|
||||||
type="text"
|
const [value, setValue] = useState(null);
|
||||||
placeholder="Optional notes for seller"
|
const [loadingPay, setLoadingPay] = useState(false);
|
||||||
className={styles.inputNote}
|
|
||||||
/>
|
const [redirect_uri, setRedirect_Uri] = useState('');
|
||||||
|
const [redirect_failed, setRedirect_Failed] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const tokenParam = urlParams.get('token');
|
||||||
|
const itemsIdString = urlParams.get('itemsId');
|
||||||
|
setRedirect_Uri(urlParams.get('redirect_uri'));
|
||||||
|
setRedirect_Failed(urlParams.get('redirect_failed'));
|
||||||
|
|
||||||
|
setToken(tokenParam);
|
||||||
|
|
||||||
|
if (!itemsIdString) {
|
||||||
|
window.location.href = redirect_failed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedIds = JSON.parse(itemsIdString);
|
||||||
|
if (!Array.isArray(parsedIds) || parsedIds.length === 0) {
|
||||||
|
window.location.href = redirect_failed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItemIds(parsedIds);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Invalid itemsId format', e);
|
||||||
|
window.location.href = redirect_failed;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch products
|
||||||
|
useEffect(() => {
|
||||||
|
if (itemIds && Array.isArray(itemIds) && itemIds.length > 0) {
|
||||||
|
fetch('https://bot.kediritechnopark.com/webhook/store-dev/products', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ itemsId: itemIds }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setProducts(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Error fetching products:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [itemIds]);
|
||||||
|
|
||||||
|
const handleRemove = (id) => {
|
||||||
|
const updatedItemIds = itemIds.filter((itemId) => itemId !== id);
|
||||||
|
const updatedProducts = products.filter((product) => product.id !== id);
|
||||||
|
|
||||||
|
if (updatedItemIds.length === 0) {
|
||||||
|
window.location.href = redirect_failed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItemIds(updatedItemIds);
|
||||||
|
setProducts(updatedProducts);
|
||||||
|
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('itemsId', JSON.stringify(updatedItemIds));
|
||||||
|
window.history.replaceState(null, '', url.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePay = async () => {
|
||||||
|
if (!itemIds || !token) {
|
||||||
|
alert('Token atau itemsId tidak ditemukan.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingPay(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
itemIds.forEach((id) => params.append('itemsId', id));
|
||||||
|
params.append('socketId', socketId);
|
||||||
|
// Jika butuh socketId bisa tambahkan di sini, misal: params.append('socketId', socketId);
|
||||||
|
|
||||||
|
const response = await fetch('https://bot.kediritechnopark.com/webhook/store-dev/pay', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result?.qris_dynamic) {
|
||||||
|
setQrisData(result.qris_dynamic);
|
||||||
|
setValue(result.total_price);
|
||||||
|
} else {
|
||||||
|
alert(`Gagal mendapatkan QRIS: ${result?.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Network error:', error);
|
||||||
|
alert('Terjadi kesalahan jaringan.');
|
||||||
|
} finally {
|
||||||
|
setLoadingPay(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (transactionSuccess) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
window.location.href = redirect_uri;
|
||||||
|
}, 10000); // 10 detik = 10000 ms
|
||||||
|
|
||||||
|
// Bersihkan timer kalau komponen unmount atau transactionSuccess berubah
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [transactionSuccess]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.checkoutCard}>
|
||||||
|
{/* Product List */}
|
||||||
|
<div className={styles.cartSection}>
|
||||||
|
{!qrisData &&
|
||||||
|
<>
|
||||||
|
<h2 className={styles.cartTitle}>Your Cart</h2>
|
||||||
|
<ul className={styles.cartList}>
|
||||||
|
{products.map((item, index) => (
|
||||||
|
<li key={index} className={styles.cartItem}>
|
||||||
|
<div className={styles.itemDetails}>
|
||||||
|
<img
|
||||||
|
src={item.image || 'https://via.placeholder.com/60'}
|
||||||
|
alt={item.name}
|
||||||
|
className={styles.productImage}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className={styles.itemText}>{item.name}</p>
|
||||||
|
<p className={styles.itemPrice}>Rp{item.price?.toLocaleString('id-ID')}</p>
|
||||||
|
{item.duration?.hours && (
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#777' }}>
|
||||||
|
Durasi: {item.duration.hours} jam
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={() => handleRemove(item.id)}
|
||||||
|
aria-label={`Remove ${item.name}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{qrisData && (
|
||||||
|
<>
|
||||||
|
<p>Silahkan scan QRIS ini</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '2rem', textAlign: 'center', position: 'relative', display: 'inline-block' }}>
|
||||||
|
<QRCodeCanvas value={qrisData} size={256} />
|
||||||
|
|
||||||
|
{transactionSuccess && (
|
||||||
|
<div className={styles.CheckmarkOverlay}>
|
||||||
|
<svg
|
||||||
|
className={styles.Checkmark}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 52 52"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className={styles.CheckmarkCircle}
|
||||||
|
cx="26"
|
||||||
|
cy="26"
|
||||||
|
r="25"
|
||||||
|
fill="none"
|
||||||
|
stroke="#4BB543"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className={styles.CheckmarkCheck}
|
||||||
|
fill="none"
|
||||||
|
stroke="#4BB543"
|
||||||
|
strokeWidth="4"
|
||||||
|
d="M14 27l7 7 16-16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!transactionSuccess && (
|
||||||
|
<>
|
||||||
|
<h2>Rp{value?.toLocaleString('id-ID')}</h2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkout form */}
|
||||||
|
<div className={styles.checkoutSection}>
|
||||||
|
<div>
|
||||||
|
<h2 className={styles.checkoutTitle}>
|
||||||
|
Rp{qrisData ? value : products.reduce((acc, item) => acc + (item.price || 0), 0).toLocaleString('id-ID')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={parseJwt(token)?.username || 'User'}
|
||||||
|
className={styles.inputNote}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.paymentBtn}
|
||||||
|
onClick={handlePay}
|
||||||
|
disabled={loadingPay || qrisData !== null}
|
||||||
|
>
|
||||||
|
{loadingPay ? 'Processing...' : qrisData ? 'Payment QRIS Generated' : 'Complete Payment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={styles.footerText}>
|
||||||
|
Powered by <span className={styles.footerHighlight}>KEDIRITECHNOPARK</span> •{' '}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className={styles.paymentBtn}>Complete Payment</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className={styles.footerText}>
|
|
||||||
Powered by <span className={styles.footerHighlight}>Stripe</span> •{' '}
|
|
||||||
<a href="#" className={styles.footerLink}>Terms</a> •{' '}
|
|
||||||
<a href="#" className={styles.footerLink}>Privacy Policy</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Checkout;
|
export default Checkout;
|
||||||
|
|||||||
@@ -181,3 +181,60 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.CheckmarkOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
width: 130px;
|
||||||
|
height: 130px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CheckmarkSvg {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CheckmarkCircle {
|
||||||
|
fill: none;
|
||||||
|
stroke: #4BB543;
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-dasharray: 157; /* 2πr where r = 25 */
|
||||||
|
stroke-dashoffset: 157;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: center;
|
||||||
|
animation: CircleFill 1s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CheckmarkCheck {
|
||||||
|
fill: none;
|
||||||
|
stroke: #4BB543;
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-dasharray: 48;
|
||||||
|
stroke-dashoffset: 48;
|
||||||
|
animation: DrawCheck 0.5s ease forwards;
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Circle fills in a clockwise motion */
|
||||||
|
@keyframes CircleFill {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkmark is drawn after circle is full */
|
||||||
|
@keyframes DrawCheck {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user