fix: reuse cart token

This commit is contained in:
paolosantarsiero 2025-04-08 19:47:55 +02:00
parent 150d361791
commit b250d83534
16 changed files with 1660 additions and 647 deletions

View File

@ -1,21 +1,38 @@
import { authOptions } from 'lib/auth/config'; import { getStoreApiFromRequest } from 'lib/woocomerce/storeApi';
import { storeApi } from 'lib/woocomerce/storeApi';
import { getServerSession } from 'next-auth';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const storeApi = await getStoreApiFromRequest(req);
storeApi._setAuthorizationToken(session?.user?.token ?? ''); const { cart, cartToken: updatedToken } = await storeApi.getCart();
const cart = await storeApi.getCart();
return NextResponse.json(cart, { status: 200 }); const response = NextResponse.json(cart);
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch cart', message: error }, { status: 500 }); if (updatedToken) {
response.cookies.set('cart-token', updatedToken, {
httpOnly: true,
sameSite: 'lax',
path: '/', //
maxAge: 60 * 60 * 46 // 46 ore
});
}
return response;
} catch (error: any) {
if (error.message.includes('jwt_auth_invalid_token')) {
console.error('Token expired, please reauthenticate.');
return NextResponse.json({ error: 'Token expired, please reauthenticate.' }, { status: 401 });
}
return NextResponse.json(
{ error: 'Failed to fetch cart', message: error.message },
{ status: 500 }
);
} }
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const storeApi = await getStoreApiFromRequest(req);
const { id, quantity, variation } = await req.json(); const { id, quantity, variation } = await req.json();
const cart = await storeApi.addToCart({ id, quantity, variation }); const cart = await storeApi.addToCart({ id, quantity, variation });
return NextResponse.json(cart, { status: 200 }); return NextResponse.json(cart, { status: 200 });
@ -29,6 +46,7 @@ export async function POST(req: NextRequest) {
export async function PUT(req: NextRequest) { export async function PUT(req: NextRequest) {
try { try {
const storeApi = await getStoreApiFromRequest(req);
const { key, quantity } = await req.json(); const { key, quantity } = await req.json();
if (quantity > 0) { if (quantity > 0) {
const cart = await storeApi.updateItem({ key, quantity }); const cart = await storeApi.updateItem({ key, quantity });
@ -47,6 +65,7 @@ export async function PUT(req: NextRequest) {
export async function DELETE(req: NextRequest) { export async function DELETE(req: NextRequest) {
try { try {
const storeApi = await getStoreApiFromRequest(req);
const { key } = await req.json(); const { key } = await req.json();
const cart = await storeApi.removeFromCart({ key }); const cart = await storeApi.removeFromCart({ key });
return NextResponse.json(cart, { status: 200 }); return NextResponse.json(cart, { status: 200 });

View File

@ -0,0 +1,31 @@
import { authOptions } from 'lib/auth/config';
import { getStoreApiFromRequest, OrderPayload } from 'lib/woocomerce/storeApi';
import { getServerSession } from 'next-auth';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.customer_id) {
return NextResponse.json({ error: 'User not logged' }, { status: 401 });
}
const storeApi = await getStoreApiFromRequest(req);
const { billing_address, shipping_address, customer_note, payment_method, payment_data } =
await req.json();
const order: OrderPayload = {
shipping_address,
billing_address: billing_address || shipping_address,
customer_note,
payment_method: payment_method || 'bacs', // Ensure payment method is used
payment_data: payment_data || [] // Ensure payment data is used
};
console.log('Creating order', order);
const result = await storeApi.createOrder(order);
return NextResponse.json(result, { status: 200 });
} catch (error) {
console.error('Error creating order', error);
return NextResponse.json({ error: JSON.stringify(error) }, { status: 500 });
}
}

View File

@ -20,9 +20,8 @@ const shippingSchema = z.object({
address_1: z.string().min(3), address_1: z.string().min(3),
address_2: z.string().optional(), address_2: z.string().optional(),
city: z.string().min(3), city: z.string().min(3),
state: z.string().max(2).min(2),
postcode: z.string().min(3), postcode: z.string().min(3),
country: z.string().min(3), country: z.string().min(2).max(2),
company: z.string().optional() company: z.string().optional()
}); });

View File

@ -9,6 +9,18 @@ export default function CheckoutReview() {
const { cart } = useCart(); const { cart } = useCart();
const { checkout } = useCheckout(); const { checkout } = useCheckout();
const handleCreateOrder = async () => {
const order = await fetch('/api/customer/order', {
method: 'POST',
body: JSON.stringify({
billing_address: checkout?.billing,
shipping_address: checkout?.shipping,
payment_method: checkout?.payment_method
})
}).catch((err) => {
console.error('Error creating order', err);
});
};
return ( return (
<section className="mt-4 grid w-full gap-4 px-4 pb-4"> <section className="mt-4 grid w-full gap-4 px-4 pb-4">
<h1 className="text-2xl font-bold">Riassunto</h1> <h1 className="text-2xl font-bold">Riassunto</h1>
@ -41,8 +53,13 @@ export default function CheckoutReview() {
<span className="mt-4 text-lg font-bold">Metodo di pagamento</span> <span className="mt-4 text-lg font-bold">Metodo di pagamento</span>
<span>{checkout?.payment_method}</span> <span>{checkout?.payment_method}</span>
<Button title="Vai al pagamento" color="primary" className="text-white"> <Button
Vai al pagamento title="Vai al pagamento"
color="primary"
className="text-white"
onPress={handleCreateOrder}
>
Crea ordine
</Button> </Button>
</section> </section>
); );

View File

@ -6,7 +6,8 @@ export default async function ProductPage(props: { params: Promise<{ slug: strin
const slug = (await props.params).slug; const slug = (await props.params).slug;
const category = (await woocommerce.get('products/categories', { slug }))?.[0]; const category = (await woocommerce.get('products/categories', { slug }))?.[0];
const products: Product[] = await woocommerce.get('products', { const products: Product[] = await woocommerce.get('products', {
category: category.id.toString() category: category.id.toString(),
author: 1
}); });
return ( return (

View File

@ -103,7 +103,7 @@ export default async function ProductPage(props: { params: Promise<{ name: strin
<div className="mx-auto max-w-screen-2xl px-4"> <div className="mx-auto max-w-screen-2xl px-4">
<div className="grid items-start gap-8 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-black md:flex-col-reverse lg:grid-cols-2 lg:flex-row lg:flex-col"> <div className="grid items-start gap-8 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-black md:flex-col-reverse lg:grid-cols-2 lg:flex-row lg:flex-col">
<h1 className="mb-2 text-5xl font-medium md:hidden lg:hidden">{product.name}</h1> <h1 className="mb-2 text-5xl font-medium md:hidden lg:hidden">{product.name}</h1>
<div className="lg:sticky top-4 w-full self-start"> <div className="top-4 w-full self-start lg:sticky">
<Suspense <Suspense
fallback={ fallback={
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" /> <div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />

View File

@ -20,7 +20,8 @@ export default async function SearchPage(props: {
orderby: sortKey, orderby: sortKey,
order, order,
min_price: minPrice ?? '0', min_price: minPrice ?? '0',
max_price: maxPrice ?? '1000' max_price: maxPrice ?? '1000',
author: 1
}); });
const resultsText = products.length > 1 ? 'results' : 'result'; const resultsText = products.length > 1 ? 'results' : 'result';

View File

@ -4,8 +4,7 @@ import Link from 'next/link';
import { GridTileImage } from './grid/tile'; import { GridTileImage } from './grid/tile';
export async function Carousel() { export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page. const products: Product[] = await woocommerce.get('products', { author: 1 });
const products: Product[] = await woocommerce.get('products');
if (!products?.length) return null; if (!products?.length) return null;

View File

@ -5,24 +5,22 @@ import { Cart } from 'lib/woocomerce/models/cart';
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
type CartContextType = { type CartContextType = {
cart: Cart | undefined; cart?: Cart;
setNewCart: (cart: Cart) => void; setNewCart: (cart: Cart) => void;
}; };
const CartContext = createContext<CartContextType | undefined>(undefined); const CartContext = createContext<CartContextType | undefined>(undefined);
export function CartProvider({ children }: { children: React.ReactNode }) { export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<Cart | undefined>(undefined); const [cart, setCart] = useState<Cart>();
const setNewCart = (cart: Cart) => {
setCart(cart);
};
const fetchCart = async () => { const fetchCart = async () => {
try { try {
const cart = await (await fetch('/api/cart')).json(); const res = await fetch('/api/cart');
setNewCart(cart); const cart = await res.json();
setCart(cart);
} catch (err) { } catch (err) {
console.error(err); console.error('Error fetching cart', err);
} }
}; };
@ -32,21 +30,14 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
return ( return (
<NextUIProvider> <NextUIProvider>
<CartContext.Provider <CartContext.Provider value={{ cart, setNewCart: setCart }}>{children}</CartContext.Provider>
value={{
cart,
setNewCart
}}
>
{children}
</CartContext.Provider>
</NextUIProvider> </NextUIProvider>
); );
} }
export function useCart() { export function useCart() {
const context = useContext(CartContext); const context = useContext(CartContext);
if (context === undefined) { if (!context) {
throw new Error('useCart must be used within a CartProvider'); throw new Error('useCart must be used within a CartProvider');
} }
return context; return context;

View File

@ -32,7 +32,7 @@ export default function ShippingForm({
<div className={clsx('flex flex-col', className)}> <div className={clsx('flex flex-col', className)}>
{title && <h2 className="mt-2 text-2xl font-bold">{title}</h2>} {title && <h2 className="mt-2 text-2xl font-bold">{title}</h2>}
{Object.entries(checkout?.shipping || {}) {Object.entries(checkout?.shipping || {})
.filter(([key]) => key !== 'country') .filter(([key]) => key !== 'country' && key !== 'state')
.map(([key, value], index) => ( .map(([key, value], index) => (
<div className={index !== 0 ? 'mt-4' : ''} key={key}> <div className={index !== 0 ? 'mt-4' : ''} key={key}>
<Input <Input
@ -74,7 +74,7 @@ export default function ShippingForm({
> >
{countries.map((item) => ( {countries.map((item) => (
<SelectItem <SelectItem
key={item.name} key={item.code}
startContent={ startContent={
<Avatar alt={item.name + '-img'} className="h-6 w-6" src={item.icon} /> <Avatar alt={item.name + '-img'} className="h-6 w-6" src={item.icon} />
} }

View File

@ -1,4 +1,3 @@
import { storeApi } from 'lib/woocomerce/storeApi';
import { wordpress } from 'lib/wordpress/wordpress'; import { wordpress } from 'lib/wordpress/wordpress';
import { NextAuthOptions, Session, User } from 'next-auth'; import { NextAuthOptions, Session, User } from 'next-auth';
import { JWT } from 'next-auth/jwt'; import { JWT } from 'next-auth/jwt';
@ -25,8 +24,7 @@ export const authOptions = {
if (user) { if (user) {
return user; return user;
} }
storeApi._seCartToken('');
storeApi._setAuthorizationToken('');
// Return null if user data could not be retrieved // Return null if user data could not be retrieved
return null; return null;
} }
@ -45,14 +43,5 @@ export const authOptions = {
session.user = token.user; session.user = token.user;
return session; return session;
} }
},
events: {
async signIn() {
storeApi._seCartToken('');
},
async signOut() {
storeApi._seCartToken('');
storeApi._setAuthorizationToken('');
}
} }
} satisfies NextAuthOptions; } satisfies NextAuthOptions;

View File

@ -11,10 +11,11 @@ export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyUR
export const ensureStartsWith = (stringToCheck: string, startsWith: string) => export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`; stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
export const getCountries = (): { name: string; icon: string }[] => export const getCountries = (): { name: string; icon: string; code: string }[] =>
(countries as { country: string; flag_base64: string }[]).map(({ country, flag_base64 }) => ({ (countries as { name: string; emoji: string; code: string }[]).map(({ name, emoji, code }) => ({
name: country, name,
icon: flag_base64 icon: emoji,
code
})); }));
export const isStrinInteger = (value: string) => { export const isStrinInteger = (value: string) => {

View File

@ -72,7 +72,7 @@ export type OrderItem = {
export interface OrderNotes { export interface OrderNotes {
id: number; id: number;
author: string; author: string | number;
date_created: Date; date_created: Date;
date_created_gmt: Date; date_created_gmt: Date;
note: string; note: string;

View File

@ -1,14 +1,12 @@
import axios, { AxiosInstance, RawAxiosRequestHeaders } from 'axios'; import axios, { RawAxiosRequestHeaders } from 'axios';
import { getToken } from 'next-auth/jwt';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { Billing } from './models/billing'; import { Billing } from './models/billing';
import { Cart } from './models/cart'; import { Cart } from './models/cart';
import { Order } from './models/orders'; import { Order } from './models/orders';
import { Shipping } from './models/shipping'; import { Shipping } from './models/shipping';
/**
* WooCommerce Store API Client for server-side requests.
* To use this in the client-side, you need to create a new route of api endpoint in your Next.js app.
*/
export type OrderPayload = { export type OrderPayload = {
billing_address: Billing; billing_address: Billing;
shipping_address: Shipping; shipping_address: Shipping;
@ -22,78 +20,98 @@ export type PaymentMethodData = {
value: string; value: string;
}; };
class WooCommerceStoreApiClient { function createStoreApiClient({
private client: AxiosInstance; baseURL = process.env.WOOCOMMERCE_STORE_API_URL ?? 'http://localhost/wp-json/wc/store/v1',
authToken,
cartToken
}: {
baseURL?: string;
authToken?: string;
cartToken?: string;
}) {
const headers: RawAxiosRequestHeaders = {
'Content-Type': 'application/json',
Accept: '*/*',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...(cartToken ? { 'cart-token': cartToken } : {})
};
constructor(baseURL: string) { const client = axios.create({ baseURL, headers });
const headers: RawAxiosRequestHeaders = {
'Content-Type': 'application/json',
Accept: '*/*'
};
this.client = axios.create({ async function _request(method: 'get' | 'post' | 'put' | 'delete', url: string, data?: any) {
baseURL, try {
headers console.debug('Request', method, url, data, headers);
}); const response = await axios({
} method,
url: baseURL + url,
_setAuthorizationToken(token: string) { data,
if (token) { headers
this.client.defaults.headers['Authorization'] = `Bearer ${token}`; });
} else { return response;
this._deleteAuthorizationToken(); } catch (error: any) {
if (error.response) {
console.error('Error response:', error.response.data);
if (error.response.data.code === 'jwt_auth_invalid_token') {
console.debug('Token expired, regenerating...');
const newAuthToken = await regenerateAuthToken();
headers.Authorization = `Bearer ${newAuthToken}`;
return _request(method, url, data);
}
throw new Error(
`Request failed with status ${error.response.status}: ${error.response.data.message}`
);
}
throw error;
} }
} }
_deleteAuthorizationToken() { async function regenerateAuthToken(): Promise<string> {
this.client.defaults.headers['Authorization'] = ''; console.debug('Regenerating auth token...');
const res = await axios.post(`${baseURL}/auth/refresh`, {}, { headers });
return res.data.token;
} }
_seCartToken(cartToken: string) { return {
this.client.defaults.headers['cart-token'] = cartToken; async getCart(
} params?: Record<string, string | number>
): Promise<{ cart: Cart; cartToken?: string }> {
const res = await _request('get', '/cart', { params });
return { cart: res.data, cartToken: res.headers['cart-token'] };
},
async getCart(params?: Record<string, string | number>): Promise<Cart> { async addToCart(payload: {
return this.client.get<Cart>('/cart', { params }).then(async (response) => { id: string | number;
this._seCartToken(response.headers['cart-token']); quantity: number;
variation: { attribute: string; value: string }[];
return response.data; }): Promise<Cart> {
}); const res = await _request('post', '/cart/add-item', payload);
} return res.data;
},
async addToCart(payload: { async updateItem(payload: { key: string | number; quantity: number }): Promise<Cart> {
id: string | number; const res = await _request('post', '/cart/update-item', payload);
quantity: number; return res.data;
variation: { attribute: string; value: string }[]; },
}): Promise<Cart> { async removeFromCart(payload: { key: string | number }): Promise<Cart> {
return this.client.post<Cart>('/cart/add-item', payload).then((response) => response.data); const res = await _request('post', `/cart/remove-item?key=${payload.key}`);
} return res.data;
},
async updateItem(payload: { key: string | number; quantity: number }): Promise<Cart> { createOrder(order: OrderPayload): Promise<Order> {
return this.client.post<Cart>('/cart/update-item', payload).then((response) => response.data); return _request('post', '/checkout', order).then((res) => res.data);
} },
async getOrders(): Promise<Order[]> {
async removeFromCart(payload: { key: string | number }): Promise<Cart> { const res = await _request('get', '/checkout');
return this.client return res.data;
.post<Cart>(`/cart/remove-item?key=${payload.key}`) },
.then((response) => response.data); async getOrder(id: string | number): Promise<Order> {
} const res = await _request('get', `/checkout/${id}`);
return res.data;
async createOrder(order: OrderPayload): Promise<Order> { }
return this.client.post('/checkout', order).then((response) => response.data); };
}
async getOrders(params?: Record<string, string | number>): Promise<Order[]> {
return this.client.get<Order[]>('/checkout', { params }).then((response) => response.data);
}
async getOrder(id: string | number): Promise<Order> {
return this.client.get<Order>(`/checkout/${id}`).then((response) => response.data);
}
} }
// Example usage. export async function getStoreApiFromRequest(req?: NextRequest) {
const baseURL = const cartToken = (await cookies()).get('cart-token')?.value;
process.env.WOOCOMMERCE_STORE_API_URL ?? 'http://wordpress.localhost/wp-json/wc/store/v1'; const authToken = req ? (await getToken({ req }))?.user?.token : undefined;
export const storeApi = new WooCommerceStoreApiClient(baseURL); return createStoreApiClient({ cartToken, authToken });
}

View File

@ -13,7 +13,6 @@ const nextConfig: NextConfig = {
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https',
hostname: '**' hostname: '**'
} }
] ]

File diff suppressed because one or more lines are too long