Skip to content

Commit f31d5cf

Browse files
committed
feat: improve crypto payment UX - progress steps, prominent copy buttons, urgent timer
- Add visual 4-step payment progress indicator (Copy Details → Send Crypto → Confirming → Done) - Make Copy Address and Copy Amount full-width prominent buttons with success states - Add urgent red pulsing countdown timer when < 5 minutes remain with 'expiring soon' warning - QR codes already encode proper payment URIs (BIP21/EIP-681/Solana Pay) - verified - Add comprehensive tests for all new components (progress steps, copy buttons, timer urgency) - Bump version to 0.5.5
1 parent 211484b commit f31d5cf

File tree

4 files changed

+307
-18
lines changed

4 files changed

+307
-18
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "coinpayportal",
3-
"version": "0.5.2",
3+
"version": "0.5.5",
44
"private": true,
55
"type": "module",
66
"bin": {

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/coinpay",
3-
"version": "0.5.2",
3+
"version": "0.5.5",
44
"description": "CoinPay SDK & CLI — Accept cryptocurrency payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet and swap support",
55
"type": "module",
66
"main": "./src/index.js",

src/app/pay/[id]/page.test.tsx

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,218 @@ describe('PublicPaymentPage', () => {
672672
});
673673
});
674674

675+
describe('Payment Progress Steps', () => {
676+
it('should show step indicator for pending payment', async () => {
677+
vi.mocked(fetch).mockResolvedValue({
678+
ok: true,
679+
json: async () => ({
680+
success: true,
681+
payment: mockPayment,
682+
}),
683+
} as Response);
684+
685+
render(<PublicPaymentPage />);
686+
687+
await waitFor(() => {
688+
expect(screen.getByTestId('payment-steps')).toBeInTheDocument();
689+
expect(screen.getByText('Copy Details')).toBeInTheDocument();
690+
expect(screen.getByText('Send Crypto')).toBeInTheDocument();
691+
expect(screen.getByText('Confirming')).toBeInTheDocument();
692+
expect(screen.getByText('Done')).toBeInTheDocument();
693+
});
694+
});
695+
696+
it('should highlight step 1 for fresh pending payment', async () => {
697+
vi.mocked(fetch).mockResolvedValue({
698+
ok: true,
699+
json: async () => ({
700+
success: true,
701+
payment: mockPayment,
702+
}),
703+
} as Response);
704+
705+
render(<PublicPaymentPage />);
706+
707+
await waitFor(() => {
708+
const step1 = screen.getByTestId('step-1');
709+
expect(step1.className).toContain('bg-purple-500');
710+
});
711+
});
712+
713+
it('should show step 3 for detected payment', async () => {
714+
vi.mocked(fetch).mockResolvedValue({
715+
ok: true,
716+
json: async () => ({
717+
success: true,
718+
payment: { ...mockPayment, status: 'detected' },
719+
}),
720+
} as Response);
721+
722+
render(<PublicPaymentPage />);
723+
724+
await waitFor(() => {
725+
const step3 = screen.getByTestId('step-3');
726+
expect(step3.className).toContain('bg-purple-500');
727+
});
728+
});
729+
730+
it('should show step 4 active for confirmed payment', async () => {
731+
vi.mocked(fetch).mockResolvedValue({
732+
ok: true,
733+
json: async () => ({
734+
success: true,
735+
payment: { ...mockPayment, status: 'confirmed' },
736+
}),
737+
} as Response);
738+
739+
render(<PublicPaymentPage />);
740+
741+
await waitFor(() => {
742+
const step4 = screen.getByTestId('step-4');
743+
expect(step4.className).toContain('bg-purple-500');
744+
// All previous steps should be green (completed)
745+
const step1 = screen.getByTestId('step-1');
746+
expect(step1.className).toContain('bg-green-500');
747+
});
748+
});
749+
750+
it('should not show steps for failed payment', async () => {
751+
vi.mocked(fetch).mockResolvedValue({
752+
ok: true,
753+
json: async () => ({
754+
success: true,
755+
payment: { ...mockPayment, status: 'failed' },
756+
}),
757+
} as Response);
758+
759+
render(<PublicPaymentPage />);
760+
761+
await waitFor(() => {
762+
expect(screen.getByText(/payment failed/i)).toBeInTheDocument();
763+
});
764+
765+
expect(screen.queryByTestId('payment-steps')).not.toBeInTheDocument();
766+
});
767+
});
768+
769+
describe('Prominent Copy Buttons', () => {
770+
beforeEach(() => {
771+
vi.mocked(fetch).mockResolvedValue({
772+
ok: true,
773+
json: async () => ({
774+
success: true,
775+
payment: { ...mockPayment, status: 'confirmed' },
776+
}),
777+
} as Response);
778+
});
779+
780+
it('should show prominent Copy Address button', async () => {
781+
render(<PublicPaymentPage />);
782+
783+
await waitFor(() => {
784+
const btn = screen.getByTestId('copy-address-btn');
785+
expect(btn).toBeInTheDocument();
786+
expect(btn.textContent).toContain('Copy Address');
787+
});
788+
});
789+
790+
it('should show prominent Copy Amount button', async () => {
791+
render(<PublicPaymentPage />);
792+
793+
await waitFor(() => {
794+
const btn = screen.getByTestId('copy-amount-btn');
795+
expect(btn).toBeInTheDocument();
796+
expect(btn.textContent).toContain('Copy Amount');
797+
});
798+
});
799+
800+
it('should show success state after copying address', async () => {
801+
render(<PublicPaymentPage />);
802+
803+
await waitFor(() => {
804+
screen.getByTestId('copy-address-btn');
805+
});
806+
807+
await act(async () => {
808+
fireEvent.click(screen.getByTestId('copy-address-btn'));
809+
});
810+
811+
expect(screen.getByTestId('copy-address-btn').textContent).toContain('Address Copied!');
812+
});
813+
814+
it('should show success state after copying amount', async () => {
815+
render(<PublicPaymentPage />);
816+
817+
await waitFor(() => {
818+
screen.getByTestId('copy-amount-btn');
819+
});
820+
821+
await act(async () => {
822+
fireEvent.click(screen.getByTestId('copy-amount-btn'));
823+
});
824+
825+
expect(screen.getByTestId('copy-amount-btn').textContent).toContain('Amount Copied!');
826+
});
827+
});
828+
829+
describe('Countdown Timer Urgency', () => {
830+
it('should show urgent styling when less than 5 minutes remain', async () => {
831+
// Create payment that expires in 4 minutes
832+
const fourMinutesFromNow = new Date(Date.now() + 4 * 60 * 1000).toISOString();
833+
vi.mocked(fetch).mockResolvedValue({
834+
ok: true,
835+
json: async () => ({
836+
success: true,
837+
payment: { ...mockPayment, status: 'pending', expires_at: fourMinutesFromNow },
838+
}),
839+
} as Response);
840+
841+
render(<PublicPaymentPage />);
842+
843+
await waitFor(() => {
844+
const timer = screen.getByTestId('countdown-timer');
845+
expect(timer.className).toContain('text-red-400');
846+
expect(timer.className).toContain('animate-pulse');
847+
});
848+
});
849+
850+
it('should show "expiring soon" warning when urgent', async () => {
851+
const twoMinutesFromNow = new Date(Date.now() + 2 * 60 * 1000).toISOString();
852+
vi.mocked(fetch).mockResolvedValue({
853+
ok: true,
854+
json: async () => ({
855+
success: true,
856+
payment: { ...mockPayment, status: 'pending', expires_at: twoMinutesFromNow },
857+
}),
858+
} as Response);
859+
860+
render(<PublicPaymentPage />);
861+
862+
await waitFor(() => {
863+
expect(screen.getByText(/expiring soon/i)).toBeInTheDocument();
864+
});
865+
});
866+
867+
it('should show normal styling when more than 5 minutes remain', async () => {
868+
const tenMinutesFromNow = new Date(Date.now() + 10 * 60 * 1000).toISOString();
869+
vi.mocked(fetch).mockResolvedValue({
870+
ok: true,
871+
json: async () => ({
872+
success: true,
873+
payment: { ...mockPayment, status: 'pending', expires_at: tenMinutesFromNow },
874+
}),
875+
} as Response);
876+
877+
render(<PublicPaymentPage />);
878+
879+
await waitFor(() => {
880+
const timer = screen.getByTestId('countdown-timer');
881+
expect(timer.className).toContain('text-white');
882+
expect(timer.className).not.toContain('text-red-400');
883+
});
884+
});
885+
});
886+
675887
describe('Currency Display', () => {
676888
it('should show Bitcoin name for BTC', async () => {
677889
vi.mocked(fetch).mockResolvedValue({

src/app/pay/[id]/page.tsx

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,18 @@ export default function PublicPaymentPage() {
395395
const isPaymentFailed = paymentStatus === 'expired' || paymentStatus === 'failed';
396396
const isPaymentPending = paymentStatus === 'pending' || paymentStatus === 'detected';
397397

398+
// Determine current step for progress indicator
399+
const getCurrentStep = (): number => {
400+
if (isPaymentComplete) return 4;
401+
if (paymentStatus === 'detected') return 3;
402+
if (copiedField === 'address' || copiedField === 'amount') return 2;
403+
return 1;
404+
};
405+
const currentStep = getCurrentStep();
406+
407+
// Timer urgency
408+
const isTimerUrgent = timeRemaining > 0 && timeRemaining < 300; // < 5 minutes
409+
398410
return (
399411
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900 to-gray-900 py-8 px-4">
400412
<div className="max-w-lg mx-auto">
@@ -460,10 +472,12 @@ export default function PublicPaymentPage() {
460472
</div>
461473
{isPaymentPending && timeRemaining > 0 && (
462474
<div className="text-right">
463-
<div className="text-2xl font-mono font-bold text-white">
475+
<div className={`text-2xl font-mono font-bold ${isTimerUrgent ? 'text-red-400 animate-pulse' : 'text-white'}`} data-testid="countdown-timer">
464476
{formatTimeRemaining(timeRemaining)}
465477
</div>
466-
<p className="text-xs text-gray-400">remaining</p>
478+
<p className={`text-xs ${isTimerUrgent ? 'text-red-400 font-semibold' : 'text-gray-400'}`}>
479+
{isTimerUrgent ? '⚠ expiring soon!' : 'remaining'}
480+
</p>
467481
</div>
468482
)}
469483
</div>
@@ -481,6 +495,53 @@ export default function PublicPaymentPage() {
481495
)}
482496
</div>
483497

498+
{/* Payment Progress Steps */}
499+
{!isPaymentFailed && (
500+
<div className="px-6 pt-5 pb-0" data-testid="payment-steps">
501+
<div className="flex items-center justify-between">
502+
{[
503+
{ num: 1, label: 'Copy Details' },
504+
{ num: 2, label: 'Send Crypto' },
505+
{ num: 3, label: 'Confirming' },
506+
{ num: 4, label: 'Done' },
507+
].map((step, i) => (
508+
<div key={step.num} className="flex items-center flex-1">
509+
<div className="flex flex-col items-center">
510+
<div
511+
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors ${
512+
currentStep >= step.num
513+
? currentStep === step.num
514+
? 'bg-purple-500 text-white ring-2 ring-purple-400 ring-offset-2 ring-offset-gray-800'
515+
: 'bg-green-500 text-white'
516+
: 'bg-gray-700 text-gray-400'
517+
}`}
518+
data-testid={`step-${step.num}`}
519+
>
520+
{currentStep > step.num ? (
521+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
522+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
523+
</svg>
524+
) : (
525+
step.num
526+
)}
527+
</div>
528+
<span className={`text-xs mt-1 whitespace-nowrap ${
529+
currentStep >= step.num ? 'text-purple-300' : 'text-gray-500'
530+
}`}>
531+
{step.label}
532+
</span>
533+
</div>
534+
{i < 3 && (
535+
<div className={`flex-1 h-0.5 mx-2 mt-[-16px] ${
536+
currentStep > step.num ? 'bg-green-500' : 'bg-gray-700'
537+
}`} />
538+
)}
539+
</div>
540+
))}
541+
</div>
542+
</div>
543+
)}
544+
484545
<div className="p-6 space-y-6">
485546
{/* Amount Display */}
486547
<div className="text-center">
@@ -525,21 +586,26 @@ export default function PublicPaymentPage() {
525586
{payment.crypto_amount && (
526587
<button
527588
onClick={() => copyToClipboard(parseFloat(payment.crypto_amount).toFixed(8), 'amount')}
528-
className="mt-2 inline-flex items-center gap-1 text-sm text-purple-400 hover:text-purple-300 transition-colors"
589+
className={`mt-3 w-full py-3 px-4 rounded-xl font-medium text-sm flex items-center justify-center gap-2 transition-all ${
590+
copiedField === 'amount'
591+
? 'bg-green-500/20 text-green-400 border border-green-500/30'
592+
: 'bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/30'
593+
}`}
594+
data-testid="copy-amount-btn"
529595
>
530596
{copiedField === 'amount' ? (
531597
<>
532-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
598+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
533599
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
534600
</svg>
535-
Copied!
601+
Amount Copied!
536602
</>
537603
) : (
538604
<>
539-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
605+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
540606
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
541607
</svg>
542-
Copy amount
608+
Copy Amount
543609
</>
544610
)}
545611
</button>
@@ -589,23 +655,34 @@ export default function PublicPaymentPage() {
589655
<label className="block text-sm font-medium text-gray-400 mb-2">
590656
Send to this address:
591657
</label>
592-
<div className="bg-gray-900/50 rounded-xl p-4 flex items-center gap-3">
593-
<p className="font-mono text-sm text-white break-all flex-1">
658+
<div className="bg-gray-900/50 rounded-xl p-4">
659+
<p className="font-mono text-sm text-white break-all mb-3">
594660
{payment.payment_address}
595661
</p>
596662
<button
597663
onClick={() => copyToClipboard(payment.payment_address, 'address')}
598-
className="flex-shrink-0 p-2 text-gray-400 hover:text-purple-400 hover:bg-purple-500/10 rounded-lg transition-colors"
664+
className={`w-full py-3 px-4 rounded-xl font-medium text-sm flex items-center justify-center gap-2 transition-all ${
665+
copiedField === 'address'
666+
? 'bg-green-500/20 text-green-400 border border-green-500/30'
667+
: 'bg-purple-600 text-white hover:bg-purple-500'
668+
}`}
599669
title="Copy address"
670+
data-testid="copy-address-btn"
600671
>
601672
{copiedField === 'address' ? (
602-
<svg className="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
603-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
604-
</svg>
673+
<>
674+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
675+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
676+
</svg>
677+
Address Copied!
678+
</>
605679
) : (
606-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
607-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
608-
</svg>
680+
<>
681+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
682+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
683+
</svg>
684+
Copy Address
685+
</>
609686
)}
610687
</button>
611688
</div>

0 commit comments

Comments
 (0)