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:
2025-02-15 01:45:15 +00:00
parent 81dc9acd81
commit 12d7d21d28
25 changed files with 3410 additions and 8596 deletions

View File

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
};

View 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>
);
}

View File

@ -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>

View File

@ -25,7 +25,6 @@ async function fetchProducts() {
}
const data = await response.json();
console.log(JSON.stringify(data.products));
return data.products;
}

View File

@ -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>

View File

@ -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}

View File

@ -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}