hey-pharmacist-ecommerce 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -1
- package/dist/index.d.mts +3636 -316
- package/dist/index.d.ts +3636 -316
- package/dist/index.js +6802 -3866
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6756 -3818
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -14
- package/src/components/AddressFormModal.tsx +171 -0
- package/src/components/CartItem.tsx +17 -12
- package/src/components/FilterChips.tsx +195 -0
- package/src/components/Header.tsx +121 -71
- package/src/components/OrderCard.tsx +18 -25
- package/src/components/ProductCard.tsx +209 -72
- package/src/components/ui/Button.tsx +13 -5
- package/src/components/ui/Card.tsx +46 -0
- package/src/hooks/useAddresses.ts +83 -0
- package/src/hooks/useOrders.ts +37 -19
- package/src/hooks/useProducts.ts +55 -63
- package/src/hooks/useWishlistProducts.ts +75 -0
- package/src/index.ts +3 -19
- package/src/lib/Apis/api.ts +1 -0
- package/src/lib/Apis/apis/cart-api.ts +3 -3
- package/src/lib/Apis/apis/inventory-api.ts +0 -108
- package/src/lib/Apis/apis/stores-api.ts +70 -0
- package/src/lib/Apis/apis/wishlist-api.ts +447 -0
- package/src/lib/Apis/models/cart-item-populated.ts +0 -1
- package/src/lib/Apis/models/create-single-variant-product-dto.ts +3 -10
- package/src/lib/Apis/models/create-variant-dto.ts +26 -33
- package/src/lib/Apis/models/extended-product-dto.ts +20 -24
- package/src/lib/Apis/models/index.ts +2 -1
- package/src/lib/Apis/models/order-time-line-dto.ts +49 -0
- package/src/lib/Apis/models/order.ts +3 -8
- package/src/lib/Apis/models/populated-order.ts +3 -8
- package/src/lib/Apis/models/product-variant.ts +29 -0
- package/src/lib/Apis/models/update-product-variant-dto.ts +16 -23
- package/src/lib/Apis/models/wishlist.ts +51 -0
- package/src/lib/Apis/wrapper.ts +18 -7
- package/src/lib/api-adapter/index.ts +0 -12
- package/src/lib/types/index.ts +16 -61
- package/src/lib/utils/colors.ts +7 -4
- package/src/lib/utils/format.ts +1 -1
- package/src/lib/validations/address.ts +14 -0
- package/src/providers/AuthProvider.tsx +61 -31
- package/src/providers/CartProvider.tsx +18 -28
- package/src/providers/EcommerceProvider.tsx +7 -0
- package/src/providers/FavoritesProvider.tsx +86 -0
- package/src/providers/ThemeProvider.tsx +16 -1
- package/src/providers/WishlistProvider.tsx +174 -0
- package/src/screens/AddressesScreen.tsx +484 -0
- package/src/screens/CartScreen.tsx +120 -84
- package/src/screens/CategoriesScreen.tsx +120 -0
- package/src/screens/CheckoutScreen.tsx +919 -241
- package/src/screens/CurrentOrdersScreen.tsx +125 -61
- package/src/screens/HomeScreen.tsx +209 -0
- package/src/screens/LoginScreen.tsx +133 -88
- package/src/screens/NewAddressScreen.tsx +187 -0
- package/src/screens/OrdersScreen.tsx +162 -50
- package/src/screens/ProductDetailScreen.tsx +641 -190
- package/src/screens/ProfileScreen.tsx +192 -116
- package/src/screens/RegisterScreen.tsx +193 -144
- package/src/screens/SearchResultsScreen.tsx +165 -0
- package/src/screens/ShopScreen.tsx +1110 -146
- package/src/screens/WishlistScreen.tsx +428 -0
- package/src/lib/Apis/models/inventory-paginated-response.ts +0 -75
- package/src/lib/api/auth.ts +0 -81
- package/src/lib/api/cart.ts +0 -42
- package/src/lib/api/orders.ts +0 -53
- package/src/lib/api/products.ts +0 -51
- package/src/lib/api-adapter/auth-adapter.ts +0 -196
- package/src/lib/api-adapter/cart-adapter.ts +0 -193
- package/src/lib/api-adapter/mappers.ts +0 -152
- package/src/lib/api-adapter/orders-adapter.ts +0 -195
- package/src/lib/api-adapter/products-adapter.ts +0 -194
|
@@ -1,340 +1,1018 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState } from 'react';
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
4
|
import { motion } from 'framer-motion';
|
|
5
5
|
import { useForm } from 'react-hook-form';
|
|
6
6
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
Check as CheckIcon,
|
|
10
|
+
CreditCard,
|
|
11
|
+
Lock,
|
|
12
|
+
MapPin,
|
|
13
|
+
PackageCheck,
|
|
14
|
+
Plus,
|
|
15
|
+
ShieldCheck,
|
|
16
|
+
Truck,
|
|
17
|
+
} from 'lucide-react';
|
|
9
18
|
import { Button } from '@/components/ui/Button';
|
|
10
19
|
import { Input } from '@/components/ui/Input';
|
|
11
20
|
import { useCart } from '@/providers/CartProvider';
|
|
12
21
|
import { useAuth } from '@/providers/AuthProvider';
|
|
13
|
-
import {
|
|
22
|
+
import { OrdersApi } from '@/lib/Apis/apis/orders-api';
|
|
23
|
+
import { ShippingApi } from '@/lib/Apis/apis/shipping-api';
|
|
24
|
+
import { useAddresses } from '@/hooks/useAddresses';
|
|
14
25
|
import { formatPrice } from '@/lib/utils/format';
|
|
15
26
|
import { useRouter } from 'next/navigation';
|
|
16
27
|
import { toast } from 'sonner';
|
|
28
|
+
import { AXIOS_CONFIG } from '@/lib/Apis/wrapper';
|
|
29
|
+
import { Card } from '@/components/ui/Card';
|
|
30
|
+
import { AddressFormModal } from '@/components/AddressFormModal';
|
|
31
|
+
import { Edit3, Trash2 } from 'lucide-react';
|
|
17
32
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
addressLine1: z.string().min(5, 'Address is required'),
|
|
21
|
-
addressLine2: z.string().optional(),
|
|
22
|
-
city: z.string().min(2, 'City is required'),
|
|
23
|
-
state: z.string().min(2, 'State is required'),
|
|
24
|
-
zipCode: z.string().min(5, 'ZIP code is required'),
|
|
25
|
-
country: z.string().min(2, 'Country is required'),
|
|
26
|
-
phone: z.string().min(10, 'Phone number is required'),
|
|
27
|
-
});
|
|
33
|
+
import { addressSchema } from '@/lib/validations/address';
|
|
34
|
+
import Image from 'next/image';
|
|
28
35
|
|
|
29
36
|
const checkoutSchema = z.object({
|
|
30
37
|
shipping: addressSchema,
|
|
31
38
|
billing: addressSchema,
|
|
32
|
-
sameAsShipping: z.boolean(),
|
|
39
|
+
sameAsShipping: z.boolean().default(true),
|
|
33
40
|
});
|
|
34
41
|
|
|
35
42
|
type CheckoutFormData = z.infer<typeof checkoutSchema>;
|
|
36
43
|
|
|
44
|
+
|
|
45
|
+
const SHIPPING_THRESHOLD = 100;
|
|
46
|
+
const TAX_RATE = 0.08;
|
|
47
|
+
|
|
48
|
+
// Add enums for payment methods
|
|
49
|
+
const PAYMENT_METHODS = [
|
|
50
|
+
{
|
|
51
|
+
label: 'Card',
|
|
52
|
+
value: 'Card',
|
|
53
|
+
icon: <CreditCard className="w-5 h-5" />,
|
|
54
|
+
description: 'Pay securely with your credit or debit card',
|
|
55
|
+
className: 'border-blue-500 hover:bg-blue-50',
|
|
56
|
+
activeClass: 'bg-blue-50 border-blue-500 text-blue-700',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
label: 'Cash',
|
|
60
|
+
value: 'Cash',
|
|
61
|
+
icon: <PackageCheck className="w-5 h-5" />,
|
|
62
|
+
description: 'Pay with cash on delivery or at pickup',
|
|
63
|
+
className: 'border-amber-500 hover:bg-amber-50',
|
|
64
|
+
activeClass: 'bg-amber-50 border-amber-500 text-amber-700',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
label: 'Credit',
|
|
68
|
+
value: 'Credit',
|
|
69
|
+
icon: <ShieldCheck className="w-5 h-5" />,
|
|
70
|
+
description: 'Use your account credit',
|
|
71
|
+
className: 'border-emerald-500 hover:bg-emerald-50',
|
|
72
|
+
activeClass: 'bg-emerald-50 border-emerald-500 text-emerald-700',
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
37
76
|
export function CheckoutScreen() {
|
|
38
77
|
const router = useRouter();
|
|
39
|
-
const { cart } = useCart();
|
|
78
|
+
const { cart, clearCart } = useCart();
|
|
40
79
|
const { isAuthenticated, user } = useAuth();
|
|
41
80
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
42
|
-
const [
|
|
81
|
+
const [isDelivery, setIsDelivery] = useState(true); // delivery or pickup
|
|
82
|
+
const [paymentMethod, setPaymentMethod] = useState('Card');
|
|
83
|
+
const [error, setError] = useState<string | null>(null);
|
|
84
|
+
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null);
|
|
85
|
+
const [selectedStoreAddressId, setSelectedStoreAddressId] = useState<string | null>(null);
|
|
86
|
+
const [storeAddresses, setStoreAddresses] = useState<any[]>([]); // For pickup selection
|
|
87
|
+
const [isAddressModalOpen, setIsAddressModalOpen] = useState(false);
|
|
88
|
+
const [editingAddress, setEditingAddress] = useState<any | null>(null);
|
|
89
|
+
const [shippingPrice, setShippingPrice] = useState(0);
|
|
43
90
|
|
|
91
|
+
|
|
92
|
+
// Use the addresses hook
|
|
93
|
+
const {
|
|
94
|
+
addresses: userAddresses,
|
|
95
|
+
defaultAddress
|
|
96
|
+
} = useAddresses();
|
|
97
|
+
const [selectedShippingRateId, setSelectedShippingRateId] = useState<string | null>(null);
|
|
98
|
+
const [shippingRates, setShippingRates] = useState<any[]>([]);
|
|
99
|
+
const [shippingRatesLoading, setShippingRatesLoading] = useState(false);
|
|
100
|
+
const [shippingRatesError, setShippingRatesError] = useState<string | null>(null);
|
|
101
|
+
const [storeData, setStoreData] = useState<any>(null); // Simulate store data if needed
|
|
102
|
+
const { addresses, isLoading, refresh, removeAddress } = useAddresses();
|
|
44
103
|
const {
|
|
45
104
|
register,
|
|
46
105
|
handleSubmit,
|
|
47
106
|
formState: { errors },
|
|
107
|
+
watch,
|
|
108
|
+
setValue,
|
|
48
109
|
} = useForm<CheckoutFormData>({
|
|
49
110
|
resolver: zodResolver(checkoutSchema),
|
|
50
111
|
defaultValues: {
|
|
51
112
|
sameAsShipping: true,
|
|
52
113
|
shipping: {
|
|
53
|
-
|
|
54
|
-
phone: user?.
|
|
114
|
+
name: user ? `${user.firstname} ${user.lastname}` : '',
|
|
115
|
+
phone: user?.phoneNumber || undefined,
|
|
55
116
|
country: 'United States',
|
|
56
117
|
},
|
|
57
118
|
billing: {
|
|
58
|
-
|
|
59
|
-
phone: user?.
|
|
119
|
+
name: user ? `${user.firstname} ${user.lastname}` : '',
|
|
120
|
+
phone: user?.phoneNumber || undefined,
|
|
60
121
|
country: 'United States',
|
|
61
122
|
},
|
|
62
123
|
},
|
|
63
124
|
});
|
|
64
125
|
|
|
126
|
+
const sameAsShipping = watch('sameAsShipping', true);
|
|
127
|
+
|
|
128
|
+
// Sync billing address with shipping address when 'Billing same as shipping' is checked
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (sameAsShipping) {
|
|
131
|
+
setValue('billing.name', watch('shipping.name'));
|
|
132
|
+
setValue('billing.phone', watch('shipping.phone'));
|
|
133
|
+
setValue('billing.street1', watch('shipping.street1'));
|
|
134
|
+
setValue('billing.street2', watch('shipping.street2'));
|
|
135
|
+
setValue('billing.city', watch('shipping.city'));
|
|
136
|
+
setValue('billing.state', watch('shipping.state'));
|
|
137
|
+
setValue('billing.zip', watch('shipping.zip'));
|
|
138
|
+
setValue('billing.country', watch('shipping.country'));
|
|
139
|
+
}
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
}, [sameAsShipping, watch('shipping.name'), watch('shipping.phone'), watch('shipping.street1'), watch('shipping.street2'), watch('shipping.city'), watch('shipping.state'), watch('shipping.zip'), watch('shipping.country')]);
|
|
142
|
+
|
|
143
|
+
// const handleAddressAdded = async (address: any) => {
|
|
144
|
+
// await refresh();
|
|
145
|
+
// };
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (defaultAddress && !selectedAddressId) {
|
|
149
|
+
setSelectedAddressId(defaultAddress.id);
|
|
150
|
+
// Update form with default address
|
|
151
|
+
setValue('shipping.name', defaultAddress.name);
|
|
152
|
+
setValue('shipping.phone', defaultAddress.phone || (undefined as unknown as string));
|
|
153
|
+
setValue('shipping.street1', defaultAddress.street1);
|
|
154
|
+
setValue('shipping.street2', defaultAddress.street2 || '');
|
|
155
|
+
setValue('shipping.city', defaultAddress.city);
|
|
156
|
+
setValue('shipping.state', defaultAddress.state);
|
|
157
|
+
setValue('shipping.zip', defaultAddress.zip);
|
|
158
|
+
setValue('shipping.country', defaultAddress.country);
|
|
159
|
+
}
|
|
160
|
+
}, [defaultAddress, selectedAddressId, setValue]);
|
|
161
|
+
|
|
162
|
+
// Handle shipping rates response and errors
|
|
163
|
+
const handleShippingResponse = (response: any) => {
|
|
164
|
+
if (!response?.data) {
|
|
165
|
+
setShippingRatesError('Unable to calculate shipping rates');
|
|
166
|
+
setShippingRates([]);
|
|
167
|
+
setSelectedShippingRateId(null);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const rates = response.data.rates || [];
|
|
172
|
+
if (rates.length > 0) {
|
|
173
|
+
setShippingRates(rates);
|
|
174
|
+
setShippingRatesError(null);
|
|
175
|
+
setSelectedShippingRateId(rates[0].objectId); // Select first rate by default
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If no rates, show the most relevant error message from messages
|
|
180
|
+
const messages = response.data.messages || [];
|
|
181
|
+
// Prefer messages that mention 'shipping', 'service area', or are from USPS
|
|
182
|
+
const relevantMessage = messages.find((msg: any) =>
|
|
183
|
+
msg.text.toLowerCase().includes('shipping') ||
|
|
184
|
+
msg.text.toLowerCase().includes('service area') ||
|
|
185
|
+
msg.source === 'USPS'
|
|
186
|
+
) || messages[0];
|
|
187
|
+
setShippingRatesError(relevantMessage?.text || 'Shipping is not available to this address');
|
|
188
|
+
setShippingRates([]);
|
|
189
|
+
setSelectedShippingRateId(null);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Define shipping message types
|
|
193
|
+
interface ShippingMessage {
|
|
194
|
+
source: string;
|
|
195
|
+
code: string;
|
|
196
|
+
text: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface ShippingRate {
|
|
200
|
+
objectId: string;
|
|
201
|
+
provider: string;
|
|
202
|
+
servicelevel: {
|
|
203
|
+
name: string;
|
|
204
|
+
token: string;
|
|
205
|
+
};
|
|
206
|
+
amount: string;
|
|
207
|
+
amountLocal: string;
|
|
208
|
+
currency: string;
|
|
209
|
+
currencyLocal: string;
|
|
210
|
+
durationTerms?: string;
|
|
211
|
+
estimatedDays?: number;
|
|
212
|
+
providerImage75?: string;
|
|
213
|
+
attributes?: string[];
|
|
214
|
+
test?: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface ShippingResponse {
|
|
218
|
+
data?: {
|
|
219
|
+
messages: ShippingMessage[];
|
|
220
|
+
rates: ShippingRate[];
|
|
221
|
+
status: string;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (!isDelivery || !selectedAddressId || !cart || cart?.cartBody?.items?.length === 0 || !cart?.cartBody?.items) {
|
|
228
|
+
setShippingRates([]);
|
|
229
|
+
setSelectedShippingRateId(null);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
setShippingRatesLoading(true);
|
|
233
|
+
setShippingRatesError(null);
|
|
234
|
+
(async () => {
|
|
235
|
+
try {
|
|
236
|
+
const api = new ShippingApi(AXIOS_CONFIG);
|
|
237
|
+
const cartBody = {
|
|
238
|
+
items: cart?.cartBody?.items?.map((item: any) => ({
|
|
239
|
+
productVariantId: String(item.productVariantId),
|
|
240
|
+
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
|
|
241
|
+
})),
|
|
242
|
+
discount: undefined,
|
|
243
|
+
};
|
|
244
|
+
const res = await api.getShippingRates(cartBody, selectedAddressId);
|
|
245
|
+
handleShippingResponse(res);
|
|
246
|
+
} catch (e: any) {
|
|
247
|
+
setShippingRates([]);
|
|
248
|
+
setSelectedShippingRateId(null);
|
|
249
|
+
setShippingRatesError(e?.message || 'Failed to fetch shipping rates');
|
|
250
|
+
} finally {
|
|
251
|
+
setShippingRatesLoading(false);
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
}, [isDelivery, selectedAddressId, cart]);
|
|
255
|
+
|
|
256
|
+
// Simulate fetching store addresses for pickup (replace with real API if available)
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!isDelivery) {
|
|
259
|
+
// Simulate store addresses
|
|
260
|
+
const stores = [
|
|
261
|
+
{ id: 'store1', name: 'Main Pharmacy', street1: '123 Main St', city: 'Seattle' },
|
|
262
|
+
{ id: 'store2', name: 'Eastside Pickup', street1: '456 East Ave', city: 'Bellevue' },
|
|
263
|
+
];
|
|
264
|
+
setStoreAddresses(stores);
|
|
265
|
+
setSelectedStoreAddressId(stores[0].id);
|
|
266
|
+
// Pickup mode: no shipping cost
|
|
267
|
+
setShippingPrice(0);
|
|
268
|
+
} else {
|
|
269
|
+
setStoreAddresses([]);
|
|
270
|
+
setSelectedStoreAddressId(null);
|
|
271
|
+
}
|
|
272
|
+
}, [isDelivery]);
|
|
273
|
+
|
|
274
|
+
const checkoutSteps = [
|
|
275
|
+
{ id: 1, label: 'Cart', status: 'complete' as const },
|
|
276
|
+
{ id: 2, label: 'Details', status: 'current' as const },
|
|
277
|
+
{ id: 3, label: 'Payment', status: 'upcoming' as const },
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
// Unified checkout handler (admin-style)
|
|
65
281
|
const onSubmit = async (data: CheckoutFormData) => {
|
|
282
|
+
setError(null);
|
|
66
283
|
if (!isAuthenticated) {
|
|
67
284
|
toast.error('Please login to continue');
|
|
68
|
-
router.push('/login?redirect=/checkout');
|
|
285
|
+
setTimeout(() => router.push('/login?redirect=/checkout'), 50);
|
|
69
286
|
return;
|
|
70
287
|
}
|
|
288
|
+
if (!cart || cart?.cartBody?.items?.length === 0 || !cart?.cartBody?.items) {
|
|
289
|
+
setError('Your cart is empty. Please add items to your cart.');
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!paymentMethod) {
|
|
293
|
+
setError('Please select a payment method.');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (isDelivery) {
|
|
297
|
+
if (!selectedAddressId) {
|
|
298
|
+
setError('Please select a shipping address.');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!selectedShippingRateId) {
|
|
302
|
+
setError('Please select a shipping method.');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Validate shipping address fields
|
|
306
|
+
const requiredFields = ['name', 'street1', 'city', 'zip', 'country'];
|
|
307
|
+
for (const field of requiredFields) {
|
|
308
|
+
if (!data.shipping[field as keyof typeof data.shipping]) {
|
|
309
|
+
setError(`Please fill in the shipping address: ${field}.`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
if (!selectedStoreAddressId) {
|
|
315
|
+
setError('Please select a pickup location.');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (storeAddresses.length === 0) {
|
|
319
|
+
setError('Store pickup location is not available.');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (paymentMethod === 'Card' && userAddresses.length === 0) {
|
|
323
|
+
setError('Card payments require a billing address. Please add an address to your account or choose a different payment method.');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!sameAsShipping) {
|
|
328
|
+
const requiredBillingFields = ['fullName', 'addressLine1', 'city', 'zipCode', 'country'];
|
|
329
|
+
for (const field of requiredBillingFields) {
|
|
330
|
+
if (!data.billing[field as keyof typeof data.billing]) {
|
|
331
|
+
setError(`Please fill in the billing address: ${field}.`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
71
336
|
|
|
72
337
|
setIsSubmitting(true);
|
|
338
|
+
toast('Submitting order...', { icon: <CreditCard className="h-4 w-4" /> });
|
|
73
339
|
try {
|
|
74
|
-
|
|
340
|
+
// Prepare items array for backend
|
|
341
|
+
const items = (cart?.cartBody?.items || []).map((item: any) => ({
|
|
342
|
+
productVariantId: String(item.productVariantId),
|
|
343
|
+
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
|
|
344
|
+
})).filter((item: any) => item.productVariantId);
|
|
345
|
+
|
|
346
|
+
// Determine billing address ID for card payments (admin-style)
|
|
347
|
+
let billingAddressId: string | undefined = undefined;
|
|
348
|
+
if (isDelivery) {
|
|
349
|
+
billingAddressId = selectedAddressId || undefined;
|
|
350
|
+
} else if (paymentMethod === 'Card') {
|
|
351
|
+
billingAddressId = userAddresses.length > 0 ? userAddresses[0].id : undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Prepare order DTO (admin-style)
|
|
355
|
+
const orderDTO: any = {
|
|
356
|
+
items,
|
|
357
|
+
paymentMethod,
|
|
358
|
+
orderStatus: 'Pending',
|
|
359
|
+
chargeTax: true,
|
|
360
|
+
orderRemindingDates: [],
|
|
75
361
|
shippingAddress: data.shipping,
|
|
76
362
|
billingAddress: sameAsShipping ? data.shipping : data.billing,
|
|
77
|
-
|
|
363
|
+
pickupStoreId: !isDelivery ? selectedStoreAddressId : undefined,
|
|
78
364
|
};
|
|
79
365
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
366
|
+
const api = new OrdersApi(AXIOS_CONFIG);
|
|
367
|
+
const response = await api.createCheckout(
|
|
368
|
+
orderDTO,
|
|
369
|
+
isDelivery,
|
|
370
|
+
user?.id,
|
|
371
|
+
isDelivery ? (selectedShippingRateId || undefined) : undefined,
|
|
372
|
+
billingAddressId
|
|
373
|
+
);
|
|
374
|
+
// Handle insufficient credit error (fallback: check for error message)
|
|
375
|
+
if (response?.data?.payment && response.data.payment.hostedInvoiceUrl === 'INSUFFICIENT_CREDIT') {
|
|
376
|
+
setError('You have insufficient credit to complete this order.');
|
|
377
|
+
toast.error('You have insufficient credit to complete this order.');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (paymentMethod === 'Card') {
|
|
381
|
+
const paymentUrl = response?.data?.payment?.hostedInvoiceUrl;
|
|
382
|
+
if (paymentUrl && paymentUrl !== 'INSUFFICIENT_CREDIT') {
|
|
383
|
+
window.open(paymentUrl, '_blank');
|
|
384
|
+
await clearCart();
|
|
385
|
+
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
toast.success('Order placed successfully!');
|
|
389
|
+
await clearCart();
|
|
390
|
+
router.push(`/orders/${response.data?.id}`);
|
|
85
391
|
} else {
|
|
86
392
|
toast.success('Order placed successfully!');
|
|
87
|
-
|
|
393
|
+
await clearCart();
|
|
394
|
+
router.push(`/orders/${response.data?.id}`);
|
|
88
395
|
}
|
|
89
|
-
} catch (
|
|
90
|
-
|
|
396
|
+
} catch (err: any) {
|
|
397
|
+
const msg = err?.message || (err?.response?.data?.message) || 'Failed to place order';
|
|
398
|
+
setError(msg);
|
|
399
|
+
toast.error(msg);
|
|
91
400
|
} finally {
|
|
92
401
|
setIsSubmitting(false);
|
|
93
402
|
}
|
|
94
403
|
};
|
|
95
404
|
|
|
96
|
-
if (!cart || cart
|
|
405
|
+
if (!cart || cart?.cartBody?.items?.length === 0 || !cart?.cartBody?.items) {
|
|
97
406
|
router.push('/cart');
|
|
98
407
|
return null;
|
|
99
408
|
}
|
|
100
|
-
|
|
101
409
|
const subtotal = cart.total;
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const total = subtotal + shipping + tax;
|
|
410
|
+
const tax = 0;
|
|
411
|
+
const total = subtotal + shippingPrice + tax;
|
|
105
412
|
|
|
106
413
|
return (
|
|
107
|
-
<div className="min-h-screen bg-
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
label="Address Line 1"
|
|
144
|
-
{...register('shipping.addressLine1')}
|
|
145
|
-
error={errors.shipping?.addressLine1?.message}
|
|
146
|
-
/>
|
|
414
|
+
<div className="min-h-screen bg-slate-50">
|
|
415
|
+
<section className="relative overflow-hidden bg-gradient-to-br from-[rgb(var(--header-from))] via-[rgb(var(--header-via))] to-[rgb(var(--header-to))] text-white">
|
|
416
|
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.35),_transparent_60%)]" />
|
|
417
|
+
<div className="relative container mx-auto px-4 py-16">
|
|
418
|
+
<div className="grid gap-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
419
|
+
<motion.div
|
|
420
|
+
initial={{ opacity: 0, y: 24 }}
|
|
421
|
+
animate={{ opacity: 1, y: 0 }}
|
|
422
|
+
className="space-y-6"
|
|
423
|
+
>
|
|
424
|
+
<span className="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-sm font-semibold uppercase tracking-[0.35em] text-white/70 backdrop-blur">
|
|
425
|
+
<ShieldCheck className="h-4 w-4" />
|
|
426
|
+
Secure checkout
|
|
427
|
+
</span>
|
|
428
|
+
<h1 className="text-4xl font-bold md:text-5xl">
|
|
429
|
+
Provide delivery details
|
|
430
|
+
</h1>
|
|
431
|
+
<p className="text-white/75 md:text-lg">
|
|
432
|
+
Our pharmacists handle every package with care. Share your shipping and billing
|
|
433
|
+
information so we can dispatch your order within the next 12 hours.
|
|
434
|
+
</p>
|
|
435
|
+
<div className="flex flex-wrap gap-3">
|
|
436
|
+
{checkoutSteps.map((step) => (
|
|
437
|
+
<div
|
|
438
|
+
key={step.id}
|
|
439
|
+
className={`flex items-center gap-3 rounded-2xl px-4 py-2 text-sm font-semibold ${step.status === 'complete'
|
|
440
|
+
? 'bg-white/20 text-white'
|
|
441
|
+
: step.status === 'current'
|
|
442
|
+
? 'bg-white text-primary-700'
|
|
443
|
+
: 'bg-white/10 text-white/60'
|
|
444
|
+
}`}
|
|
445
|
+
>
|
|
446
|
+
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/20 text-xs font-bold">
|
|
447
|
+
{step.id}
|
|
448
|
+
</span>
|
|
449
|
+
{step.label}
|
|
147
450
|
</div>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
451
|
+
))}
|
|
452
|
+
</div>
|
|
453
|
+
</motion.div>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
</section>
|
|
457
|
+
|
|
458
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
459
|
+
{error && <div className="mb-4 text-red-600 font-semibold">{error}</div>}
|
|
460
|
+
<div className="pt-12 container mx-auto grid gap-10 px-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
461
|
+
<motion.div
|
|
462
|
+
initial={{ opacity: 0, y: 24 }}
|
|
463
|
+
animate={{ opacity: 1, y: 0 }}
|
|
464
|
+
className="space-y-8"
|
|
465
|
+
>
|
|
466
|
+
<section className="rounded-3xl border border-slate-100 bg-white p-6 shadow-lg shadow-primary-50">
|
|
467
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
468
|
+
<div>
|
|
469
|
+
<h2 className="text-xl font-semibold text-slate-900">
|
|
470
|
+
Shipping information
|
|
471
|
+
</h2>
|
|
472
|
+
<p className="text-sm text-slate-500">
|
|
473
|
+
We use temperature-aware packaging and real-time tracking on every shipment.
|
|
474
|
+
</p>
|
|
475
|
+
</div>
|
|
476
|
+
<span className="inline-flex items-center gap-2 rounded-full bg-primary-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700">
|
|
477
|
+
<Truck className="h-4 w-4" />
|
|
478
|
+
Dispatch in 12h
|
|
479
|
+
</span>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
483
|
+
{/* Address selection for delivery */}
|
|
484
|
+
{isDelivery && (
|
|
485
|
+
<div className="md:col-span-2 space-y-4">
|
|
486
|
+
<div className="flex items-center justify-between">
|
|
487
|
+
<label className="block font-semibold">Select Address</label>
|
|
488
|
+
<Button
|
|
489
|
+
type="button"
|
|
490
|
+
variant="outline"
|
|
491
|
+
size="sm"
|
|
492
|
+
onClick={() => { setEditingAddress(null); setIsAddressModalOpen(true); }}
|
|
493
|
+
>
|
|
494
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
495
|
+
Add New Address
|
|
496
|
+
</Button>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
{userAddresses.length > 0 ? (
|
|
500
|
+
<div className="grid gap-4">
|
|
501
|
+
{userAddresses.map(addr => (
|
|
502
|
+
<label
|
|
503
|
+
key={addr.id}
|
|
504
|
+
className={`group relative flex items-start gap-3 p-4 rounded-2xl border ${selectedAddressId === addr.id
|
|
505
|
+
? 'border-primary-500 bg-primary-50 shadow-sm'
|
|
506
|
+
: 'border-slate-200 bg-white'
|
|
507
|
+
} cursor-pointer hover:border-primary-300 transition-colors`}
|
|
508
|
+
>
|
|
509
|
+
<input
|
|
510
|
+
type="radio"
|
|
511
|
+
name="selectedAddress"
|
|
512
|
+
value={addr.id}
|
|
513
|
+
checked={selectedAddressId === addr.id}
|
|
514
|
+
onChange={() => {
|
|
515
|
+
setSelectedAddressId(addr.id);
|
|
516
|
+
// Update form with selected address
|
|
517
|
+
setValue('shipping.name', addr.name);
|
|
518
|
+
setValue('shipping.phone', addr.phone || (undefined as unknown as string));
|
|
519
|
+
setValue('shipping.street1', addr.street1);
|
|
520
|
+
setValue('shipping.street2', addr.street2 || '');
|
|
521
|
+
setValue('shipping.city', addr.city);
|
|
522
|
+
setValue('shipping.state', addr.state);
|
|
523
|
+
setValue('shipping.zip', addr.zip);
|
|
524
|
+
setValue('shipping.country', addr.country);
|
|
525
|
+
}}
|
|
526
|
+
className="mt-1"
|
|
527
|
+
/>
|
|
528
|
+
<div className="flex-1">
|
|
529
|
+
<p className="font-semibold text-slate-900">{addr.name}</p>
|
|
530
|
+
<p className="text-sm text-slate-600">{addr.street1}</p>
|
|
531
|
+
{addr.street2 && (
|
|
532
|
+
<p className="text-sm text-slate-600">{addr.street2}</p>
|
|
533
|
+
)}
|
|
534
|
+
<p className="text-sm text-slate-600">
|
|
535
|
+
{addr.city}, {addr.state} {addr.zip}
|
|
536
|
+
</p>
|
|
537
|
+
<p className="text-sm text-slate-600">{addr.country}</p>
|
|
538
|
+
{addr.phone && (
|
|
539
|
+
<p className="text-sm text-slate-600 mt-1">{addr.phone}</p>
|
|
540
|
+
)}
|
|
541
|
+
<div className="mt-3 flex items-center gap-2">
|
|
542
|
+
{addr.isDefault && (
|
|
543
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-semibold text-primary-700">
|
|
544
|
+
Default
|
|
545
|
+
</span>
|
|
546
|
+
)}
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
onClick={(e) => { e.preventDefault(); setEditingAddress(addr); setIsAddressModalOpen(true); }}
|
|
550
|
+
className="inline-flex items-center gap-1 rounded-full border border-slate-200 px-2.5 py-0.5 text-xs font-medium text-slate-600 hover:border-primary-300 hover:text-primary-600"
|
|
551
|
+
>
|
|
552
|
+
<Edit3 className="h-3.5 w-3.5" /> Edit
|
|
553
|
+
</button>
|
|
554
|
+
<button
|
|
555
|
+
type="button"
|
|
556
|
+
onClick={async (e) => {
|
|
557
|
+
e.preventDefault();
|
|
558
|
+
const yes = window.confirm('Delete this address?');
|
|
559
|
+
if (!yes) return;
|
|
560
|
+
try {
|
|
561
|
+
await removeAddress(addr.id);
|
|
562
|
+
if (selectedAddressId === addr.id) setSelectedAddressId(null);
|
|
563
|
+
toast.success('Address deleted');
|
|
564
|
+
} catch (e) {
|
|
565
|
+
toast.error('Failed to delete address');
|
|
566
|
+
}
|
|
567
|
+
}}
|
|
568
|
+
className="inline-flex items-center gap-1 rounded-full border border-red-200 px-2.5 py-0.5 text-xs font-semibold text-red-600 hover:border-red-300 hover:text-red-700"
|
|
569
|
+
>
|
|
570
|
+
<Trash2 className="h-3.5 w-3.5" /> Delete
|
|
571
|
+
</button>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
</label>
|
|
575
|
+
))}
|
|
576
|
+
</div>
|
|
577
|
+
) : (
|
|
578
|
+
<div className="text-center py-8 bg-slate-50 rounded-lg">
|
|
579
|
+
<MapPin className="h-12 w-12 mx-auto text-slate-400" />
|
|
580
|
+
<p className="mt-2 text-slate-600">No addresses found</p>
|
|
581
|
+
<p className="text-sm text-slate-500">Add a new address to continue</p>
|
|
582
|
+
</div>
|
|
583
|
+
)}
|
|
153
584
|
</div>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
/>
|
|
169
|
-
<Input
|
|
170
|
-
label="Country"
|
|
171
|
-
{...register('shipping.country')}
|
|
172
|
-
error={errors.shipping?.country?.message}
|
|
173
|
-
/>
|
|
174
|
-
<div className="col-span-2">
|
|
175
|
-
<Input
|
|
176
|
-
label="Phone Number"
|
|
177
|
-
{...register('shipping.phone')}
|
|
178
|
-
error={errors.shipping?.phone?.message}
|
|
179
|
-
/>
|
|
585
|
+
)}
|
|
586
|
+
{/* Store address selection for pickup */}
|
|
587
|
+
{!isDelivery && storeAddresses.length > 0 && (
|
|
588
|
+
<div className="md:col-span-2">
|
|
589
|
+
<label className="block mb-2 font-semibold">Select Pickup Location</label>
|
|
590
|
+
<select
|
|
591
|
+
className="w-full border rounded p-2"
|
|
592
|
+
value={selectedStoreAddressId || ''}
|
|
593
|
+
onChange={e => setSelectedStoreAddressId(e.target.value)}
|
|
594
|
+
>
|
|
595
|
+
{storeAddresses.map(addr => (
|
|
596
|
+
<option key={addr.id} value={addr.id}>{addr.name} - {addr.street1}, {addr.city}</option>
|
|
597
|
+
))}
|
|
598
|
+
</select>
|
|
180
599
|
</div>
|
|
600
|
+
)}
|
|
601
|
+
</div>
|
|
602
|
+
</section>
|
|
603
|
+
|
|
604
|
+
{/* Shipping Options - Only show if there's a shipping cost */}
|
|
605
|
+
{isDelivery && selectedAddressId && (
|
|
606
|
+
<Card className="p-6 border border-gray-200 rounded-xl shadow-sm">
|
|
607
|
+
<div className="flex items-center gap-3 text-xl font-semibold text-gray-900 pb-4 mb-6 border-b">
|
|
608
|
+
<Truck className="text-accent w-6 h-6" />
|
|
609
|
+
<span>Shipping Options</span>
|
|
181
610
|
</div>
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
<label className="flex items-center gap-3 mb-6 cursor-pointer">
|
|
196
|
-
<input
|
|
197
|
-
type="checkbox"
|
|
198
|
-
checked={sameAsShipping}
|
|
199
|
-
onChange={(e) => setSameAsShipping(e.target.checked)}
|
|
200
|
-
className="w-5 h-5 text-primary-600 rounded"
|
|
201
|
-
/>
|
|
202
|
-
<span className="text-gray-700">Same as shipping address</span>
|
|
203
|
-
</label>
|
|
204
|
-
|
|
205
|
-
{!sameAsShipping && (
|
|
206
|
-
<div className="grid grid-cols-2 gap-4">
|
|
207
|
-
<div className="col-span-2">
|
|
208
|
-
<Input
|
|
209
|
-
label="Full Name"
|
|
210
|
-
{...register('billing.fullName')}
|
|
211
|
-
error={errors.billing?.fullName?.message}
|
|
212
|
-
/>
|
|
213
|
-
</div>
|
|
214
|
-
<div className="col-span-2">
|
|
215
|
-
<Input
|
|
216
|
-
label="Address Line 1"
|
|
217
|
-
{...register('billing.addressLine1')}
|
|
218
|
-
error={errors.billing?.addressLine1?.message}
|
|
219
|
-
/>
|
|
220
|
-
</div>
|
|
221
|
-
<div className="col-span-2">
|
|
222
|
-
<Input
|
|
223
|
-
label="Address Line 2 (Optional)"
|
|
224
|
-
{...register('billing.addressLine2')}
|
|
225
|
-
/>
|
|
611
|
+
|
|
612
|
+
{shippingRatesLoading ? (
|
|
613
|
+
<div className="flex items-center justify-center py-12">
|
|
614
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
|
|
615
|
+
<span className="ml-3 text-gray-600">Loading shipping options...</span>
|
|
616
|
+
</div>
|
|
617
|
+
) : shippingRatesError ? (
|
|
618
|
+
<div className="text-center py-12">
|
|
619
|
+
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
|
620
|
+
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
621
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
622
|
+
</svg>
|
|
226
623
|
</div>
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
{
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
624
|
+
<p className="text-red-500 text-lg font-medium">Error loading shipping options</p>
|
|
625
|
+
<p className="text-red-400 text-sm mt-1">
|
|
626
|
+
{shippingRatesError}
|
|
627
|
+
</p>
|
|
628
|
+
</div>
|
|
629
|
+
) : shippingRates && shippingRates.length > 0 ? (
|
|
630
|
+
<div className="space-y-4">
|
|
631
|
+
{shippingRates.map((rate) => {
|
|
632
|
+
const isSelected = !!selectedShippingRateId && selectedShippingRateId === rate.objectId;
|
|
633
|
+
const isTest = rate.test;
|
|
634
|
+
const hasAttributes = rate.attributes && rate.attributes.length > 0;
|
|
635
|
+
|
|
636
|
+
return (
|
|
637
|
+
<div
|
|
638
|
+
key={rate.objectId}
|
|
639
|
+
onClick={() => setSelectedShippingRateId(rate.objectId)}
|
|
640
|
+
onMouseEnter={() => setShippingPrice(parseFloat(rate.amount))}
|
|
641
|
+
onMouseLeave={() => setShippingPrice(parseFloat(rate.amountLocal))}
|
|
642
|
+
className={`relative p-5 border-2 rounded-xl cursor-pointer transition-all duration-200 hover:shadow-md ${isSelected
|
|
643
|
+
? 'border-primary-500 bg-primary-50 ring-2 ring-primary-200'
|
|
644
|
+
: 'border-gray-200 hover:border-gray-300'
|
|
645
|
+
}`}
|
|
646
|
+
>
|
|
647
|
+
<div className="flex items-start justify-between">
|
|
648
|
+
<div className="flex items-start gap-4 flex-1">
|
|
649
|
+
{/* Provider Logo */}
|
|
650
|
+
<div className="flex-shrink-0">
|
|
651
|
+
<Image
|
|
652
|
+
src={rate.providerImage75 || '/placeholder-product.jpg'}
|
|
653
|
+
alt={rate.provider}
|
|
654
|
+
className="w-12 h-12 rounded-lg object-contain bg-white border border-gray-200 p-1"
|
|
655
|
+
onError={(e: any) => {
|
|
656
|
+
const target = e.target as HTMLImageElement;
|
|
657
|
+
target.style.display = 'none';
|
|
658
|
+
}}
|
|
659
|
+
width={48}
|
|
660
|
+
height={48}
|
|
661
|
+
/>
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
{/* Main Content */}
|
|
665
|
+
<div className="flex-1 min-w-0">
|
|
666
|
+
<div className="flex items-center gap-2 mb-2">
|
|
667
|
+
<h3 className="text-lg font-semibold text-gray-900">
|
|
668
|
+
{rate.provider} {rate.servicelevel?.name}
|
|
669
|
+
</h3>
|
|
670
|
+
{isTest && (
|
|
671
|
+
<span className="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded-full">
|
|
672
|
+
TEST
|
|
673
|
+
</span>
|
|
674
|
+
)}
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
{/* Attributes */}
|
|
678
|
+
{hasAttributes && (
|
|
679
|
+
<div className="flex flex-wrap gap-2 mb-3">
|
|
680
|
+
{rate.attributes.map((attr: string) => (
|
|
681
|
+
<span
|
|
682
|
+
key={attr}
|
|
683
|
+
className={`px-2 py-1 text-xs font-medium rounded-full ${attr === 'FASTEST'
|
|
684
|
+
? 'bg-green-100 text-green-800'
|
|
685
|
+
: attr === 'BESTVALUE'
|
|
686
|
+
? 'bg-blue-100 text-blue-800'
|
|
687
|
+
: attr === 'CHEAPEST'
|
|
688
|
+
? 'bg-purple-100 text-purple-800'
|
|
689
|
+
: 'bg-gray-100 text-gray-800'
|
|
690
|
+
}`}
|
|
691
|
+
>
|
|
692
|
+
{attr}
|
|
693
|
+
</span>
|
|
694
|
+
))}
|
|
695
|
+
</div>
|
|
696
|
+
)}
|
|
697
|
+
|
|
698
|
+
{/* Delivery Info */}
|
|
699
|
+
<div className="space-y-1 text-sm text-gray-600">
|
|
700
|
+
{rate.durationTerms && (
|
|
701
|
+
<div className="flex items-center gap-2">
|
|
702
|
+
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
703
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
704
|
+
</svg>
|
|
705
|
+
<span>{rate.durationTerms}</span>
|
|
706
|
+
</div>
|
|
707
|
+
)}
|
|
708
|
+
|
|
709
|
+
{rate.estimatedDays && (
|
|
710
|
+
<div className="flex items-center gap-2">
|
|
711
|
+
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
712
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
713
|
+
</svg>
|
|
714
|
+
<span>Estimated {rate.estimatedDays} day{rate.estimatedDays !== 1 ? 's' : ''}</span>
|
|
715
|
+
</div>
|
|
716
|
+
)}
|
|
717
|
+
|
|
718
|
+
<div className="flex items-center gap-2">
|
|
719
|
+
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
720
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
721
|
+
</svg>
|
|
722
|
+
<span>Carrier: {rate.provider}</span>
|
|
723
|
+
</div>
|
|
724
|
+
|
|
725
|
+
<div className="flex items-center gap-2">
|
|
726
|
+
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
727
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
|
728
|
+
</svg>
|
|
729
|
+
<span>Currency: {rate.currency}</span>
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
</div>
|
|
734
|
+
|
|
735
|
+
{/* Price */}
|
|
736
|
+
<div className="flex flex-col items-end">
|
|
737
|
+
<div className="text-2xl font-bold text-gray-900">
|
|
738
|
+
{formatPrice(parseFloat(rate.amount))}
|
|
739
|
+
</div>
|
|
740
|
+
{rate.amount !== rate.amountLocal && (
|
|
741
|
+
<div className="text-sm text-gray-500">
|
|
742
|
+
{rate.amountLocal} {rate.currencyLocal}
|
|
743
|
+
</div>
|
|
744
|
+
)}
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
|
|
748
|
+
{/* Selection Indicator */}
|
|
749
|
+
{isSelected && (
|
|
750
|
+
<div className="absolute top-3 right-3">
|
|
751
|
+
<div className="w-6 h-6 bg-primary-500 rounded-full flex items-center justify-center">
|
|
752
|
+
<CheckIcon className="w-4 h-4 text-white" />
|
|
753
|
+
</div>
|
|
754
|
+
</div>
|
|
755
|
+
)}
|
|
756
|
+
</div>
|
|
757
|
+
);
|
|
758
|
+
})}
|
|
759
|
+
</div>
|
|
760
|
+
) : (
|
|
761
|
+
<div className="text-center py-12">
|
|
762
|
+
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
|
763
|
+
<Truck className="w-8 h-8 text-gray-400" />
|
|
253
764
|
</div>
|
|
765
|
+
<p className="text-gray-500 text-lg font-medium">No shipping options available</p>
|
|
766
|
+
<p className="text-gray-400 text-sm mt-1">Please check the shipping address or try a different location.</p>
|
|
254
767
|
</div>
|
|
255
768
|
)}
|
|
256
|
-
</motion.div>
|
|
257
|
-
</div>
|
|
258
769
|
|
|
770
|
+
</Card>
|
|
771
|
+
)}
|
|
772
|
+
</motion.div>
|
|
773
|
+
|
|
774
|
+
<motion.aside
|
|
775
|
+
initial={{ opacity: 0, y: 32 }}
|
|
776
|
+
animate={{ opacity: 1, y: 0 }}
|
|
777
|
+
transition={{ duration: 0.5, ease: 'easeOut', delay: 0.1 }}
|
|
778
|
+
className="space-y-10 lg:sticky lg:top-24"
|
|
779
|
+
>
|
|
259
780
|
{/* Order Summary */}
|
|
260
|
-
<div className="
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
className="
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
781
|
+
<div className="rounded-3xl border border-slate-100 bg-white p-6 shadow-xl shadow-primary-50/50 transition hover:shadow-primary-100/60">
|
|
782
|
+
<h2 className="text-2xl font-semibold text-slate-900">Order Summary</h2>
|
|
783
|
+
|
|
784
|
+
{/* Delivery Method */}
|
|
785
|
+
<section className="mt-6 space-y-4">
|
|
786
|
+
<h3 className="text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
|
787
|
+
Delivery Method
|
|
788
|
+
</h3>
|
|
789
|
+
<div className="grid grid-cols-1 gap-3">
|
|
790
|
+
{[
|
|
791
|
+
{
|
|
792
|
+
label: 'Delivery',
|
|
793
|
+
icon: <Truck className="w-5 h-5" />,
|
|
794
|
+
value: true,
|
|
795
|
+
desc: 'Shipped to your address',
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
label: 'Pickup',
|
|
799
|
+
icon: <MapPin className="w-5 h-5" />,
|
|
800
|
+
value: false,
|
|
801
|
+
desc: 'Collect from pharmacy',
|
|
802
|
+
},
|
|
803
|
+
].map((option) => {
|
|
804
|
+
const active = isDelivery === option.value
|
|
805
|
+
return (
|
|
806
|
+
<button
|
|
807
|
+
key={option.label}
|
|
808
|
+
type="button"
|
|
809
|
+
onClick={() => setIsDelivery(option.value)}
|
|
810
|
+
aria-pressed={active}
|
|
811
|
+
className={`relative flex w-full items-center justify-between rounded-xl border-2 p-3 transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-2 ${active
|
|
812
|
+
? 'border-primary-500 bg-primary-50 shadow-sm'
|
|
813
|
+
: 'border-slate-200 bg-white hover:border-primary-200 hover:bg-primary-50/40'
|
|
814
|
+
}`}
|
|
815
|
+
>
|
|
816
|
+
<div className="flex items-center gap-3">
|
|
817
|
+
<div
|
|
818
|
+
className={`p-2 rounded-lg ${active
|
|
819
|
+
? 'bg-primary-100 text-primary-600'
|
|
820
|
+
: 'bg-slate-100 text-slate-600 group-hover:bg-slate-50'
|
|
821
|
+
}`}
|
|
822
|
+
>
|
|
823
|
+
{option.icon}
|
|
824
|
+
</div>
|
|
825
|
+
<div className="text-left">
|
|
826
|
+
<div className="font-medium text-slate-900">
|
|
827
|
+
{option.label}
|
|
828
|
+
</div>
|
|
829
|
+
<p className="text-xs text-slate-500">{option.desc}</p>
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
{active && (
|
|
833
|
+
<div className="w-5 h-5 rounded-full bg-primary-500 flex items-center justify-center text-white shadow-sm">
|
|
834
|
+
<CheckIcon className="w-3 h-3" />
|
|
835
|
+
</div>
|
|
836
|
+
)}
|
|
837
|
+
</button>
|
|
838
|
+
)
|
|
839
|
+
})}
|
|
840
|
+
</div>
|
|
841
|
+
</section>
|
|
842
|
+
|
|
843
|
+
{/* Payment Method */}
|
|
844
|
+
<section className="mt-8 space-y-4">
|
|
845
|
+
<h3 className="text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
|
846
|
+
Payment Method
|
|
847
|
+
</h3>
|
|
848
|
+
<div className="space-y-3">
|
|
849
|
+
{PAYMENT_METHODS.map((pm) => {
|
|
850
|
+
const active = paymentMethod === pm.value
|
|
851
|
+
return (
|
|
852
|
+
<button
|
|
853
|
+
key={pm.value}
|
|
854
|
+
type="button"
|
|
855
|
+
onClick={() => setPaymentMethod(pm.value)}
|
|
856
|
+
className={`w-full flex items-center justify-between rounded-xl border-2 p-3 transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${active
|
|
857
|
+
? `${pm.activeClass} border-current shadow-sm`
|
|
858
|
+
: `${pm.className} border-slate-200 hover:border-primary-200 hover:shadow-sm`
|
|
859
|
+
}`}
|
|
860
|
+
>
|
|
861
|
+
<div className="flex items-center gap-3">
|
|
862
|
+
<div
|
|
863
|
+
className={`p-1.5 rounded-md ${pm.value === 'Card'
|
|
864
|
+
? 'bg-blue-100 text-blue-600'
|
|
865
|
+
: pm.value === 'Cash'
|
|
866
|
+
? 'bg-amber-100 text-amber-600'
|
|
867
|
+
: 'bg-emerald-100 text-emerald-600'
|
|
868
|
+
} ${active ? 'bg-opacity-30' : 'bg-opacity-100'}`}
|
|
869
|
+
>
|
|
870
|
+
{pm.icon}
|
|
871
|
+
</div>
|
|
872
|
+
<span className="text-sm font-medium text-slate-900">
|
|
873
|
+
{pm.label}
|
|
874
|
+
</span>
|
|
875
|
+
</div>
|
|
876
|
+
{active && (
|
|
877
|
+
<div className="w-4 h-4 rounded-full bg-primary-500 flex items-center justify-center text-white shadow-sm">
|
|
878
|
+
<CheckIcon className="w-2.5 h-2.5" />
|
|
879
|
+
</div>
|
|
880
|
+
)}
|
|
881
|
+
</button>
|
|
882
|
+
)
|
|
883
|
+
})}
|
|
884
|
+
</div>
|
|
885
|
+
<p className="text-xs text-slate-500 mt-2 pl-1 leading-relaxed">
|
|
886
|
+
{paymentMethod === 'Card' &&
|
|
887
|
+
'You will be redirected to a secure payment page.'}
|
|
888
|
+
{paymentMethod === 'Cash' &&
|
|
889
|
+
'Pay with cash at the time of delivery or pickup.'}
|
|
890
|
+
{paymentMethod === 'Credit' &&
|
|
891
|
+
'Use your available account credit for this order.'}
|
|
892
|
+
</p>
|
|
893
|
+
</section>
|
|
894
|
+
|
|
895
|
+
{/* Cart Summary */}
|
|
896
|
+
<section className="mt-8 pt-6 border-t border-slate-100">
|
|
897
|
+
<div className="max-h-60 space-y-4 overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-200 hover:scrollbar-thumb-slate-300">
|
|
898
|
+
{cart?.cartBody?.items?.map((item: any) => (
|
|
899
|
+
<div
|
|
900
|
+
key={item.productId}
|
|
901
|
+
className="flex gap-4 rounded-2xl border border-slate-100 p-4 hover:bg-slate-50/50 transition"
|
|
902
|
+
>
|
|
903
|
+
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-slate-100 text-slate-400">
|
|
904
|
+
<Image src={item.productVariantData.productMedia?.[0]?.file || '/placeholder-product.jpg'}
|
|
905
|
+
alt={item.productVariantData.name} width={64} height={64} className="object-contain" />
|
|
906
|
+
</div>
|
|
907
|
+
|
|
908
|
+
<div className="flex-1">
|
|
909
|
+
<p className="text-sm font-semibold text-slate-900">
|
|
910
|
+
{item?.productVariantData?.name}
|
|
279
911
|
</p>
|
|
280
|
-
<p className="text-
|
|
912
|
+
<p className="text-xs text-slate-500">Qty {item.quantity}</p>
|
|
281
913
|
</div>
|
|
282
|
-
<p className="font-
|
|
283
|
-
{formatPrice(item.
|
|
914
|
+
<p className="text-sm font-semibold text-slate-900">
|
|
915
|
+
{formatPrice(item.productVariantData.finalPrice * item.quantity)}
|
|
284
916
|
</p>
|
|
285
917
|
</div>
|
|
286
918
|
))}
|
|
287
919
|
</div>
|
|
288
920
|
|
|
289
|
-
|
|
290
|
-
|
|
921
|
+
{/* Totals */}
|
|
922
|
+
<div className="mt-6 space-y-3 text-sm text-slate-600">
|
|
923
|
+
<div className="flex items-center justify-between">
|
|
291
924
|
<span>Subtotal</span>
|
|
292
|
-
<span className="font-
|
|
293
|
-
|
|
294
|
-
<div className="flex justify-between text-gray-700">
|
|
295
|
-
<span>Shipping</span>
|
|
296
|
-
<span className="font-medium">
|
|
297
|
-
{shipping === 0 ? (
|
|
298
|
-
<span className="text-green-600">FREE</span>
|
|
299
|
-
) : (
|
|
300
|
-
formatPrice(shipping)
|
|
301
|
-
)}
|
|
925
|
+
<span className="font-semibold text-slate-900">
|
|
926
|
+
{formatPrice(subtotal)}
|
|
302
927
|
</span>
|
|
303
928
|
</div>
|
|
304
|
-
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
<div className="flex justify-between">
|
|
310
|
-
<span className="text-xl font-bold text-gray-900">Total</span>
|
|
311
|
-
<span className="text-2xl font-bold text-gray-900">
|
|
312
|
-
{formatPrice(total)}
|
|
929
|
+
{isDelivery && (
|
|
930
|
+
<div className="flex items-center justify-between">
|
|
931
|
+
<span>Shipping</span>
|
|
932
|
+
<span className="font-semibold">
|
|
933
|
+
{formatPrice(shippingPrice)}
|
|
313
934
|
</span>
|
|
314
935
|
</div>
|
|
936
|
+
)}
|
|
937
|
+
<div className="flex items-center justify-between">
|
|
938
|
+
<span>Estimated tax</span>
|
|
939
|
+
<span className="font-semibold">{formatPrice(tax)}</span>
|
|
940
|
+
</div>
|
|
941
|
+
<div className="rounded-2xl bg-slate-50 p-4">
|
|
942
|
+
<div className="flex items-center justify-between text-base font-semibold text-slate-900">
|
|
943
|
+
<span>Total Due</span>
|
|
944
|
+
<span>{formatPrice(total)}</span>
|
|
945
|
+
</div>
|
|
946
|
+
<p className="mt-1 text-xs text-slate-500">
|
|
947
|
+
Tax is estimated. Final amount confirmed after payment.
|
|
948
|
+
</p>
|
|
315
949
|
</div>
|
|
316
950
|
</div>
|
|
951
|
+
</section>
|
|
317
952
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
</
|
|
953
|
+
{/* Checkout Button */}
|
|
954
|
+
<Button
|
|
955
|
+
type="submit"
|
|
956
|
+
size="lg"
|
|
957
|
+
isLoading={isSubmitting}
|
|
958
|
+
className="mt-6 w-full transition-transform hover:scale-[1.02] active:scale-[0.99]"
|
|
959
|
+
>
|
|
960
|
+
<CreditCard className="h-5 w-5" />
|
|
961
|
+
{isSubmitting ? 'Placing order...' : 'Place Secure Order'}
|
|
962
|
+
</Button>
|
|
963
|
+
|
|
964
|
+
<p className="mt-4 flex items-center justify-center gap-2 text-xs text-slate-500">
|
|
965
|
+
<Lock className="h-4 w-4" />
|
|
966
|
+
Fully encrypted checkout — cancel anytime before shipment.
|
|
967
|
+
</p>
|
|
333
968
|
</div>
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
969
|
+
|
|
970
|
+
{/* Why Patients Choose Us */}
|
|
971
|
+
<div className="rounded-3xl border border-primary-100 bg-primary-50/80 p-6 text-sm text-primary-700 shadow-sm hover:shadow-md transition-shadow">
|
|
972
|
+
<p className="font-semibold uppercase tracking-[0.3em] text-primary-800">
|
|
973
|
+
Why Patients Choose Us
|
|
974
|
+
</p>
|
|
975
|
+
<ul className="mt-4 space-y-3 text-primary-700 leading-relaxed">
|
|
976
|
+
<li className="flex items-start gap-3">
|
|
977
|
+
<PackageCheck className="mt-0.5 h-4 w-4 shrink-0" />
|
|
978
|
+
<span>Pharmacy-grade verification on every order.</span>
|
|
979
|
+
</li>
|
|
980
|
+
<li className="flex items-start gap-3">
|
|
981
|
+
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" />
|
|
982
|
+
<span>Cold-chain logistics for sensitive medications.</span>
|
|
983
|
+
</li>
|
|
984
|
+
<li className="flex items-start gap-3">
|
|
985
|
+
<Truck className="mt-0.5 h-4 w-4 shrink-0" />
|
|
986
|
+
<span>Real-time tracking and SMS updates from prep to delivery.</span>
|
|
987
|
+
</li>
|
|
988
|
+
</ul>
|
|
989
|
+
</div>
|
|
990
|
+
</motion.aside>
|
|
991
|
+
|
|
992
|
+
</div>
|
|
993
|
+
</form>
|
|
994
|
+
<AddressFormModal
|
|
995
|
+
isOpen={isAddressModalOpen}
|
|
996
|
+
onClose={() => setIsAddressModalOpen(false)}
|
|
997
|
+
initialAddress={editingAddress}
|
|
998
|
+
onAddressAdded={(addr) => {
|
|
999
|
+
refresh().then(() => {
|
|
1000
|
+
setSelectedAddressId(addr.id);
|
|
1001
|
+
setValue('shipping.name', addr.name);
|
|
1002
|
+
setValue('shipping.phone', addr.phone || (undefined as unknown as string));
|
|
1003
|
+
setValue('shipping.street1', addr.street1);
|
|
1004
|
+
setValue('shipping.street2', addr.street2 || '');
|
|
1005
|
+
setValue('shipping.city', addr.city);
|
|
1006
|
+
setValue('shipping.state', addr.state);
|
|
1007
|
+
setValue('shipping.zip', addr.zip);
|
|
1008
|
+
setValue('shipping.country', addr.country);
|
|
1009
|
+
});
|
|
1010
|
+
}}
|
|
1011
|
+
onAddressUpdated={() => {
|
|
1012
|
+
refresh();
|
|
1013
|
+
}}
|
|
1014
|
+
/>
|
|
1015
|
+
{/* </div> */}
|
|
337
1016
|
</div>
|
|
338
1017
|
);
|
|
339
1018
|
}
|
|
340
|
-
|