Skip to content

Commit 2b3a6e6

Browse files
committed
feat: Add purchase confirmation modal for explicit user consent
- Create PurchaseConfirmationModal component with full order preview - Shows grouped items by seller with images, quantities, and subtotals - Displays total amount and seller count prominently - Includes info box explaining what happens next - Critical UX fix: nsec users now get confirmation before messages sent - NIP-07 users get modal + signature popups (double confirmation) - Modal prevents accidental purchase requests - Blocks closing during message sending (loading state) - Update PurchaseIntentButton to show modal before sending - Implements proper informed consent flow for all auth methods
1 parent ea70258 commit 2b3a6e6

2 files changed

Lines changed: 314 additions & 19 deletions

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
'use client';
2+
3+
import { CartItem } from '@/types/cart';
4+
import Image from 'next/image';
5+
6+
interface PurchaseConfirmationModalProps {
7+
isOpen: boolean;
8+
onClose: () => void;
9+
onConfirm: () => void;
10+
items: CartItem[];
11+
total: number;
12+
isLoading?: boolean;
13+
}
14+
15+
interface SellerGroup {
16+
sellerPubkey: string;
17+
items: CartItem[];
18+
subtotal: number;
19+
}
20+
21+
// Format sats with commas
22+
const formatSats = (sats: number): string => {
23+
return sats.toLocaleString('en-US');
24+
};
25+
26+
export default function PurchaseConfirmationModal({
27+
isOpen,
28+
onClose,
29+
onConfirm,
30+
items,
31+
total,
32+
isLoading = false,
33+
}: PurchaseConfirmationModalProps) {
34+
if (!isOpen) return null;
35+
36+
// Group items by seller
37+
const sellerGroups = items.reduce((acc, item) => {
38+
const existingGroup = acc.find((g) => g.sellerPubkey === item.sellerPubkey);
39+
const itemSubtotal = item.price * item.quantity;
40+
41+
if (existingGroup) {
42+
existingGroup.items.push(item);
43+
existingGroup.subtotal += itemSubtotal;
44+
} else {
45+
acc.push({
46+
sellerPubkey: item.sellerPubkey,
47+
items: [item],
48+
subtotal: itemSubtotal,
49+
});
50+
}
51+
52+
return acc;
53+
}, [] as SellerGroup[]);
54+
55+
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
56+
if (e.target === e.currentTarget && !isLoading) {
57+
onClose();
58+
}
59+
};
60+
61+
return (
62+
<div
63+
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
64+
onClick={handleBackdropClick}
65+
>
66+
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
67+
{/* Header */}
68+
<div className="px-6 py-4 border-b border-gray-200">
69+
<h2 className="text-2xl font-bold text-gray-900">
70+
Confirm Purchase Request
71+
</h2>
72+
<p className="text-sm text-gray-600 mt-1">
73+
You will send encrypted purchase requests to {sellerGroups.length}{' '}
74+
{sellerGroups.length === 1 ? 'seller' : 'sellers'}
75+
</p>
76+
</div>
77+
78+
{/* Content - Scrollable */}
79+
<div className="flex-1 overflow-y-auto px-6 py-4">
80+
{sellerGroups.map((group) => (
81+
<div key={group.sellerPubkey} className="mb-6 last:mb-0">
82+
{/* Seller Header */}
83+
<div className="bg-gray-50 px-4 py-3 rounded-lg mb-3">
84+
<h3 className="font-semibold text-gray-900">
85+
Seller
86+
</h3>
87+
<p className="text-xs text-gray-500 font-mono mt-1">
88+
{group.sellerPubkey.slice(0, 16)}...
89+
</p>
90+
</div>
91+
92+
{/* Items */}
93+
<div className="space-y-3 ml-4">
94+
{group.items.map((item) => (
95+
<div key={item.id} className="flex gap-3">
96+
{/* Image */}
97+
<div className="relative w-16 h-16 flex-shrink-0 rounded overflow-hidden bg-gray-100">
98+
{item.imageUrl ? (
99+
<Image
100+
src={item.imageUrl}
101+
alt={item.title}
102+
fill
103+
className="object-cover"
104+
/>
105+
) : (
106+
<div className="w-full h-full flex items-center justify-center text-gray-400">
107+
<svg
108+
className="w-8 h-8"
109+
fill="none"
110+
stroke="currentColor"
111+
viewBox="0 0 24 24"
112+
>
113+
<path
114+
strokeLinecap="round"
115+
strokeLinejoin="round"
116+
strokeWidth={2}
117+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
118+
/>
119+
</svg>
120+
</div>
121+
)}
122+
</div>
123+
124+
{/* Details */}
125+
<div className="flex-1 min-w-0">
126+
<p className="text-sm font-medium text-gray-900 truncate">
127+
{item.title}
128+
</p>
129+
<p className="text-sm text-gray-600">
130+
Qty: {item.quantity} × {formatSats(item.price)}
131+
</p>
132+
</div>
133+
134+
{/* Subtotal */}
135+
<div className="text-right">
136+
<p className="text-sm font-semibold text-gray-900">
137+
{formatSats(item.price * item.quantity)}
138+
</p>
139+
</div>
140+
</div>
141+
))}
142+
</div>
143+
144+
{/* Seller Subtotal */}
145+
<div className="mt-3 ml-4 pt-3 border-t border-gray-200">
146+
<div className="flex justify-between items-center text-sm">
147+
<span className="text-gray-600">Subtotal</span>
148+
<span className="font-semibold text-gray-900">
149+
{formatSats(group.subtotal)}
150+
</span>
151+
</div>
152+
</div>
153+
</div>
154+
))}
155+
156+
{/* Total */}
157+
<div className="mt-6 pt-4 border-t-2 border-gray-300">
158+
<div className="flex justify-between items-center">
159+
<span className="text-lg font-bold text-gray-900">
160+
Total Amount
161+
</span>
162+
<span className="text-2xl font-bold text-purple-600">
163+
{formatSats(total)}
164+
</span>
165+
</div>
166+
</div>
167+
168+
{/* Info Box */}
169+
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
170+
<div className="flex gap-3">
171+
<svg
172+
className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5"
173+
fill="none"
174+
stroke="currentColor"
175+
viewBox="0 0 24 24"
176+
>
177+
<path
178+
strokeLinecap="round"
179+
strokeLinejoin="round"
180+
strokeWidth={2}
181+
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
182+
/>
183+
</svg>
184+
<div className="text-sm text-blue-800">
185+
<p className="font-semibold mb-1">What happens next:</p>
186+
<ul className="list-disc list-inside space-y-1 ml-2">
187+
<li>Encrypted purchase requests will be sent to each seller</li>
188+
<li>Sellers will respond with payment links</li>
189+
<li>You can view messages in your Messages page</li>
190+
<li>This does not charge you or reserve items</li>
191+
</ul>
192+
</div>
193+
</div>
194+
</div>
195+
</div>
196+
197+
{/* Footer - Buttons */}
198+
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
199+
<div className="flex gap-3 justify-end">
200+
<button
201+
onClick={onClose}
202+
disabled={isLoading}
203+
className="px-6 py-2 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
204+
>
205+
Cancel
206+
</button>
207+
<button
208+
onClick={onConfirm}
209+
disabled={isLoading}
210+
className="px-6 py-2 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
211+
>
212+
{isLoading ? (
213+
<>
214+
<svg
215+
className="animate-spin h-5 w-5"
216+
fill="none"
217+
viewBox="0 0 24 24"
218+
>
219+
<circle
220+
className="opacity-25"
221+
cx="12"
222+
cy="12"
223+
r="10"
224+
stroke="currentColor"
225+
strokeWidth="4"
226+
/>
227+
<path
228+
className="opacity-75"
229+
fill="currentColor"
230+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
231+
/>
232+
</svg>
233+
Sending...
234+
</>
235+
) : (
236+
<>
237+
Send Purchase Requests
238+
<svg
239+
className="w-5 h-5"
240+
fill="none"
241+
stroke="currentColor"
242+
viewBox="0 0 24 24"
243+
>
244+
<path
245+
strokeLinecap="round"
246+
strokeLinejoin="round"
247+
strokeWidth={2}
248+
d="M14 5l7 7m0 0l-7 7m7-7H3"
249+
/>
250+
</svg>
251+
</>
252+
)}
253+
</button>
254+
</div>
255+
</div>
256+
</div>
257+
</div>
258+
);
259+
}

