Fixed the Customer Context. Because of how setState worked, customers were not properly registered.
Customer Profile Page, Customer Addresses Page, Checkout Page
This commit is contained in:
@ -20,9 +20,9 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
</head>
|
||||
<body>
|
||||
<Navbar />
|
||||
<RegionProvider>
|
||||
<CustomerProvider>
|
||||
<Navbar />
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</CustomerProvider>
|
||||
</RegionProvider>
|
||||
|
||||
336
src/app/(storefront)/account/addresses/AddressesManager.tsx
Normal file
336
src/app/(storefront)/account/addresses/AddressesManager.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import { HttpTypes } from '@medusajs/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCustomer } from '@/providers/customer';
|
||||
import { useRegion } from '@/providers/region';
|
||||
|
||||
export default function AddressesManager() {
|
||||
const { region } = useRegion();
|
||||
const { customer, token, setCustomer } = useCustomer();
|
||||
|
||||
const [addresses, setAddresses] = useState<HttpTypes.StoreCustomerAddress[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMorePages, setHasMorePages] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
id: '', // Empty for new addresses, set when editing
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
address1: '',
|
||||
company: '',
|
||||
postalCode: '',
|
||||
city: '',
|
||||
countryCode: '',
|
||||
province: '',
|
||||
phoneNumber: '',
|
||||
});
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const limit = 20;
|
||||
|
||||
// Fetch addresses when component loads or page changes
|
||||
useEffect(() => {
|
||||
if (!customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const searchParams = new URLSearchParams({
|
||||
limit: `${limit}`,
|
||||
offset: `${offset}`,
|
||||
});
|
||||
|
||||
fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/customers/me/addresses?${searchParams.toString()}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ addresses: addressesData, count }) => {
|
||||
setAddresses((prev) => (prev.length > offset ? prev : [...prev, ...addressesData]));
|
||||
setHasMorePages(count > limit * currentPage);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [currentPage, customer]);
|
||||
|
||||
// Handles input field changes
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handles adding or updating an address
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const apiUrl = formData.id
|
||||
? `${process.env.NEXT_PUBLIC_STORE_URL}/store/customers/me/addresses/${formData.id}` // Edit
|
||||
: `${process.env.NEXT_PUBLIC_STORE_URL}/store/customers/me/addresses`; // Add
|
||||
|
||||
// In your code, "POST" is used both for adding and editing.
|
||||
// If your backend uses PUT/PATCH for editing, adjust accordingly.
|
||||
const method = 'POST';
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl, {
|
||||
credentials: 'include',
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
address_1: formData.address1,
|
||||
company: formData.company,
|
||||
postal_code: formData.postalCode,
|
||||
city: formData.city,
|
||||
country_code: formData.countryCode,
|
||||
province: formData.province,
|
||||
phone: formData.phoneNumber,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
setCustomer(data.customer);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Error adding/updating address', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handles deleting an address
|
||||
const handleDelete = async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/customers/me/addresses/${id}`, {
|
||||
credentials: 'include',
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
});
|
||||
|
||||
setAddresses((prev) => prev.filter((addr) => addr.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Error deleting address', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sets form data when editing an address
|
||||
const handleEdit = (address: HttpTypes.StoreCustomerAddress) => {
|
||||
setIsEditing(true);
|
||||
setFormData({
|
||||
id: address.id,
|
||||
firstName: address.first_name,
|
||||
lastName: address.last_name,
|
||||
address1: address.address_1,
|
||||
company: address.company,
|
||||
postalCode: address.postal_code,
|
||||
city: address.city,
|
||||
countryCode: address.country_code,
|
||||
province: address.province,
|
||||
phoneNumber: address.phone,
|
||||
});
|
||||
};
|
||||
|
||||
// Resets the form
|
||||
const resetForm = () => {
|
||||
setIsEditing(false);
|
||||
setFormData({
|
||||
id: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
address1: '',
|
||||
company: '',
|
||||
postalCode: '',
|
||||
city: '',
|
||||
countryCode: '',
|
||||
province: '',
|
||||
phoneNumber: '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='addresses-manager'>
|
||||
<form onSubmit={handleSubmit} className='address-form'>
|
||||
<fieldset>
|
||||
<legend>{isEditing ? 'Edit Address' : 'Add Address'}</legend>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='firstName'>First Name</label>
|
||||
<input
|
||||
id='firstName'
|
||||
type='text'
|
||||
name='firstName'
|
||||
placeholder='First Name'
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='lastName'>Last Name</label>
|
||||
<input
|
||||
id='lastName'
|
||||
type='text'
|
||||
name='lastName'
|
||||
placeholder='Last Name'
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='address1'>Address</label>
|
||||
<input
|
||||
id='address1'
|
||||
type='text'
|
||||
name='address1'
|
||||
placeholder='Address'
|
||||
value={formData.address1}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='company'>Company</label>
|
||||
<input
|
||||
id='company'
|
||||
type='text'
|
||||
name='company'
|
||||
placeholder='Company'
|
||||
value={formData.company}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='postalCode'>Postal Code</label>
|
||||
<input
|
||||
id='postalCode'
|
||||
type='text'
|
||||
name='postalCode'
|
||||
placeholder='Postal Code'
|
||||
value={formData.postalCode}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='city'>City</label>
|
||||
<input
|
||||
id='city'
|
||||
type='text'
|
||||
name='city'
|
||||
placeholder='City'
|
||||
value={formData.city}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='countryCode'>Country</label>
|
||||
<select
|
||||
id='countryCode'
|
||||
name='countryCode'
|
||||
value={formData.countryCode}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
>
|
||||
<option value='' disabled>
|
||||
Select a country...
|
||||
</option>
|
||||
{region?.countries?.map((country) => (
|
||||
<option key={country.iso_2} value={country.iso_2}>
|
||||
{country.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='province'>Province</label>
|
||||
<input
|
||||
id='province'
|
||||
type='text'
|
||||
name='province'
|
||||
placeholder='Province'
|
||||
value={formData.province}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-group'>
|
||||
<label htmlFor='phoneNumber'>Phone Number</label>
|
||||
<input
|
||||
id='phoneNumber'
|
||||
type='tel'
|
||||
name='phoneNumber'
|
||||
placeholder='Phone Number'
|
||||
value={formData.phoneNumber}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-actions'>
|
||||
<button type='submit' disabled={loading}>
|
||||
{isEditing ? 'Update' : 'Add'}
|
||||
</button>
|
||||
{isEditing && (
|
||||
<button type='button' onClick={resetForm}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<section className='address-list'>
|
||||
<h2>Your Addresses</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>City</th>
|
||||
<th>Country Code</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{addresses.map((address) => (
|
||||
<tr key={address.id}>
|
||||
<td>{address.city}</td>
|
||||
<td>{address.country_code}</td>
|
||||
<td>
|
||||
<button onClick={() => handleEdit(address)}>Edit</button>
|
||||
<button onClick={() => handleDelete(address.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/app/(storefront)/account/addresses/page.tsx
Normal file
13
src/app/(storefront)/account/addresses/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
// app/addresses/page.tsx
|
||||
'use client';
|
||||
|
||||
import AddressesManager from './AddressesManager';
|
||||
|
||||
export default function AddressesPage() {
|
||||
return (
|
||||
<div className='px-4 py-8 pt-28'>
|
||||
<h1>Manage Your Addresses</h1>
|
||||
<AddressesManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/app/(storefront)/account/profile/ProfileForm.tsx
Normal file
106
src/app/(storefront)/account/profile/ProfileForm.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
// app/account/profile/ProfileForm.tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface ProfileFormProps {
|
||||
customer: Customer;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ProfileForm({ customer, onCancel }: ProfileFormProps) {
|
||||
const [firstName, setFirstName] = useState(customer.first_name || '');
|
||||
const [lastName, setLastName] = useState(customer.last_name || '');
|
||||
const [phone, setPhone] = useState(customer.phone || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/customers/me`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone: phone,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// handle error
|
||||
const errorData = await res.json();
|
||||
setError(errorData?.message || 'Failed to update profile');
|
||||
} else {
|
||||
// success - you might want to refresh the page or re-fetch data
|
||||
// For simplicity, let's just call onCancel to go back
|
||||
onCancel();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError('An error occurred while updating.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className='flex flex-col max-w-sm'>
|
||||
{error && <p className='text-red-500 mb-2'>{error}</p>}
|
||||
|
||||
<label className='mb-2'>
|
||||
First Name:
|
||||
<input
|
||||
type='text'
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className='block w-full border border-gray-300 p-2 mt-1'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className='mb-2'>
|
||||
Last Name:
|
||||
<input
|
||||
type='text'
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className='block w-full border border-gray-300 p-2 mt-1'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className='mb-2'>
|
||||
Phone:
|
||||
<input
|
||||
type='text'
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className='block w-full border border-gray-300 p-2 mt-1'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className='flex gap-2 mt-4'>
|
||||
<button type='submit' disabled={loading} className='px-4 py-2 bg-green-500 text-white rounded'>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button type='button' onClick={onCancel} disabled={loading} className='px-4 py-2 bg-gray-300 rounded'>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
58
src/app/(storefront)/account/profile/ProfileView.tsx
Normal file
58
src/app/(storefront)/account/profile/ProfileView.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
// app/account/profile/ProfileView.tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import ProfileForm from './ProfileForm';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
// ... other fields from the Medusa customer object
|
||||
}
|
||||
|
||||
interface ProfileViewProps {
|
||||
customer: Customer;
|
||||
}
|
||||
|
||||
export default function ProfileView({ customer }: ProfileViewProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const handleEditClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isEditing ? (
|
||||
<div>
|
||||
<p>
|
||||
<strong>Email:</strong> {customer.email}
|
||||
</p>
|
||||
<p>
|
||||
<strong>First name:</strong> {customer.first_name || 'N/A'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Last name:</strong> {customer.last_name || 'N/A'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Phone:</strong> {customer.phone || 'N/A'}
|
||||
</p>
|
||||
|
||||
<button onClick={handleEditClick} className='mt-4 px-4 py-2 bg-blue-500 text-white rounded'>
|
||||
Edit Profile
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ProfileForm customer={customer} onCancel={handleCancel} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/app/(storefront)/account/profile/page.tsx
Normal file
107
src/app/(storefront)/account/profile/page.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
// app/account/profile/page.tsx
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useCustomer } from '@/providers/customer';
|
||||
|
||||
export default function ProfilePage() {
|
||||
// Access customer data (and possibly other methods: register, login, logout)
|
||||
const { customer, token } = useCustomer();
|
||||
|
||||
// For demonstration, we keep track of form states to update first/last name
|
||||
const [firstName, setFirstName] = useState(customer?.first_name ?? '');
|
||||
const [lastName, setLastName] = useState(customer?.last_name ?? '');
|
||||
const [message, setMessage] = useState<string>('');
|
||||
|
||||
// If the user isn't logged in, `customer` will be undefined
|
||||
if (!customer) {
|
||||
return <div className='p-4'>Please log in to view your profile.</div>;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
// Make a PUT request to update the currently logged-in Medusa customer
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/customers/me`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`, // pass the JWT
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY as string,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData?.message || 'Failed to update profile.');
|
||||
}
|
||||
|
||||
setMessage('Profile updated successfully!');
|
||||
|
||||
// If you'd like to refresh the data in your context,
|
||||
// you could call a function from your provider here (e.g., getCustomer()).
|
||||
// Or simply do another fetch of /customers/me and update context:
|
||||
// await someProviderMethodToRefreshCustomer();
|
||||
} catch (error: any) {
|
||||
setMessage(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='p-4'>
|
||||
<h1 className='text-xl font-bold mb-4'>Your Profile</h1>
|
||||
|
||||
<p>
|
||||
<strong>Email:</strong> {customer.email}
|
||||
</p>
|
||||
<p>
|
||||
<strong>First Name:</strong> {customer.first_name}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Last Name:</strong> {customer.last_name}
|
||||
</p>
|
||||
|
||||
<hr className='my-4' />
|
||||
|
||||
<h2 className='font-semibold mb-2'>Update Profile</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className='flex flex-col max-w-sm gap-2'>
|
||||
{message && <p className='text-red-500'>{message}</p>}
|
||||
|
||||
<label>
|
||||
New First Name:
|
||||
<input
|
||||
className='block w-full border border-gray-300 p-2 mt-1'
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
New Last Name:
|
||||
<input
|
||||
className='block w-full border border-gray-300 p-2 mt-1'
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type='submit' className='mt-2 bg-blue-600 text-white px-4 py-2 rounded'>
|
||||
Update
|
||||
</button>
|
||||
<Link className='w-full mt-4 bg-white' href={'./addresses'}>
|
||||
Manage addresses
|
||||
</Link>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Minus, Plus, Trash2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@ -56,9 +57,9 @@ export default function MyCartComponent() {
|
||||
<span className='font-semibold'>Total</span>
|
||||
<span className='font-semibold'>₹{cart?.total?.toFixed(2)}</span>
|
||||
</div>
|
||||
<Button className='w-full mt-4 bg-white' size='lg'>
|
||||
<Link className='w-full mt-4 bg-white' href={'/checkout/email'} size='lg'>
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
</Link>
|
||||
Need Help? Call us at 9915020200
|
||||
</div>
|
||||
</div>
|
||||
|
||||
250
src/app/(storefront)/checkout/address/page.tsx
Normal file
250
src/app/(storefront)/checkout/address/page.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCart } from '@/providers/cart';
|
||||
import { useCustomer } from '@/providers/customer';
|
||||
|
||||
export default function CheckoutAddressStep() {
|
||||
const { cart, setCart } = useCart();
|
||||
const { customer } = useCustomer();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// State for new address form fields
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [address1, setAddress1] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
const [postalCode, setPostalCode] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [countryCode, setCountryCode] = useState('');
|
||||
const [province, setProvince] = useState('');
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
|
||||
// State for saved address selection
|
||||
const [selectedAddress, setSelectedAddress] = useState(customer?.addresses?.[0]?.id || '');
|
||||
|
||||
// Toggle between using a saved address and entering a new one
|
||||
const [useSaved, setUseSaved] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (customer && customer.addresses && customer.addresses.length > 0) {
|
||||
setSelectedAddress(customer.addresses[0].id);
|
||||
}
|
||||
}, [customer]);
|
||||
|
||||
// Function to update the cart using a saved address
|
||||
const updateAddressFromSaved = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!cart || !selectedAddress) return;
|
||||
|
||||
const customerAddress = customer?.addresses.find((address) => address.id === selectedAddress);
|
||||
if (!customerAddress) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Build the address from the saved address data
|
||||
const address = {
|
||||
first_name: customerAddress.first_name || '',
|
||||
last_name: customerAddress.last_name || '',
|
||||
address_1: customerAddress.address_1 || '',
|
||||
company: customerAddress.company || '',
|
||||
postal_code: customerAddress.postal_code || '',
|
||||
city: customerAddress.city || '',
|
||||
country_code: customerAddress.country_code || cart.region?.countries?.[0]?.iso_2 || '',
|
||||
province: customerAddress.province || '',
|
||||
phone: customerAddress.phone || '',
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/carts/${cart.id}`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shipping_address: address,
|
||||
billing_address: address,
|
||||
}),
|
||||
});
|
||||
const { cart: updatedCart } = await res.json();
|
||||
setCart(updatedCart);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to update the cart using the new address form inputs
|
||||
const updateAddressFromNew = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!cart) return;
|
||||
setLoading(true);
|
||||
|
||||
const address = {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
address_1: address1,
|
||||
company: company,
|
||||
postal_code: postalCode,
|
||||
city: city,
|
||||
country_code: countryCode || cart.region?.countries?.[0]?.iso_2 || '',
|
||||
province: province,
|
||||
phone: phoneNumber,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/carts/${cart.id}`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shipping_address: address,
|
||||
billing_address: address,
|
||||
}),
|
||||
});
|
||||
const { cart: updatedCart } = await res.json();
|
||||
setCart(updatedCart);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='px-4 py-8 pt-28'>
|
||||
<form>
|
||||
{!cart && <span>Loading...</span>}
|
||||
|
||||
{/* Toggle for saved vs. new address */}
|
||||
<div className='mb-4'>
|
||||
<label className='mr-4'>
|
||||
<input type='radio' checked={useSaved} onChange={() => setUseSaved(true)} /> Use Saved Address
|
||||
</label>
|
||||
<label>
|
||||
<input type='radio' checked={!useSaved} onChange={() => setUseSaved(false)} /> Enter New Address
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{useSaved ? (
|
||||
<div className='mb-4'>
|
||||
{(customer?.addresses?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<select
|
||||
value={selectedAddress}
|
||||
onChange={(e) => setSelectedAddress(e.target.value)}
|
||||
className='mb-4 p-2 border border-gray-300 rounded'
|
||||
>
|
||||
{customer?.addresses?.map((address) => (
|
||||
<option value={address.id} key={address.id}>
|
||||
{address.country_code} – {address.address_1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
disabled={!cart || loading || !selectedAddress}
|
||||
onClick={updateAddressFromSaved}
|
||||
className='bg-blue-500 text-white px-4 py-2 rounded'
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Address'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>No saved addresses available.</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='First Name'
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Last Name'
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Address Line'
|
||||
value={address1}
|
||||
onChange={(e) => setAddress1(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Company'
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Postal Code'
|
||||
value={postalCode}
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='City'
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
/>
|
||||
<select
|
||||
value={countryCode}
|
||||
onChange={(e) => setCountryCode(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
>
|
||||
{cart?.region?.countries?.map((country) => (
|
||||
<option key={country.iso_2} value={country.iso_2}>
|
||||
{country.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Province'
|
||||
value={province}
|
||||
onChange={(e) => setProvince(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
/>
|
||||
<input
|
||||
type='tel'
|
||||
placeholder='Phone Number'
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
className='p-2 border border-gray-300 rounded'
|
||||
/>
|
||||
<button
|
||||
disabled={!cart || loading}
|
||||
onClick={updateAddressFromNew}
|
||||
className='bg-blue-500 text-white px-4 py-2 rounded'
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Address'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<Link className='w-full mt-4 bg-white' href={'/checkout/shipping'} size='lg'>
|
||||
Proceed to Shipping
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/app/(storefront)/checkout/checkout.module.css
Normal file
7
src/app/(storefront)/checkout/checkout.module.css
Normal file
@ -0,0 +1,7 @@
|
||||
.formInput {
|
||||
@apply border border-[hsl(0,0%,50%)] rounded-md py-3 px-2;
|
||||
}
|
||||
|
||||
.formButton {
|
||||
@apply py-2 px-4 bg-[#703133] text-white font-semibold shadow focus:outline-none;
|
||||
}
|
||||
77
src/app/(storefront)/checkout/email/page.tsx
Normal file
77
src/app/(storefront)/checkout/email/page.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
'use client'; // include with Next.js 13+
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCart } from '@/providers/cart';
|
||||
import { useCustomer } from '@/providers/customer';
|
||||
|
||||
export default function CheckoutEmailStep() {
|
||||
const router = useRouter();
|
||||
const { cart, setCart } = useCart();
|
||||
// Check if there's a logged-in customer:
|
||||
const { customer } = useCustomer();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// If a customer is already logged in, skip this step
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
router.replace('/checkout/address');
|
||||
}
|
||||
}, [customer, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cart && !cart.items?.length) {
|
||||
console.log('Cart is empty');
|
||||
// TODO redirect to another path
|
||||
} else {
|
||||
console.log(cart);
|
||||
}
|
||||
}, [cart]);
|
||||
|
||||
const updateCartEmail = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
if (!cart || !email.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/carts/${cart.id}`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ cart: updatedCart }) => {
|
||||
setCart(updatedCart);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='px-4 py-8 pt-28'>
|
||||
{!cart && <span>Loading...</span>}
|
||||
<input
|
||||
type='email'
|
||||
placeholder='Email'
|
||||
value={email}
|
||||
disabled={!cart}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button disabled={!cart || !email || loading} onClick={updateCartEmail}>
|
||||
Set Email
|
||||
</button>
|
||||
<Link className='w-full mt-4 bg-white' href={'/checkout/address'} size='lg'>
|
||||
Proceed to Address
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/app/(storefront)/checkout/page.tsx
Normal file
148
src/app/(storefront)/checkout/page.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import styles from './checkout.module.css';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCart } from '@/providers/cart';
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const orderItems = [
|
||||
{ name: 'Product 1', price: 19.99, quantity: 2 },
|
||||
{ name: 'Product 2', price: 29.99, quantity: 1 },
|
||||
];
|
||||
|
||||
const subtotal = orderItems.reduce((acc, item) => acc + item.price * item.quantity, 0);
|
||||
const shipping = 5.99;
|
||||
const total = subtotal + shipping;
|
||||
|
||||
return (
|
||||
<div className='min-h-screen pt-28 grid md:grid-cols-2 gap-8 overflow-visible'>
|
||||
<div className='sticky self-start flex flex-row gap-4 flex-wrap p-4'>
|
||||
<h3 className='basis-full'>Delivery</h3>
|
||||
<input
|
||||
type='text'
|
||||
name='country'
|
||||
className={`grow shrink basis-full ${styles.formInput}`}
|
||||
placeholder='Country / Region'
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
name='firstName'
|
||||
className={`grow shrink basis-5/12 ${styles.formInput}`}
|
||||
placeholder='First Name'
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
name='lastName'
|
||||
className={`grow shrink basis-5/12 box-border ${styles.formInput}`}
|
||||
placeholder='Last Name'
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
name='address'
|
||||
className={`grow shrink basis-full box-border ${styles.formInput}`}
|
||||
placeholder='Address'
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
name='addressTwo'
|
||||
className={`grow shrink basis-full box-border ${styles.formInput}`}
|
||||
placeholder='Additional Address'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
name='city'
|
||||
className={`grow shrink basis-3/12 box-border ${styles.formInput}`}
|
||||
placeholder='City'
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
name='state'
|
||||
className={`grow shrink basis-3/12 box-border ${styles.formInput}`}
|
||||
placeholder='State'
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
name='pincode'
|
||||
className={`grow shrink basis-3/12 box-border ${styles.formInput}`}
|
||||
placeholder='PIN Code'
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
name='phone'
|
||||
className={`grow shrink basis-full box-border ${styles.formInput}`}
|
||||
placeholder='Phone'
|
||||
required
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Payment Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='cardNumber'>Card Number</Label>
|
||||
<Input id='cardNumber' placeholder='1234 5678 9012 3456' />
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Label htmlFor='expirationDate'>Expiration Date</Label>
|
||||
<Input id='expirationDate' placeholder='MM/YY' />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='cvv'>CVV</Label>
|
||||
<Input id='cvv' placeholder='123' />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className='w-full'>Place Order</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<div className='sticky self-start'>
|
||||
<div className='space-y-8'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Order Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{orderItems.map((item, index) => (
|
||||
<div key={index} className='flex justify-between'>
|
||||
<span>
|
||||
{item.name} x {item.quantity}
|
||||
</span>
|
||||
<span>${(item.price * item.quantity).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<Separator />
|
||||
<div className='flex justify-between'>
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Shipping</span>
|
||||
<span>${shipping.toFixed(2)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='flex justify-between font-bold'>
|
||||
<span>Total</span>
|
||||
<span>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/app/(storefront)/checkout/payment/multiple.tsx
Normal file
139
src/app/(storefront)/checkout/payment/multiple.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
'use client'; // include with Next.js 13+
|
||||
|
||||
import { HttpTypes } from '@medusajs/types';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import RazorpayPayment from './razorpay';
|
||||
|
||||
import { useCart } from '@/providers/cart';
|
||||
|
||||
export default function CheckoutPaymentStep() {
|
||||
const { cart, setCart } = useCart();
|
||||
const [paymentProviders, setPaymentProviders] = useState<HttpTypes.StorePaymentProvider[]>([]);
|
||||
const [selectedPaymentProvider, setSelectedPaymentProvider] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('checkout payment step, load razorpay script');
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cart) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/payment-providers?region_id=${cart.region_id}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ payment_providers }) => {
|
||||
setPaymentProviders(payment_providers);
|
||||
setSelectedPaymentProvider(cart.payment_collection?.payment_sessions?.[0]?.id || payment_providers[0].id);
|
||||
});
|
||||
}, [cart]);
|
||||
|
||||
const handleSelectProvider = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
console.log('checkout payment step, handleSelectProvider', cart, paymentProviders, selectedPaymentProvider);
|
||||
if (!cart || !selectedPaymentProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
|
||||
let paymentCollectionId = cart.payment_collection?.id;
|
||||
if (!paymentCollectionId) {
|
||||
// create payment collection
|
||||
const { payment_collection } = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/payment-collections`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cart_id: cart.id,
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
paymentCollectionId = payment_collection.id;
|
||||
}
|
||||
|
||||
// initialize payment session
|
||||
const sess = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_STORE_URL}/store/payment-collections/${paymentCollectionId}/payment-sessions`,
|
||||
{
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider_id: selectedPaymentProvider,
|
||||
}),
|
||||
},
|
||||
).then((res) => res.json());
|
||||
|
||||
console.log('sess', sess);
|
||||
// re-fetch cart
|
||||
const { cart: updatedCart } = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/carts/${cart.id}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
}).then((res) => res.json());
|
||||
|
||||
setCart(updatedCart);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getPaymentUi = useCallback(() => {
|
||||
const activePaymentSession = cart?.payment_collection?.payment_sessions?.[0];
|
||||
if (!activePaymentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case activePaymentSession.provider_id.startsWith('pp_razorpay_'):
|
||||
return <RazorpayPayment />;
|
||||
case activePaymentSession.provider_id.startsWith('pp_system_default'):
|
||||
return <span>You chose manual payment! No additional actions required.</span>;
|
||||
default:
|
||||
return <span>You chose {activePaymentSession.provider_id} which is in development.</span>;
|
||||
}
|
||||
}, [cart]);
|
||||
|
||||
return (
|
||||
<div className='px-4 py-8 pt-28'>
|
||||
<form>
|
||||
<select value={selectedPaymentProvider} onChange={(e) => setSelectedPaymentProvider(e.target.value)}>
|
||||
{paymentProviders.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={async (e) => {
|
||||
await handleSelectProvider(e);
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
{getPaymentUi()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/app/(storefront)/checkout/payment/page.tsx
Normal file
131
src/app/(storefront)/checkout/payment/page.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'use client'; // include with Next.js 13+
|
||||
|
||||
import { HttpTypes } from '@medusajs/types';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import RazorpayPayment from './razorpay';
|
||||
|
||||
import { useCart } from '@/providers/cart';
|
||||
|
||||
export default function CheckoutPaymentStep() {
|
||||
const { cart, setCart } = useCart();
|
||||
const [paymentProvider, setPaymentProvider] = useState<HttpTypes.StorePaymentProvider | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cart) {
|
||||
return;
|
||||
}
|
||||
console.log('useEffect cart', cart);
|
||||
|
||||
fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/payment-providers?region_id=${cart.region_id}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ payment_providers }) => {
|
||||
if (JSON.stringify(payment_providers[0]) !== JSON.stringify(paymentProvider)) {
|
||||
setPaymentProvider(payment_providers[0]);
|
||||
}
|
||||
});
|
||||
}, [cart]);
|
||||
|
||||
const fetchOrCreatePaymentCollection = async (cart: any) => {
|
||||
const paymentCollectionId = cart.payment_collection?.id;
|
||||
if (paymentCollectionId) {
|
||||
return paymentCollectionId;
|
||||
}
|
||||
// create payment collection
|
||||
const { payment_collection } = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/payment-collections`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cart_id: cart.id,
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
return payment_collection.id;
|
||||
};
|
||||
const initializePaymentSession = async (paymentCollectionId: string, providerId: string) => {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_STORE_URL}/store/payment-collections/${paymentCollectionId}/payment-sessions`,
|
||||
{
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider_id: providerId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const sessionData = await response.json();
|
||||
return sessionData;
|
||||
};
|
||||
|
||||
const fetchUpdatedCart = async (cartId: string) => {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/carts/${cartId}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
});
|
||||
const { cart: updatedCart } = await response.json();
|
||||
return updatedCart;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('useEffect paymentProvider', cart, paymentProvider);
|
||||
if (!cart || !paymentProvider) {
|
||||
return;
|
||||
}
|
||||
const initializePayment = async () => {
|
||||
try {
|
||||
// Step 1: Fetch or create the Payment Collection
|
||||
const paymentCollectionId = await fetchOrCreatePaymentCollection(cart);
|
||||
|
||||
// Step 2: Initialize Payment Session for the provider
|
||||
const session = await initializePaymentSession(paymentCollectionId, paymentProvider.id);
|
||||
|
||||
// Step 3: Fetch and update the cart to reflect the changes
|
||||
const updatedCart = await fetchUpdatedCart(cart.id);
|
||||
if (JSON.stringify(cart) !== JSON.stringify(updatedCart)) {
|
||||
setCart(updatedCart);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing payment:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializePayment();
|
||||
}, [paymentProvider]);
|
||||
|
||||
const getPaymentUi = useCallback(() => {
|
||||
const activePaymentSession = cart?.payment_collection?.payment_sessions?.[0];
|
||||
if (activePaymentSession) {
|
||||
return <RazorpayPayment />;
|
||||
}
|
||||
}, [cart]);
|
||||
|
||||
return <div className='px-4 py-8 pt-28'>{getPaymentUi()}</div>;
|
||||
}
|
||||
137
src/app/(storefront)/checkout/payment/razorpay.tsx
Normal file
137
src/app/(storefront)/checkout/payment/razorpay.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
'use client'; // required for Next.js 13+ Client Components
|
||||
|
||||
import Script from 'next/script';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useCart } from '@/providers/cart';
|
||||
|
||||
/**
|
||||
* This component injects the Razorpay checkout script
|
||||
* and displays the `RazorpayForm` after the script has loaded.
|
||||
*/
|
||||
export default function RazorpayPayment() {
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
Load the Razorpay script directly from their CDN.
|
||||
Once loaded, you can safely initialize the checkout
|
||||
*/}
|
||||
<Script src='https://checkout.razorpay.com/v1/checkout.js' onLoad={() => setScriptLoaded(true)} />
|
||||
|
||||
{/* Only render the payment form if the script has loaded */}
|
||||
{scriptLoaded && <RazorpayForm />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple form that triggers the Razorpay checkout
|
||||
* using data from the cart.
|
||||
*/
|
||||
function RazorpayForm() {
|
||||
const { cart, refreshCart } = useCart();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Typically the order_id is generated on your server (Medusa backend)
|
||||
// and passed back via the payment session's data.
|
||||
const order_id = cart?.payment_collection?.payment_sessions?.[0]?.data?.id;
|
||||
// fallback currency, if needed
|
||||
const currency = cart?.payment_collection?.payment_sessions?.[0]?.data?.currency || 'INR';
|
||||
// fallback amount in smallest currency unit, if needed
|
||||
const amount = cart?.payment_collection?.payment_sessions?.[0]?.data?.amount || cart?.total;
|
||||
|
||||
// The "Place Order" button triggers Razorpay's checkout
|
||||
const handlePayment = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!cart || !order_id) {
|
||||
console.error('Cart or order_id not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Configure the Razorpay checkout options
|
||||
const options = {
|
||||
key: process.env.NEXT_PUBLIC_RAZORPAY_KEY ?? 'test_key', // replace with your actual key
|
||||
amount: amount, // in the smallest currency unit (e.g. paise for INR)
|
||||
currency: currency,
|
||||
name: 'Mozimo Chocolates',
|
||||
description: 'Mozimo + Razorpay Payment',
|
||||
order_id: order_id,
|
||||
handler: async function (response: any) {
|
||||
// This function is called when payment is successful
|
||||
// `response.razorpay_payment_id` will have the Payment ID
|
||||
// `response.razorpay_order_id` will have the Order ID
|
||||
// `response.razorpay_signature` will have the signature
|
||||
|
||||
try {
|
||||
// Complete the cart in Medusa
|
||||
const result = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/carts/${cart.id}/complete`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await result.json();
|
||||
// Example response shape:
|
||||
// { type: "order" | "cart", order?: any, cart?: any, error?: any }
|
||||
|
||||
if (data.type === 'cart' && data.cart) {
|
||||
// Some error or failure state
|
||||
console.error('Error completing cart:', data.error);
|
||||
} else if (data.type === 'order' && data.order) {
|
||||
// Payment and order creation successful
|
||||
alert('Order placed.');
|
||||
// Clears/reloads cart in your client state
|
||||
refreshCart();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error completing cart:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
prefill: {
|
||||
// Prefill customer data in the Razorpay form
|
||||
name: cart.billing_address?.first_name ?? '',
|
||||
email: cart.email ?? '',
|
||||
contact: cart.billing_address?.phone ?? '',
|
||||
},
|
||||
notes: {
|
||||
address: `${cart.billing_address?.address_1 ?? ''} ${cart.billing_address?.address_2 ?? ''}`,
|
||||
},
|
||||
theme: {
|
||||
color: '#3399cc',
|
||||
},
|
||||
};
|
||||
|
||||
// Create a new Razorpay checkout instance and open it
|
||||
const rzp = new (window as any).Razorpay(options);
|
||||
|
||||
// Optional: you can bind an event for payment failure as well
|
||||
rzp.on('payment.failed', function (response: any) {
|
||||
setLoading(false);
|
||||
console.error('Payment failed:', response.error);
|
||||
// handle failure in your UI
|
||||
});
|
||||
|
||||
rzp.open();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/*
|
||||
Because Razorpay handles the card input in its own modal,
|
||||
we only need a simple button. We do not need something like
|
||||
Stripe's <CardElement /> for card fields.
|
||||
*/}
|
||||
<button onClick={handlePayment} disabled={loading || !order_id}>
|
||||
{loading ? 'Processing...' : 'Place Order'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/app/(storefront)/checkout/payment/stripe.tsx
Normal file
101
src/app/(storefront)/checkout/payment/stripe.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
'use client'; // include with Next.js 13+
|
||||
|
||||
import { CardElement, Elements, useElements, useStripe } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useCart } from '../../providers/cart';
|
||||
|
||||
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PK || 'temp');
|
||||
|
||||
export default function StripePayment() {
|
||||
const { cart } = useCart();
|
||||
const clientSecret = cart?.payment_collection?.payment_sessions?.[0].data.client_secret as string;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
}}
|
||||
>
|
||||
<StripeForm clientSecret={clientSecret} />
|
||||
</Elements>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StripeForm = ({ clientSecret }: { clientSecret: string | undefined }) => {
|
||||
const { cart, refreshCart } = useCart();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
async function handlePayment(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
|
||||
e.preventDefault();
|
||||
const card = elements?.getElement(CardElement);
|
||||
|
||||
if (!stripe || !elements || !card || !cart || !clientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
stripe
|
||||
?.confirmCardPayment(clientSecret, {
|
||||
payment_method: {
|
||||
card,
|
||||
billing_details: {
|
||||
name: cart.billing_address?.first_name,
|
||||
email: cart.email,
|
||||
phone: cart.billing_address?.phone,
|
||||
address: {
|
||||
city: cart.billing_address?.city,
|
||||
country: cart.billing_address?.country_code,
|
||||
line1: cart.billing_address?.address_1,
|
||||
line2: cart.billing_address?.address_2,
|
||||
postal_code: cart.billing_address?.postal_code,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(({ error }) => {
|
||||
if (error) {
|
||||
// TODO handle errors
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`http://localhost:9000/store/carts/${cart.id}/complete`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ type, cart, order, error }) => {
|
||||
if (type === 'cart' && cart) {
|
||||
// an error occured
|
||||
console.error(error);
|
||||
} else if (type === 'order' && order) {
|
||||
// TODO redirect to order success page
|
||||
alert('Order placed.');
|
||||
console.log(order);
|
||||
refreshCart();
|
||||
}
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<form>
|
||||
<CardElement />
|
||||
<button onClick={handlePayment} disabled={loading}>
|
||||
Place Order
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
144
src/app/(storefront)/checkout/shipping/page.tsx
Normal file
144
src/app/(storefront)/checkout/shipping/page.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
'use client'; // include with Next.js 13+
|
||||
|
||||
import { HttpTypes } from '@medusajs/types';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useCart } from '@/providers/cart';
|
||||
|
||||
export default function CheckoutShippingStep() {
|
||||
const { cart, setCart } = useCart();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shippingOptions, setShippingOptions] = useState<HttpTypes.StoreCartShippingOption[]>([]);
|
||||
const [calculatedPrices, setCalculatedPrices] = useState<Record<string, number>>({});
|
||||
const [selectedShippingOption, setSelectedShippingOption] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!cart) {
|
||||
return;
|
||||
}
|
||||
fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/shipping-options?cart_id=${cart.id}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ shipping_options }) => {
|
||||
setShippingOptions(shipping_options);
|
||||
});
|
||||
}, [cart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cart || !shippingOptions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = shippingOptions
|
||||
.filter((shippingOption) => shippingOption.price_type === 'calculated')
|
||||
.map((shippingOption) =>
|
||||
fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/shipping-options/${shippingOption.id}/calculate`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
cart_id: cart.id,
|
||||
data: {
|
||||
// pass any data useful for calculation with third-party provider.
|
||||
},
|
||||
}),
|
||||
}).then((res) => res.json()),
|
||||
);
|
||||
|
||||
if (promises.length) {
|
||||
Promise.allSettled(promises).then((res) => {
|
||||
const pricesMap: Record<string, number> = {};
|
||||
res
|
||||
.filter((r) => r.status === 'fulfilled')
|
||||
.forEach((p) => (pricesMap[p.value?.shipping_option.id || ''] = p.value?.shipping_option.amount));
|
||||
|
||||
setCalculatedPrices(pricesMap);
|
||||
});
|
||||
}
|
||||
}, [shippingOptions, cart]);
|
||||
|
||||
const setShipping = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
if (!cart || !selectedShippingOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/carts/${cart.id}/shipping-methods`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || 'temp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
option_id: selectedShippingOption,
|
||||
data: {
|
||||
// TODO add any data necessary for
|
||||
// fulfillment provider
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ cart: updatedCart }) => {
|
||||
setCart(updatedCart);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const formatPrice = (amount: number): string => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: cart?.currency_code || 'INR',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getShippingOptionPrice = useCallback(
|
||||
(shippingOption: HttpTypes.StoreCartShippingOption) => {
|
||||
if (shippingOption.price_type === 'flat') {
|
||||
return formatPrice(shippingOption.amount);
|
||||
}
|
||||
|
||||
if (!calculatedPrices[shippingOption.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
return formatPrice(calculatedPrices[shippingOption.id]);
|
||||
},
|
||||
[calculatedPrices],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='px-4 py-8 pt-28'>
|
||||
{loading || (!cart && <span>Loading...</span>)}
|
||||
<form>
|
||||
<select value={selectedShippingOption} onChange={(e) => setSelectedShippingOption(e.target.value)}>
|
||||
{shippingOptions.map((shippingOption) => {
|
||||
const price = getShippingOptionPrice(shippingOption);
|
||||
|
||||
return (
|
||||
<option key={shippingOption.id} value={shippingOption.id} disabled={price === undefined}>
|
||||
{shippingOption.name} - {price}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button disabled={loading || !cart} onClick={setShipping}>
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<Link className='w-full mt-4 bg-white' href={'/checkout/payment'} size='lg'>
|
||||
Proceed to Payment
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -20,13 +20,15 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
</head>
|
||||
<body>
|
||||
<Navbar />
|
||||
<RegionProvider>
|
||||
<CustomerProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
<CartProvider>
|
||||
<Navbar />
|
||||
{children}
|
||||
<Footer />
|
||||
</CartProvider>
|
||||
</CustomerProvider>
|
||||
</RegionProvider>
|
||||
<Footer />
|
||||
</body>
|
||||
<GoogleAnalytics gaId='G-R3FGFLLVRX' />
|
||||
</html>
|
||||
|
||||
@ -25,7 +25,6 @@ async function fetchProducts() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(JSON.stringify(data.products));
|
||||
return data.products;
|
||||
}
|
||||
|
||||
|
||||
@ -10,12 +10,21 @@ import logoBlack from '/public/images/logo-black.svg';
|
||||
|
||||
import styles from './navbar.module.css';
|
||||
|
||||
import { useCustomer } from '@/providers/customer';
|
||||
|
||||
// components/Navbar.tsx
|
||||
export function Navbar() {
|
||||
const { customer, logout } = useCustomer();
|
||||
|
||||
const prevScrollY = useRef(0);
|
||||
const [navBarStyle, setNavBarStyle] = useState('initial');
|
||||
const [isVisible, setIsVisible] = useState('false');
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(false);
|
||||
|
||||
// New piece of state for the profile dropdown
|
||||
const [isProfileDropdownOpen, setIsProfileDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const currentScrollY = window.scrollY;
|
||||
@ -33,7 +42,18 @@ export function Navbar() {
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []); // Empty dependency array
|
||||
}, []);
|
||||
|
||||
// Close dropdown if you click outside of it
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsProfileDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// const navClasses = `${styles.navBar} ${
|
||||
// navBarStyle === 'scrolledUp'
|
||||
@ -56,6 +76,13 @@ export function Navbar() {
|
||||
}
|
||||
}
|
||||
|
||||
// You can define your logout function here or in the customer provider
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
console.log('User logged out');
|
||||
// Optionally redirect or handle client-side state cleanup
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
@ -94,9 +121,34 @@ export function Navbar() {
|
||||
<button aria-label='Search' className='rounded-full hover:shadow-md'>
|
||||
<FiSearch size={20} className='m-2.5' />
|
||||
</button>
|
||||
<button aria-label='Profile' className='rounded-full hover:shadow-md'>
|
||||
<FiUser size={20} className='m-2.5' />
|
||||
</button>
|
||||
|
||||
{/* Show dropdown if user is logged in */}
|
||||
{customer && customer.id ? (
|
||||
<div ref={dropdownRef} className='relative'>
|
||||
<button
|
||||
onClick={() => setIsProfileDropdownOpen((prev) => !prev)}
|
||||
className='rounded-full hover:shadow-md'
|
||||
>
|
||||
<FiUser size={20} className='m-2.5' />
|
||||
</button>
|
||||
|
||||
{isProfileDropdownOpen && (
|
||||
<div className='absolute right-0 mt-2 w-44 bg-white border rounded-md shadow-md p-2 text-sm z-50'>
|
||||
<Link href='/account/profile' className='block px-2 py-1 hover:bg-gray-100'>
|
||||
Profile
|
||||
</Link>
|
||||
<button onClick={handleLogout} className='block w-full text-left px-2 py-1 hover:bg-gray-100'>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* If user is not logged in, show the link to login */
|
||||
<Link href='/login' className='rounded-full hover:shadow-md'>
|
||||
<FiUser size={20} className='m-2.5' />
|
||||
</Link>
|
||||
)}
|
||||
<a aria-label='Shopping Cart' href='/cart' className='rounded-full hover:shadow-md'>
|
||||
<HiShoppingBag size={20} className='m-2.5' />
|
||||
</a>
|
||||
|
||||
@ -8,7 +8,6 @@ import { cn } from '@/lib/utils';
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
// eslint-disable-next-line react/prop-types
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
|
||||
@ -5,9 +5,11 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface CustomerContextType {
|
||||
customer: HttpTypes.StoreCustomer | undefined;
|
||||
setCustomer: React.Dispatch<React.SetStateAction<HttpTypes.StoreCustomer | undefined>>;
|
||||
register: (firstName: string, lastName: string, email: string, password: string) => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
const CustomerContext = createContext<CustomerContextType | null>(null);
|
||||
@ -18,13 +20,12 @@ interface CustomerProviderProps {
|
||||
|
||||
export const CustomerProvider = ({ children }: CustomerProviderProps) => {
|
||||
const [customer, setCustomer] = useState<HttpTypes.StoreCustomer | undefined>();
|
||||
const [jwt, setJwt] = useState<string | null>();
|
||||
const [jwt, setJwt] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('jwt');
|
||||
const storedCustomer = localStorage.getItem('user');
|
||||
|
||||
console.log('Stored customer: ', storedCustomer);
|
||||
if (storedToken) {
|
||||
setJwt(storedToken);
|
||||
}
|
||||
@ -37,20 +38,16 @@ export const CustomerProvider = ({ children }: CustomerProviderProps) => {
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
localStorage.setItem('user', JSON.stringify(customer));
|
||||
console.log('User set to: ', customer);
|
||||
} else {
|
||||
localStorage.removeItem('user');
|
||||
console.log('User removed');
|
||||
}
|
||||
}, [customer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (jwt) {
|
||||
localStorage.setItem('jwt', jwt);
|
||||
console.log('Jwt set to: ', jwt);
|
||||
} else {
|
||||
localStorage.removeItem('jwt');
|
||||
console.log('Jwt removed');
|
||||
}
|
||||
}, [jwt]);
|
||||
|
||||
@ -66,12 +63,13 @@ export const CustomerProvider = ({ children }: CustomerProviderProps) => {
|
||||
}
|
||||
|
||||
// obtain JWT token
|
||||
await getJwt(email, password);
|
||||
const token = await getJwt(email, password);
|
||||
|
||||
// get customer
|
||||
await getCustomer();
|
||||
const customer = await getCustomer(token);
|
||||
|
||||
console.log('Logged in');
|
||||
setJwt(token);
|
||||
setCustomer(customer);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
// Optionally, handle error (e.g., show error message to user)
|
||||
@ -98,12 +96,19 @@ export const CustomerProvider = ({ children }: CustomerProviderProps) => {
|
||||
}
|
||||
|
||||
// obtain JWT token
|
||||
await registerEmail(email, password);
|
||||
let token = await registerEmail(email, password);
|
||||
|
||||
// get customer
|
||||
await createCustomer(firstName, lastName, email);
|
||||
let customer = await createCustomer(firstName, lastName, email, token);
|
||||
|
||||
console.log('Logged in');
|
||||
// obtain updated JWT token
|
||||
token = await getJwt(email, password);
|
||||
|
||||
// get customer
|
||||
customer = await getCustomer(token);
|
||||
|
||||
setJwt(token);
|
||||
setCustomer(customer);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
// Optionally, handle error (e.g., show error message to user)
|
||||
@ -138,7 +143,7 @@ export const CustomerProvider = ({ children }: CustomerProviderProps) => {
|
||||
if (!token) {
|
||||
throw new Error('Unable to retrieve token');
|
||||
}
|
||||
setJwt(token);
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('Login error in getJwt: ', error);
|
||||
// Optionally, handle error (e.g., show error message to user)
|
||||
@ -163,47 +168,41 @@ export const CustomerProvider = ({ children }: CustomerProviderProps) => {
|
||||
if (!token) {
|
||||
throw new Error('Unable to retrieve token');
|
||||
}
|
||||
setJwt(token);
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('Register error in registerEmail: ', error);
|
||||
// Optionally, handle error (e.g., show error message to user)
|
||||
throw error; // Re-throw to allow caller to handle it
|
||||
}
|
||||
};
|
||||
const getCustomer = async () => {
|
||||
const getCustomer = async (token: string) => {
|
||||
try {
|
||||
if (!jwt) {
|
||||
return;
|
||||
}
|
||||
// get customer
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/customers/me`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY as string,
|
||||
},
|
||||
}).then((res) => res.json());
|
||||
|
||||
setCustomer(response.customer);
|
||||
return response.customer;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
// Optionally, handle error (e.g., show error message to user)
|
||||
throw error; // Re-throw to allow caller to handle it
|
||||
}
|
||||
};
|
||||
const createCustomer = async (firstName: string, lastName: string, email: string) => {
|
||||
const createCustomer = async (firstName: string, lastName: string, email: string, token: string) => {
|
||||
try {
|
||||
if (!jwt) {
|
||||
return;
|
||||
}
|
||||
// create customer
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_STORE_URL}/store/customers`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'x-publishable-api-key': process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY as string,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@ -213,7 +212,7 @@ export const CustomerProvider = ({ children }: CustomerProviderProps) => {
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
setCustomer(response.customer);
|
||||
return response.customer;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
// Optionally, handle error (e.g., show error message to user)
|
||||
@ -224,9 +223,11 @@ export const CustomerProvider = ({ children }: CustomerProviderProps) => {
|
||||
<CustomerContext.Provider
|
||||
value={{
|
||||
customer,
|
||||
setCustomer,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
token: jwt,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user