feat/payment-link-flow #13

Merged
root merged 2 commits from feat/payment-link-flow into main 2025-11-24 13:36:37 +00:00
10 changed files with 37 additions and 26 deletions

View File

@ -1,10 +1,11 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { AppLayout } from './AppLayout' import { AppLayout } from './AppLayout'
import { CheckoutPage } from '../pages/CheckoutPage' // import { CheckoutPage } from '../pages/CheckoutPage'
import { PaymentStatusPage } from '../pages/PaymentStatusPage' import { PaymentStatusPage } from '../pages/PaymentStatusPage'
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage' import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
import { NotFoundPage } from '../pages/NotFoundPage' import { NotFoundPage } from '../pages/NotFoundPage'
import { DemoStorePage } from '../pages/DemoStorePage' // import { DemoStorePage } from '../pages/DemoStorePage'
import { InitPage } from '../pages/InitialPage'
import { PayPage } from '../pages/PayPage' import { PayPage } from '../pages/PayPage'
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -13,8 +14,8 @@ const router = createBrowserRouter([
element: <AppLayout />, element: <AppLayout />,
errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>, errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>,
children: [ children: [
{ index: true, element: <DemoStorePage /> }, { index: true, element: <InitPage /> },
{ path: 'checkout', element: <CheckoutPage /> }, // { path: 'checkout', element: <CheckoutPage /> },
{ path: 'pay/:token', element: <PayPage /> }, { path: 'pay/:token', element: <PayPage /> },
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> }, { path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
{ path: 'history', element: <PaymentHistoryPage /> }, { path: 'history', element: <PaymentHistoryPage /> },

View File

@ -103,7 +103,7 @@ export function CStoreLogosRow({ compact = false, size }: { compact?: boolean; s
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LogoAlfamart compact={compact} size={size} /> <LogoAlfamart compact={compact} size={size} />
<LogoIndomaret compact={compact} size={size} /> {/* <LogoIndomaret compact={compact} size={size} /> */}
</div> </div>
) )
} }

View File

@ -64,22 +64,21 @@ export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireA
</button> </button>
</div> </div>
</div> </div>
{/* Amount panel */}
{expanded && ( {expanded && (
<div className="p-4 border-b border-black/10 flex items-start justify-between"> <div className="p-4 border-b border-black/10 flex items-start justify-between">
<div> <div>
<div className="text-xs text-black/60">Total</div> <div className="text-xs text-black">Total</div>
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div> <div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
<div className="text-xs text-black/60">Order ID #{orderId}</div> <div className="text-xs text-black/60">Order ID #{orderId}</div>
</div> </div>
</div> </div>
)} )}
{/* Body */}
<div className="p-4"> <div className="p-4">
{children} {children}
<TrustStrip location="sheet" /> <TrustStrip location="sheet" />
</div> </div>
{/* Sticky CTA (mobile-friendly) */}
{showStatusCTA && ( {showStatusCTA && (
<div className="sticky bottom-0 bg-white/95 backdrop-blur border-t border-black/10 p-3 pb-[env(safe-area-inset-bottom)]"> <div className="sticky bottom-0 bg-white/95 backdrop-blur border-t border-black/10 p-3 pb-[env(safe-area-inset-bottom)]">
<Link <Link

View File

@ -4,7 +4,7 @@ export function usePaymentNavigation() {
const navigate = useNavigate() const navigate = useNavigate()
return { return {
toCheckout() { toCheckout() {
navigate('/checkout') window.location.assign('https://erpskrip.id/pembayaran-pelanggan')
}, },
toStatus(orderId: string, method?: string) { toStatus(orderId: string, method?: string) {
const qs = method ? `?m=${encodeURIComponent(method)}` : '' const qs = method ? `?m=${encodeURIComponent(method)}` : ''
@ -14,4 +14,4 @@ export function usePaymentNavigation() {
navigate('/history') navigate('/history')
}, },
} }
} }

View File

@ -1,7 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './styles/globals.css' import './styles/globals.css'
// Force light theme in case hosting injects a global 'dark' class
(() => { (() => {
const html = document.documentElement const html = document.documentElement
try { try {
@ -9,11 +9,9 @@ import './styles/globals.css'
html.classList.remove('dark') html.classList.remove('dark')
} }
document.body.classList.remove('dark') document.body.classList.remove('dark')
// Hint browsers to prefer light color scheme
html.style.colorScheme = 'light' html.style.colorScheme = 'light'
html.setAttribute('data-theme', 'light') html.setAttribute('data-theme', 'light')
} catch { } catch {
// noop
} }
})() })()
import { AppRouter } from './app/router' import { AppRouter } from './app/router'

13
src/pages/InitialPage.tsx Normal file
View File

@ -0,0 +1,13 @@
export function InitPage() {
return (
<div className="fixed inset-0 bg-white overflow-hidden flex items-center justify-center p-6">
<div className="flex items-center gap-3">
<img src="/simaya.png" alt="Simaya" className="h-12 w-12 md:h-16 md:w-16" />
<div className="text-black">
<div className="text-xl font-semibold leading-none">Simaya Midtrans</div>
<div className="text-sm leading-none">Payment Service</div>
</div>
</div>
</div>
)
}

View File

@ -5,7 +5,8 @@ export function NotFoundPage() {
<div className="space-y-3"> <div className="space-y-3">
<h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1> <h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1>
<p className="text-sm text-black/70">Periksa URL atau kembali ke checkout.</p> <p className="text-sm text-black/70">Periksa URL atau kembali ke checkout.</p>
<Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link> {/* <Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link> */}
<Link to="/" className="text-brand-600 underline">Kembali</Link>
</div> </div>
) )
} }

View File

@ -53,7 +53,7 @@ export function PayPage() {
} }
}, [token]) }, [token])
const merchantName = useMemo(() => 'Simaya Retail', []) const merchantName = useMemo(() => '', [])
const isExpired = expireAt ? Date.now() > expireAt : false const isExpired = expireAt ? Date.now() > expireAt : false
const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => { const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => {
@ -88,16 +88,17 @@ export function PayPage() {
> >
<div className="space-y-4 px-4 py-6"> <div className="space-y-4 px-4 py-6">
<Alert title={title}>{msg}</Alert> <Alert title={title}>{msg}</Alert>
<div className="flex gap-2"> <div className="flex flex-col gap-2 sm:flex-row">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => { try { window.location.reload() } catch { } }} onClick={() => { try { window.location.reload() } catch { } }}
className="w-full sm:w-auto"
> >
Muat ulang Muat ulang
</Button> </Button>
<a <a
href="mailto:retailimaya@gmail.com?subject=Permintaan%20Link%20Pembayaran&body=Order%20ID:%20" href="mailto:retailimaya@gmail.com?subject=Permintaan%20Link%20Pembayaran&body=Order%20ID:%20"
className="inline-flex items-center px-3 py-2 rounded border bg-gray-800 !text-white hover:!text-white focus:!text-white visited:!text-white active:!text-white" className="inline-flex items-center px-3 py-2 rounded border bg-gray-800 !text-white hover:!text-white focus:!text-white visited:!text-white active:!text-white w-full sm:w-auto justify-center"
> >
Hubungi Admin Hubungi Admin
</a> </a>
@ -123,7 +124,6 @@ export function PayPage() {
onSelect={(m) => { onSelect={(m) => {
setSelectedMethod(m as Method) setSelectedMethod(m as Method)
if (m === 'bank_transfer' || m === 'cstore') { if (m === 'bank_transfer' || m === 'cstore') {
// Panel pemilihan bank/toko akan muncul di bawah item, dan lanjut ke step 3 setelah memilih
} else if (m === 'cpay') { } else if (m === 'cpay') {
try { try {
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank') window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
@ -148,7 +148,7 @@ export function PayPage() {
return ( return (
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div> <div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}> <div className={`grid grid-cols-2 md:grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => ( {(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => (
<button <button
key={bk} key={bk}
@ -158,7 +158,7 @@ export function PayPage() {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}} }}
className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100" className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center overflow-hidden hover:bg-gray-100"
aria-label={`Pilih bank ${bk.toUpperCase()}`} aria-label={`Pilih bank ${bk.toUpperCase()}`}
> >
<BankLogo bank={bk} /> <BankLogo bank={bk} />
@ -179,7 +179,8 @@ export function PayPage() {
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div> <div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}> <div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['alfamart', 'indomaret'] as const).map((st) => ( {/* {(['alfamart', 'indomaret'] as const).map((st) => ( */}
{(['alfamart'] as const).map((st) => (
<button <button
key={st} key={st}
type="button" type="button"
@ -188,7 +189,7 @@ export function PayPage() {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}} }}
className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center hover:bg-gray-100" className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center hover:bg-gray-100"
aria-label={`Pilih toko ${st.toUpperCase()}`} aria-label={`Pilih toko ${st.toUpperCase()}`}
> >
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />} {st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}

View File

@ -112,7 +112,7 @@ export function PaymentStatusPage() {
) : null} ) : null}
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<Button onClick={() => nav.toHistory()}>Lihat Riwayat</Button> <Button onClick={() => nav.toHistory()}>Lihat Riwayat</Button>
<Button variant="secondary" onClick={() => nav.toCheckout()}>Kembali ke Checkout</Button> <Button variant="secondary" onClick={() => nav.toCheckout()}>Kembali</Button>
</div> </div>
</div> </div>
{!Env.API_BASE_URL && ( {!Env.API_BASE_URL && (

View File

@ -129,8 +129,6 @@ export async function getPaymentLinkPayload(token: string): Promise<PaymentLinkP
Logger.info('paymentlink.resolve', { tokenLen: token.length }) Logger.info('paymentlink.resolve', { tokenLen: token.length })
return data as PaymentLinkPayload return data as PaymentLinkPayload
} }
// Fallback when API base not set or resolver unavailable
// Try a best-effort decode of base64(JSON) payload; if fails, use defaults
try { try {
const json = JSON.parse(atob(token)) const json = JSON.parse(atob(token))
return { return {