src/components/shop/PurchaseIntentButton.tsx

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
'use client';
22

3+
import { useState } from 'react';
34
import { ShoppingBag, Loader2, Check, AlertCircle } from 'lucide-react';
45
import { useRouter } from 'next/navigation';
56
import { usePurchaseIntent } from '@/hooks/usePurchaseIntent';
7+
import { useCartStore } from '@/stores/useCartStore';
8+
import PurchaseConfirmationModal from './PurchaseConfirmationModal';
69
import { logger } from '@/services/core/LoggingService';
710

811
interface PurchaseIntentButtonProps {
@@ -12,15 +15,37 @@ interface PurchaseIntentButtonProps {
1215

1316
export function PurchaseIntentButton({ disabled = false, className = '' }: PurchaseIntentButtonProps) {
1417
const router = useRouter();
18+
const [showModal, setShowModal] = useState(false);
19+
const { items } = useCartStore();
1520
const { loading, success, error, result, sendPurchaseIntent, reset } = usePurchaseIntent();
1621

17-
const handleClick = async () => {
18-
logger.info('Purchase intent button clicked', {
22+
// Calculate total in sats
23+
const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
24+
25+
const handleClick = () => {
26+
logger.info('Purchase intent button clicked - opening confirmation modal', {
1927
service: 'PurchaseIntentButton',
2028
method: 'handleClick',
29+
itemCount: items.length,
30+
});
31+
32+
setShowModal(true);
33+
};
34+
35+
const handleConfirm = async () => {
36+
logger.info('Purchase intent confirmed in modal', {
37+
service: 'PurchaseIntentButton',
38+
method: 'handleConfirm',
2139
});
2240

2341
await sendPurchaseIntent();
42+
setShowModal(false);
43+
};
44+
45+
const handleClose = () => {
46+
if (!loading) {
47+
setShowModal(false);
48+
}
2449
};
2550

2651
// Success state - show confirmation then redirect
@@ -87,22 +112,33 @@ export function PurchaseIntentButton({ disabled = false, className = '' }: Purch
87112

88113
// Default button state
89114
return (
90-
<button
91-
onClick={handleClick}
92-
disabled={disabled || loading}
93-
className={`w-full btn-primary flex items-center justify-center gap-2 ${className}`}
94-
>
95-
{loading ? (
96-
<>
97-
<Loader2 className="w-5 h-5 animate-spin" />
98-
<span>Sending to Sellers...</span>
99-
</>
100-
) : (
101-
<>
102-
<ShoppingBag className="w-5 h-5" />
103-
<span>Proceed to Checkout</span>
104-
</>
105-
)}
106-
</button>
115+
<>
116+
<button
117+
onClick={handleClick}
118+
disabled={disabled || loading}
119+
className={`w-full btn-primary flex items-center justify-center gap-2 ${className}`}
120+
>
121+
{loading ? (
122+
<>
123+
<Loader2 className="w-5 h-5 animate-spin" />
124+
<span>Sending to Sellers...</span>
125+
</>
126+
) : (
127+
<>
128+
<ShoppingBag className="w-5 h-5" />
129+
<span>Proceed to Checkout</span>
130+
</>
131+
)}
132+
</button>
133+
134+
<PurchaseConfirmationModal
135+
isOpen={showModal}
136+
onClose={handleClose}
137+
onConfirm={handleConfirm}
138+
items={items}
139+
total={total}
140+
isLoading={loading}
141+
/>
142+
</>
107143
);
108144
}

0 commit comments

Comments
 (0)