From 56160f4d5e3c7ea5fc0cccc5e76c770d6f80dfdc Mon Sep 17 00:00:00 2001 From: GustavH Date: Sun, 1 Dec 2024 17:27:32 +0100 Subject: [PATCH 1/3] Update environment configuration and enhance password validation in authentication forms --- .env.example | 20 +++++++++++++------- src/components/authentication/AuthForms.tsx | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index fbc2167..47ad158 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,21 @@ -NEXT_PUBLIC_COMMERCIFY_API_URL=http://localhost:8080/api/v1 +NODE_ENV=development -# MySQL Configuration -MYSQL_ROOT_PASSWORD=Password1234! -MYSQL_USER=commercify -MYSQL_PASSWORD=c0mmercifypassw0rd123! +NEXT_PUBLIC_COMMERCIFY_API_URL=https://domain.com:6091/api/v1 +NEXT_PUBLIC_DEV_COMMERCIFY_API_URL=http://localhost:6091/api/v1 +SMTP_HOST=smtp.ethereal.email +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +EMAIL_DEV=dev@email.com -# Spring Boot Application Configuration -SPRING_DATASOURCE_URL=jdbc:mysql://mysql-db:3306/commercifydb +# Backend Configuration +# use "host.docker.internal" as host if you are connecting to a MySQL database running on the host machine +SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/commercifydb?createDatabaseIfNotExist=true SPRING_DATASOURCE_USERNAME=root SPRING_DATASOURCE_PASSWORD=Password1234! STRIPE_TEST_SECRET=sk_test_ STRIPE_SECRET_WEBHOOK=whsec_ STRIPE_WEBHOOK_ENDPOINT=we_ JWT_SECRET_KEY=7581e8477a88733917bc3b48f683a876935a492a0bd976a59429a2f28c71fde +ADMIN_EMAIL=admin@commercify.app +ADMIN_PASSWORD=commercifyadmin123! # min 8 characters \ No newline at end of file diff --git a/src/components/authentication/AuthForms.tsx b/src/components/authentication/AuthForms.tsx index 21cca4c..8990c34 100644 --- a/src/components/authentication/AuthForms.tsx +++ b/src/components/authentication/AuthForms.tsx @@ -20,7 +20,7 @@ import { Checkbox } from '@/components/ui/checkbox'; const registerSchema = z.object({ email: z.string().email(), - password: z.string().min(2).optional(), + password: z.string().min(8).optional(), firstName: z.string().min(2), lastName: z.string().min(2), isGuest: z.boolean().default(false), @@ -35,7 +35,7 @@ const registerSchema = z.object({ const loginSchema = z.object({ email: z.string().email(), - password: z.string().min(2), + password: z.string().min(8), rememberMe: z.boolean().default(false), }); From b79474412d42bddd7fd830b96f46faa0c7b44fab Mon Sep 17 00:00:00 2001 From: GustavH Date: Sun, 1 Dec 2024 17:57:39 +0100 Subject: [PATCH 2/3] Add checkout and confirmation pages, implement order service, and update routing --- package-lock.json | 24 +++ package.json | 1 + .../checkout/_components/CheckoutPage.tsx | 161 ++++++++++++++++++ .../checkout/_components/ConfirmationPage.tsx | 61 +++++++ .../(client)/checkout/confirmation/page.tsx | 5 + src/app/(client)/checkout/page.tsx | 5 + src/components/ui/separator.tsx | 31 ++++ src/components/webshop/CartSheet.tsx | 32 +++- src/context/AuthContext.tsx | 2 +- src/services/orderService.ts | 32 ++++ src/types/order.ts | 37 ++++ 11 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 src/app/(client)/checkout/_components/CheckoutPage.tsx create mode 100644 src/app/(client)/checkout/_components/ConfirmationPage.tsx create mode 100644 src/app/(client)/checkout/confirmation/page.tsx create mode 100644 src/app/(client)/checkout/page.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/services/orderService.ts create mode 100644 src/types/order.ts diff --git a/package-lock.json b/package-lock.json index 54cde5f..297455e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "axios": "^1.7.7", @@ -1330,6 +1331,29 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", + "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", diff --git a/package.json b/package.json index 65e62b0..1cb2076 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "axios": "^1.7.7", diff --git a/src/app/(client)/checkout/_components/CheckoutPage.tsx b/src/app/(client)/checkout/_components/CheckoutPage.tsx new file mode 100644 index 0000000..f71ad31 --- /dev/null +++ b/src/app/(client)/checkout/_components/CheckoutPage.tsx @@ -0,0 +1,161 @@ +"use client"; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useCart } from '@/context/CartContext'; +import { useAuth } from '@/context/AuthContext'; +import { orderService } from '@/services/orderService'; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useToast } from '@/hooks/use-toast'; +import { Separator } from '@/components/ui/separator'; + +export function CheckoutPage() { + const { cart, totalPrice, clearCart } = useCart(); + const { user } = useAuth(); + const { toast } = useToast(); + const router = useRouter(); + const [isProcessing, setIsProcessing] = useState(false); + + const handleCheckout = async () => { + if (!user) { + toast({ + title: "Please log in", + description: "You need to be logged in to checkout", + variant: "destructive", + }); + router.push('/auth/login'); + return; + } + + try { + setIsProcessing(true); + + // Create order lines from cart + const orderLines = cart.map(item => ({ + productId: item.id, + quantity: item.quantity, + ...(item.selectedVariant && { variantId: item.selectedVariant.id }), + })); + + // Create order + const orderResponse = await orderService.createOrder(user.id, { + currency: "DKK", + orderLines, + }); + + // Create MobilePay payment + const paymentResponse = await orderService.createMobilePayPayment({ + orderId: orderResponse.order.id.toString(), + currency: "DKK", + paymentMethod: "WALLET", + returnUrl: `${window.location.origin}/checkout/confirmation`, + }); + + // Clear cart and redirect to MobilePay + clearCart(); + window.location.href = paymentResponse.redirectUrl; + + } catch (error) { + toast({ + title: "Checkout failed", + description: error instanceof Error ? error.message : "Please try again", + variant: "destructive", + }); + } finally { + setIsProcessing(false); + } + }; + + const formatPrice = (amount: number) => { + return new Intl.NumberFormat('da-DK', { + style: 'currency', + currency: 'DKK', + }).format(amount); + }; + + if (cart.length === 0) { + return ( +
+

Your cart is empty

+ +
+ ); + } + + return ( +
+
+ {/* Order Summary */} +
+ + + Order Summary + Review your items + + + {cart.map((item) => ( +
+
+

{item.name}

+ {item.selectedVariant && ( +

+ Size: {item.selectedVariant.options[0].value} +

+ )} +

+ Quantity: {item.quantity} +

+
+

+ {formatPrice(item.price.amount * item.quantity)} +

+
+ ))} +
+ + +
+

Total

+

{formatPrice(totalPrice)}

+
+
+
+
+ + {/* Payment */} +
+ + + Payment + Choose your payment method + + +

+ You will be redirected to MobilePay to complete your payment +

+
+ + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(client)/checkout/_components/ConfirmationPage.tsx b/src/app/(client)/checkout/_components/ConfirmationPage.tsx new file mode 100644 index 0000000..9eb5411 --- /dev/null +++ b/src/app/(client)/checkout/_components/ConfirmationPage.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { CheckCircle, XCircle } from "lucide-react"; + +export function ConfirmationPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const success = searchParams.get('success') === 'true'; + const orderId = searchParams.get('orderId'); + + return ( +
+ + +
+ {success ? ( + + ) : ( + + )} +
+ + {success ? 'Order Confirmed' : 'Payment Failed'} + + {orderId && success && ( +

+ Order #{orderId} +

+ )} +
+ +

+ {success + ? 'Thank you for your order. You will receive a confirmation email shortly.' + : 'Sorry, your payment was not successful. Please try again.'} +

+
+ {!success && ( + + )} + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(client)/checkout/confirmation/page.tsx b/src/app/(client)/checkout/confirmation/page.tsx new file mode 100644 index 0000000..0ec4d7f --- /dev/null +++ b/src/app/(client)/checkout/confirmation/page.tsx @@ -0,0 +1,5 @@ +import { ConfirmationPage } from "../_components/ConfirmationPage"; + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/src/app/(client)/checkout/page.tsx b/src/app/(client)/checkout/page.tsx new file mode 100644 index 0000000..1463880 --- /dev/null +++ b/src/app/(client)/checkout/page.tsx @@ -0,0 +1,5 @@ +import { CheckoutPage } from "./_components/CheckoutPage"; + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..12d81c4 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/webshop/CartSheet.tsx b/src/components/webshop/CartSheet.tsx index 0452e33..19ddf57 100644 --- a/src/components/webshop/CartSheet.tsx +++ b/src/components/webshop/CartSheet.tsx @@ -1,6 +1,7 @@ "use client"; import React from 'react'; +import { useRouter } from 'next/navigation'; import { useCart } from '@/context/CartContext'; import { Button } from "@/components/ui/button"; import { @@ -9,15 +10,22 @@ import { SheetHeader, SheetTitle, SheetTrigger, + SheetClose, } from "@/components/ui/sheet"; import { ShoppingCart, Minus, Plus, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { useAuth } from '@/context/AuthContext'; +import { useToast } from '@/hooks/use-toast'; +import { CartItem } from '@/types/product'; import Image from 'next/image'; import placeholderImage from '@/public/placeholder.webp'; -import { CartItem } from '@/types/product'; export const CartSheet = () => { + const router = useRouter(); const { cart, totalItems, totalPrice, updateCartItemQuantity, removeFromCart } = useCart(); + const { isAuthenticated } = useAuth(); + const { toast } = useToast(); + const sheetCloseRef = React.useRef(null); const formatPrice = (amount: number, currency: string) => { return new Intl.NumberFormat('da-DK', { @@ -26,6 +34,22 @@ export const CartSheet = () => { }).format(amount); }; + const handleCheckout = () => { + if (!isAuthenticated) { + toast({ + title: "Login Required", + description: "Please log in to proceed with checkout", + variant: "destructive", + }); + sheetCloseRef.current?.click(); // Close the cart sheet + router.push('/auth/login'); + return; + } + + sheetCloseRef.current?.click(); // Close the cart sheet + router.push('/checkout'); + }; + const getVariantDisplay = (item: CartItem) => { if (!item.selectedVariant) return null; @@ -150,11 +174,15 @@ export const CartSheet = () => { {formatPrice(totalPrice, cart[0].price.currency)} - )} + ); diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 507e27c..8cfa202 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -87,7 +87,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { title: "Logged out", description: "You have been successfully logged out", }); - router.push('/login'); + router.push('/auth/login'); }; return ( diff --git a/src/services/orderService.ts b/src/services/orderService.ts new file mode 100644 index 0000000..0a4ab8b --- /dev/null +++ b/src/services/orderService.ts @@ -0,0 +1,32 @@ +import { BaseApiService } from '@/types/apiBase'; +import { + CreateOrderRequest, + CreateOrderResponse, + CreatePaymentRequest, + CreatePaymentResponse +} from '@/types/order'; + +class OrderService extends BaseApiService { + private static instance: OrderService; + + private constructor() { + super('http://localhost:6091/api/v1'); + } + + public static getInstance(): OrderService { + if (!OrderService.instance) { + OrderService.instance = new OrderService(); + } + return OrderService.instance; + } + + async createOrder(userId: number, orderData: CreateOrderRequest): Promise { + return this.post(`/orders/${userId}`, orderData, true); + } + + async createMobilePayPayment(paymentData: CreatePaymentRequest): Promise { + return this.post('/payments/mobilepay/create', paymentData, true); + } +} + +export const orderService = OrderService.getInstance(); \ No newline at end of file diff --git a/src/types/order.ts b/src/types/order.ts new file mode 100644 index 0000000..adfb5c2 --- /dev/null +++ b/src/types/order.ts @@ -0,0 +1,37 @@ +export interface OrderLine { + productId: number; + quantity: number; + variantId?: number; +} + +export interface CreateOrderRequest { + currency: string; + orderLines: OrderLine[]; +} + +export interface Order { + id: number; + userId: number; + totalPrice: number; + currency: string; + orderStatus: string; + createdAt: string; +} + +export interface CreateOrderResponse { + order: Order; + message: string; +} + +export interface CreatePaymentRequest { + orderId: string; + currency: string; + paymentMethod: 'WALLET'; + returnUrl: string; +} + +export interface CreatePaymentResponse { + paymentId: number; + status: string; + redirectUrl: string; +} \ No newline at end of file From 649c22bdb9f106e508b3ae15938025c84f9aad3e Mon Sep 17 00:00:00 2001 From: GustavH Date: Sun, 1 Dec 2024 22:55:51 +0100 Subject: [PATCH 3/3] Updated checkout flow, to work with guest user --- package-lock.json | 142 +++++++ package.json | 3 + .../_components/AuthenticationStep.tsx | 141 +++++++ .../checkout/_components/CheckoutPage.tsx | 186 +++------- .../checkout/_components/CheckoutSteps.tsx | 48 +++ .../checkout/_components/InformationStep.tsx | 348 ++++++++++++++++++ .../checkout/_components/OrderSummary.tsx | 88 +++++ .../checkout/_components/PaymentStep.tsx | 184 +++++++++ src/app/(client)/checkout/layout.tsx | 13 + src/app/(client)/checkout/page.tsx | 6 +- src/app/(client)/layout.tsx | 2 +- src/app/(client)/page.tsx | 147 ++++++-- src/components/ui/alert.tsx | 59 +++ src/components/ui/radio-group.tsx | 44 +++ src/components/ui/scroll-area.tsx | 48 +++ src/components/ui/tabs.tsx | 55 +++ src/components/webshop/CartSheet.tsx | 24 +- src/context/AuthContext.tsx | 23 +- src/context/CartContext.tsx | 2 +- src/context/CheckoutContext.tsx | 46 +++ src/services/authService.ts | 26 +- src/services/authStorage.ts | 52 +-- src/services/orderService.ts | 2 +- src/services/productsService.ts | 13 +- src/types/apiBase.ts | 8 +- src/types/auth.ts | 3 +- src/types/checkout.ts | 17 + src/types/order.ts | 10 +- src/types/product.ts | 6 +- 29 files changed, 1464 insertions(+), 282 deletions(-) create mode 100644 src/app/(client)/checkout/_components/AuthenticationStep.tsx create mode 100644 src/app/(client)/checkout/_components/CheckoutSteps.tsx create mode 100644 src/app/(client)/checkout/_components/InformationStep.tsx create mode 100644 src/app/(client)/checkout/_components/OrderSummary.tsx create mode 100644 src/app/(client)/checkout/_components/PaymentStep.tsx create mode 100644 src/app/(client)/checkout/layout.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/radio-group.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/context/CheckoutContext.tsx create mode 100644 src/types/checkout.ts diff --git a/package-lock.json b/package-lock.json index 297455e..77a695b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,12 @@ "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-radio-group": "^1.2.1", + "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", @@ -1288,6 +1291,115 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", + "integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.1.tgz", + "integrity": "sha512-FnM1fHfCtEZ1JkyfH/1oMiTcFBQvHKl4vD9WnpwkLgtF+UmnXMCad6ECPTaAjcDjam+ndOEJWgHyKDGNteWSHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", @@ -1372,6 +1484,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", diff --git a/package.json b/package.json index 1cb2076..42eec78 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,12 @@ "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-radio-group": "^1.2.1", + "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", diff --git a/src/app/(client)/checkout/_components/AuthenticationStep.tsx b/src/app/(client)/checkout/_components/AuthenticationStep.tsx new file mode 100644 index 0000000..33180f7 --- /dev/null +++ b/src/app/(client)/checkout/_components/AuthenticationStep.tsx @@ -0,0 +1,141 @@ +"use client"; + +import React from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { useAuth } from '@/context/AuthContext'; +import { useCheckout } from '@/context/CheckoutContext'; +import { useToast } from '@/hooks/use-toast'; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(6, "Password must be at least 6 characters"), +}); + +export function AuthenticationStep() { + const { isAuthenticated, login } = useAuth(); + const { setStep, setIsGuest } = useCheckout(); + const { toast } = useToast(); + const [isLoading, setIsLoading] = React.useState(false); + + const form = useForm>({ + resolver: zodResolver(loginSchema), + }); + + console.log(isAuthenticated); + + if (isAuthenticated) { + setStep('information'); + return null; + } + + const handleLogin = async (data: z.infer) => { + try { + setIsLoading(true); + await login(data); + setIsGuest(false); + setStep('information'); + } catch (error) { + if (error instanceof Error) { + toast({ + title: "Login failed", + description: "Invalid email or password", + variant: "destructive", + }); + } + } finally { + setIsLoading(false); + } + }; + + const handleGuestCheckout = () => { + setIsGuest(true); + setStep('information'); + }; + + return ( + + + Sign In + + Sign in to your account or continue as a guest + + + +
+ + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + + + + +
+
+
+
+
+ + Or continue with + +
+
+ + + + + ); +} \ No newline at end of file diff --git a/src/app/(client)/checkout/_components/CheckoutPage.tsx b/src/app/(client)/checkout/_components/CheckoutPage.tsx index f71ad31..8a46906 100644 --- a/src/app/(client)/checkout/_components/CheckoutPage.tsx +++ b/src/app/(client)/checkout/_components/CheckoutPage.tsx @@ -1,159 +1,65 @@ "use client"; -import React, { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import React from 'react'; +import { AuthenticationStep } from './AuthenticationStep'; +import { StepIndicator } from './CheckoutSteps'; +import { useCheckout } from '@/context/CheckoutContext'; +import OrderSummary from './OrderSummary'; +import { InformationStep } from './InformationStep'; +import { PaymentStep } from './PaymentStep'; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; import { useCart } from '@/context/CartContext'; -import { useAuth } from '@/context/AuthContext'; -import { orderService } from '@/services/orderService'; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { useToast } from '@/hooks/use-toast'; -import { Separator } from '@/components/ui/separator'; +import { ShoppingBag } from 'lucide-react'; +import { useRouter } from 'next/navigation'; -export function CheckoutPage() { - const { cart, totalPrice, clearCart } = useCart(); - const { user } = useAuth(); - const { toast } = useToast(); +export function Checkout() { const router = useRouter(); - const [isProcessing, setIsProcessing] = useState(false); - - const handleCheckout = async () => { - if (!user) { - toast({ - title: "Please log in", - description: "You need to be logged in to checkout", - variant: "destructive", - }); - router.push('/auth/login'); - return; - } - - try { - setIsProcessing(true); - - // Create order lines from cart - const orderLines = cart.map(item => ({ - productId: item.id, - quantity: item.quantity, - ...(item.selectedVariant && { variantId: item.selectedVariant.id }), - })); - - // Create order - const orderResponse = await orderService.createOrder(user.id, { - currency: "DKK", - orderLines, - }); - - // Create MobilePay payment - const paymentResponse = await orderService.createMobilePayPayment({ - orderId: orderResponse.order.id.toString(), - currency: "DKK", - paymentMethod: "WALLET", - returnUrl: `${window.location.origin}/checkout/confirmation`, - }); - - // Clear cart and redirect to MobilePay - clearCart(); - window.location.href = paymentResponse.redirectUrl; - - } catch (error) { - toast({ - title: "Checkout failed", - description: error instanceof Error ? error.message : "Please try again", - variant: "destructive", - }); - } finally { - setIsProcessing(false); - } - }; - - const formatPrice = (amount: number) => { - return new Intl.NumberFormat('da-DK', { - style: 'currency', - currency: 'DKK', - }).format(amount); - }; + const { state } = useCheckout(); + const { cart } = useCart(); if (cart.length === 0) { return ( -
-

Your cart is empty

- +
+ + +
+ +
+ Your Cart is Empty + + Add some items to your cart to proceed with checkout + +
+ + + +
); } + const currentStepIndex = { + 'authentication': 0, + 'information': 1, + 'payment': 2, + }[state.step]; + return (
-
- {/* Order Summary */} -
- - - Order Summary - Review your items - - - {cart.map((item) => ( -
-
-

{item.name}

- {item.selectedVariant && ( -

- Size: {item.selectedVariant.options[0].value} -

- )} -

- Quantity: {item.quantity} -

-
-

- {formatPrice(item.price.amount * item.quantity)} -

-
- ))} -
- - -
-

Total

-

{formatPrice(totalPrice)}

-
-
-
+ + +
+
+ {state.step === 'authentication' && } + {state.step === 'information' && } + {state.step === 'payment' && }
- {/* Payment */} -
- - - Payment - Choose your payment method - - -

- You will be redirected to MobilePay to complete your payment -

-
- - - -
+
+
diff --git a/src/app/(client)/checkout/_components/CheckoutSteps.tsx b/src/app/(client)/checkout/_components/CheckoutSteps.tsx new file mode 100644 index 0000000..50bb5e9 --- /dev/null +++ b/src/app/(client)/checkout/_components/CheckoutSteps.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from 'react'; +import { LockIcon, UserIcon, CreditCard } from 'lucide-react'; + +interface StepIndicatorProps { + currentStep: number; +} + +export function StepIndicator({ currentStep }: StepIndicatorProps) { + const steps = [ + { title: 'Sign In', icon: }, + { title: 'Information', icon: }, + { title: 'Payment', icon: }, + ]; + + return ( +
+
+ {/* Progress bar */} +
+
+
+ + {/* Steps */} + {steps.map((step, index) => ( +
+
+ {step.icon} +
+ {step.title} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(client)/checkout/_components/InformationStep.tsx b/src/app/(client)/checkout/_components/InformationStep.tsx new file mode 100644 index 0000000..ceaa5b4 --- /dev/null +++ b/src/app/(client)/checkout/_components/InformationStep.tsx @@ -0,0 +1,348 @@ +"use client"; + +import React from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { useAuth } from '@/context/AuthContext'; +import { useCheckout } from '@/context/CheckoutContext'; +import { useToast } from '@/hooks/use-toast'; +import { RegisterRequest } from '@/types/auth'; + +const addressSchema = z.object({ + street: z.string().min(1, "Street is required"), + city: z.string().min(1, "City is required"), + state: z.string().min(1, "State/Region is required"), + zipCode: z.string().min(4, "Zip code must be at least 4 characters"), + country: z.string().min(1, "Country is required"), +}); + +const informationSchema = z.object({ + firstName: z.string().min(2, "First name must be at least 2 characters"), + lastName: z.string().min(2, "Last name must be at least 2 characters"), + email: z.string().email(), + shippingAddress: addressSchema, + useSameForBilling: z.boolean().default(true), + billingAddress: addressSchema.optional(), + wantsToRegister: z.boolean().default(false), + password: z.string().min(6).optional(), +}); + +function AddressFields({ prefix, disabled = false }: { prefix: 'shippingAddress' | 'billingAddress', disabled?: boolean }) { + return ( +
+ ( + + Street Address + + + + + + )} + /> + +
+ ( + + City + + + + + + )} + /> + ( + + Zip Code + + + + + + )} + /> +
+ + ( + + State/Region + + + + + + )} + /> + + ( + + Country + + + + + + )} + /> +
+ ); +} + +export function InformationStep() { + const { user, register } = useAuth(); + const { state, setStep, setCustomerInfo } = useCheckout(); + const { toast } = useToast(); + const [isLoading, setIsLoading] = React.useState(false); + + const form = useForm>({ + resolver: zodResolver(informationSchema), + defaultValues: { + firstName: user?.firstName || '', + lastName: user?.lastName || '', + email: user?.email || '', + useSameForBilling: true, + wantsToRegister: false, + shippingAddress: { + country: 'Danmark', + }, + }, + }); + + const wantsToRegister = form.watch('wantsToRegister'); + const useSameForBilling = form.watch('useSameForBilling'); + + const onSubmit = async (data: z.infer) => { + try { + setIsLoading(true); + + if (state.isGuest) { + const registerRequest: RegisterRequest = { + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + isGuest: true, + }; + + const toastTitle = wantsToRegister ? "Account created" : "Guest checkout"; + const toastMessage = wantsToRegister ? + "Your account has been created successfully" : + "You will continue as a guest"; + + if (wantsToRegister) + registerRequest.password = data.password; + + await register(registerRequest); + + toast({ + title: toastTitle, + description: toastMessage + }); + } + + setCustomerInfo({ + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + shippingAddress: data.shippingAddress, + billingAddress: data.useSameForBilling ? undefined : data.billingAddress!, + }); + + setStep('payment'); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "An error occurred", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Shipping Information + + {state.isGuest + ? "Enter your shipping details" + : "Confirm your shipping details"} + + + +
+ + {/* Personal Information */} +
+

Personal Information

+
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ + ( + + Email + + + + + + )} + /> +
+ + {/* Shipping Address */} +
+

Shipping Address

+ +
+ + {/* Billing Address */} +
+
+

Billing Address

+ ( + + + + + + Same as shipping address + + + )} + /> +
+ + {!useSameForBilling && ( + + )} +
+ + {/* Registration Option for Guests */} + {state.isGuest && ( +
+ ( + + + + +
+ + Create an account for faster checkout + + + Save your information for future purchases + +
+
+ )} + /> + + {wantsToRegister && ( + ( + + Password + + + + + + )} + /> + )} +
+ )} + +
+ + +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(client)/checkout/_components/OrderSummary.tsx b/src/app/(client)/checkout/_components/OrderSummary.tsx new file mode 100644 index 0000000..2533d1e --- /dev/null +++ b/src/app/(client)/checkout/_components/OrderSummary.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card' +import { ScrollArea } from '@/components/ui/scroll-area' +import { useCart } from '@/context/CartContext'; +import Image from 'next/image'; +import React from 'react'; +import placeholderImage from '@/public/placeholder.webp'; +import { Separator } from '@/components/ui/separator'; + +const OrderSummary = () => { + const { cart, totalPrice } = useCart(); + + const formatPrice = (amount: number) => { + return new Intl.NumberFormat('da-DK', { + style: 'currency', + currency: 'DKK', + }).format(amount); + }; + + return ( +
+ + + Order Summary + + {cart.length} {cart.length === 1 ? 'item' : 'items'} in your cart + + + + +
+ {cart.map((item) => ( +
+
+ {item.name} +
+
+

{item.name}

+ {item.selectedVariant && ( +

+ Size: {item.selectedVariant.options[0].value} +

+ )} +
+

+ Qty: {item.quantity} +

+

+ {formatPrice(item.price.amount * item.quantity)} +

+
+
+
+ ))} +
+
+
+ + +
+
+ Subtotal + {formatPrice(totalPrice)} +
+
+ Shipping + Free +
+ +
+ Total + {formatPrice(totalPrice)} +
+
+
+
+
+ ) +} + +export default OrderSummary \ No newline at end of file diff --git a/src/app/(client)/checkout/_components/PaymentStep.tsx b/src/app/(client)/checkout/_components/PaymentStep.tsx new file mode 100644 index 0000000..ef325c1 --- /dev/null +++ b/src/app/(client)/checkout/_components/PaymentStep.tsx @@ -0,0 +1,184 @@ +"use client"; + +import React from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useCart } from '@/context/CartContext'; +import { useCheckout } from '@/context/CheckoutContext'; +import { useAuth } from '@/context/AuthContext'; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; +import { CreditCard, Phone } from "lucide-react"; +import { orderService } from '@/services/orderService'; +import { useToast } from '@/hooks/use-toast'; + +const PAYMENT_METHODS = { + MOBILEPAY: 'WALLET', + CARD: 'CARD' +} as const; + +type PaymentMethod = typeof PAYMENT_METHODS[keyof typeof PAYMENT_METHODS]; + +export function PaymentStep() { + const { toast } = useToast(); + const { cart, clearCart } = useCart(); + const { state } = useCheckout(); + const { user } = useAuth(); + const [isProcessing, setIsProcessing] = React.useState(false); + const [selectedMethod, setSelectedMethod] = React.useState(PAYMENT_METHODS.MOBILEPAY); + + const canPay = React.useMemo(() => { + return user !== null || (state.isGuest && state.customerInfo !== undefined); + }, [user, state.isGuest, state.customerInfo]); + + const handlePayment = async () => { + if (!canPay) { + toast({ + title: "Cannot process payment", + description: "Please complete your information before proceeding", + variant: "destructive", + }); + return; + } + + try { + setIsProcessing(true); + + if (user === null) { + throw new Error("User is not authenticated"); + } + + // Create order lines from cart + const orderLines = cart.map(item => ({ + productId: item.id, + quantity: item.quantity, + ...(item.selectedVariant && { variantId: item.selectedVariant.id }), + })); + + console.log(orderLines); + + // Create order + const orderResponse = await orderService.createOrder(user.id, { + currency: "DKK", + orderLines, + }); + + console.log(orderResponse); + + // Create payment based on selected method + if (selectedMethod === PAYMENT_METHODS.MOBILEPAY) { + const paymentResponse = await orderService.createMobilePayPayment({ + orderId: orderResponse.order.id, + currency: "DKK", + paymentMethod: PAYMENT_METHODS.MOBILEPAY, + returnUrl: `${window.location.origin}/checkout/confirmation`, + }); + + // Clear cart and redirect to MobilePay + clearCart(); + window.location.href = paymentResponse.redirectUrl; + } else { + toast({ + title: "Not implemented", + description: "Card payment is not yet implemented.", + variant: "destructive", + }); + } + } catch (error) { + toast({ + title: "Payment failed", + description: error instanceof Error ? error.message : "Please try again", + variant: "destructive", + }); + } finally { + setIsProcessing(false); + } + }; + + return ( + + + Payment Method + + Choose how you would like to pay + + + + setSelectedMethod(value as PaymentMethod)} + > +
+
+ + +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(client)/checkout/layout.tsx b/src/app/(client)/checkout/layout.tsx new file mode 100644 index 0000000..87ff8cc --- /dev/null +++ b/src/app/(client)/checkout/layout.tsx @@ -0,0 +1,13 @@ +import { CheckoutProvider } from '@/context/CheckoutContext' + +export default function CheckoutLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/app/(client)/checkout/page.tsx b/src/app/(client)/checkout/page.tsx index 1463880..2328dff 100644 --- a/src/app/(client)/checkout/page.tsx +++ b/src/app/(client)/checkout/page.tsx @@ -1,5 +1,5 @@ -import { CheckoutPage } from "./_components/CheckoutPage"; +import { Checkout } from "./_components/CheckoutPage"; -export default function Page() { - return ; +export default function CheckoutPage() { + return ; } \ No newline at end of file diff --git a/src/app/(client)/layout.tsx b/src/app/(client)/layout.tsx index 21532ea..63ccad9 100644 --- a/src/app/(client)/layout.tsx +++ b/src/app/(client)/layout.tsx @@ -27,11 +27,11 @@ export default function RootLayout({
-
{children}
+ ) diff --git a/src/app/(client)/page.tsx b/src/app/(client)/page.tsx index ae698c5..858fb52 100644 --- a/src/app/(client)/page.tsx +++ b/src/app/(client)/page.tsx @@ -1,53 +1,130 @@ "use client"; -import React from 'react'; -import { productApi } from '@/services/productsService'; +import React, { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + AlertCircle, + RefreshCw, + ServerCrash, + Wifi, + WifiOff +} from 'lucide-react'; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; import { Product } from '@/types/product'; -import { PageInfo } from '@/types/pagination'; +import { useToast } from '@/hooks/use-toast'; +import { productApi } from '@/services/productsService'; import ProductCard from '@/components/products/ProductCard'; -const ProductsPage: React.FC = () => { - const [products, setProducts] = React.useState([]); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [pageInfo, setPageInfo] = React.useState(null); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - const fetchProducts = async () => { - setLoading(true); - try { - const response = await productApi.getProducts({ - page: 0, - size: 10, - sort: 'id,desc' - }); - setProducts(response._embedded.productViewModels); - setPageInfo(response.page); - } catch (err) { - if(err instanceof Error) { - setError(err.message); - } - } finally { - setLoading(false); - } - }; +export default function ProductsPage() { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isRetrying, setIsRetrying] = useState(false); + const { toast } = useToast(); + const fetchProducts = async () => { + try { + setError(null); + setIsRetrying(true); + const response = await productApi.getProducts(); + setProducts(response._embedded.productViewModels); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load products'); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load products. Please try again.", + }); + } finally { + setLoading(false); + setIsRetrying(false); + } + }; + useEffect(() => { fetchProducts(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (loading) { return ( -
-
+
+
+
+ +
+

Loading products...

+
); } if (error) { return ( -
-
Error: {error}
+
+
+ + + Error Loading Products + + We couldn't load the products at this time. + + + +
+
+ +
+ +
+

+ Something went wrong +

+

+ We're having trouble connecting to our servers. This might be due to: +

+
+ +
+
+ + Your internet connection +
+
+ + Our servers might be down +
+
+ + A temporary network issue +
+
+ +
+ +
+
+
); } @@ -62,6 +139,4 @@ const ProductsPage: React.FC = () => {
); -}; - -export default ProductsPage; \ No newline at end of file +} \ No newline at end of file diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..e9bde17 --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,44 @@ +"use client" + +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0b4a48d --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..26eb109 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/components/webshop/CartSheet.tsx b/src/components/webshop/CartSheet.tsx index 19ddf57..93eb82a 100644 --- a/src/components/webshop/CartSheet.tsx +++ b/src/components/webshop/CartSheet.tsx @@ -14,8 +14,6 @@ import { } from "@/components/ui/sheet"; import { ShoppingCart, Minus, Plus, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { useAuth } from '@/context/AuthContext'; -import { useToast } from '@/hooks/use-toast'; import { CartItem } from '@/types/product'; import Image from 'next/image'; import placeholderImage from '@/public/placeholder.webp'; @@ -23,8 +21,6 @@ import placeholderImage from '@/public/placeholder.webp'; export const CartSheet = () => { const router = useRouter(); const { cart, totalItems, totalPrice, updateCartItemQuantity, removeFromCart } = useCart(); - const { isAuthenticated } = useAuth(); - const { toast } = useToast(); const sheetCloseRef = React.useRef(null); const formatPrice = (amount: number, currency: string) => { @@ -35,16 +31,16 @@ export const CartSheet = () => { }; const handleCheckout = () => { - if (!isAuthenticated) { - toast({ - title: "Login Required", - description: "Please log in to proceed with checkout", - variant: "destructive", - }); - sheetCloseRef.current?.click(); // Close the cart sheet - router.push('/auth/login'); - return; - } + // if (!isAuthenticated) { + // toast({ + // title: "Login Required", + // description: "Please log in to proceed with checkout", + // variant: "destructive", + // }); + // sheetCloseRef.current?.click(); // Close the cart sheet + // router.push('/auth/login'); + // return; + // } sheetCloseRef.current?.click(); // Close the cart sheet router.push('/checkout'); diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 8cfa202..cc2498f 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -36,38 +36,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, []); - const register = async (data: RegisterRequest, rememberMe = false) => { + const register = async (data: RegisterRequest) => { try { setIsLoading(true); - const response = await authService.register(data, rememberMe); + const response = await authService.register(data); setUser(response.user); - toast({ - title: "Registration successful", - description: "Welcome to our store!", - }); - router.push('/'); } catch (error) { - toast({ - title: "Registration failed", - description: error instanceof Error ? error.message : "Please try again", - variant: "destructive", - }); throw error; } finally { setIsLoading(false); } }; - const login = async (data: LoginRequest, options: LoginOptions = { rememberMe: false }) => { + const login = async (data: LoginRequest) => { try { setIsLoading(true); - const response = await authService.login(data, options); + const response = await authService.login(data); setUser(response.user); - toast({ - title: "Login successful", - description: "Welcome back!", - }); - router.push('/'); } catch (error) { toast({ title: "Login failed", diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx index 96ad434..a617fea 100644 --- a/src/context/CartContext.tsx +++ b/src/context/CartContext.tsx @@ -32,7 +32,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('cart', JSON.stringify(cart)); }, [cart]); - const generateCartItemId = (productId: number, variantSku?: string) => { + const generateCartItemId = (productId: string, variantSku?: string) => { return variantSku ? `${productId}-${variantSku}` : `${productId}`; }; diff --git a/src/context/CheckoutContext.tsx b/src/context/CheckoutContext.tsx new file mode 100644 index 0000000..8ef4d08 --- /dev/null +++ b/src/context/CheckoutContext.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React, { createContext, useContext, useState } from 'react'; +import { CheckoutState, CheckoutStep, CustomerInfo } from '@/types/checkout'; + +interface CheckoutContextType { + state: CheckoutState; + setStep: (step: CheckoutStep) => void; + setIsGuest: (isGuest: boolean) => void; + setCustomerInfo: (info: CustomerInfo) => void; +} + +const CheckoutContext = createContext(undefined); + +export function CheckoutProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ + isGuest: false, + step: 'authentication', + }); + + const setStep = (step: CheckoutStep) => { + setState(prev => ({ ...prev, step })); + }; + + const setIsGuest = (isGuest: boolean) => { + setState(prev => ({ ...prev, isGuest })); + }; + + const setCustomerInfo = (customerInfo: CustomerInfo) => { + setState(prev => ({ ...prev, customerInfo })); + }; + + return ( + + {children} + + ); +} + +export function useCheckout() { + const context = useContext(CheckoutContext); + if (!context) { + throw new Error('useCheckout must be used within a CheckoutProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/services/authService.ts b/src/services/authService.ts index 0102ea3..9a99479 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -2,10 +2,6 @@ import { BaseApiService } from '@/types/apiBase'; import { RegisterRequest, LoginRequest, AuthResponse } from '@/types/auth'; import { authStorage } from './authStorage'; -interface LoginOptions { - rememberMe: boolean; -} - class AuthService extends BaseApiService { private static instance: AuthService; @@ -20,10 +16,16 @@ class AuthService extends BaseApiService { return AuthService.instance; } - async register(data: RegisterRequest, rememberMe = false): Promise { + async register(data: RegisterRequest): Promise { try { const response = await this.post('/signup', data); - authStorage.setToken(response.token, rememberMe); + + console.log(response); + + if (response.token.length >= 0) { + authStorage.setToken(response.token); + } + return response; } catch (error) { if (error instanceof Error) { @@ -33,10 +35,11 @@ class AuthService extends BaseApiService { } } - async login(data: LoginRequest, options: LoginOptions = { rememberMe: false }): Promise { + async login(data: LoginRequest): Promise { try { const response = await this.post('/signin', data); - authStorage.setToken(response.token, options.rememberMe); + + authStorage.setToken(response.token); return response; } catch (error) { if (error instanceof Error) { @@ -47,7 +50,7 @@ class AuthService extends BaseApiService { } logout() { - authStorage.clearToken(); + authStorage.removeToken(); } getToken() { @@ -57,11 +60,6 @@ class AuthService extends BaseApiService { isAuthenticated() { return !!this.getToken(); } - - isRememberMe() { - return authStorage.isRememberMe(); - } } - export const authService = AuthService.getInstance(); \ No newline at end of file diff --git a/src/services/authStorage.ts b/src/services/authStorage.ts index b53977b..1eea36c 100644 --- a/src/services/authStorage.ts +++ b/src/services/authStorage.ts @@ -1,38 +1,26 @@ export class AuthStorage { - private readonly TOKEN_KEY = 'token'; - private readonly REMEMBER_ME_KEY = 'rememberMe'; - - setToken(token: string, rememberMe: boolean) { - const storage = rememberMe ? localStorage : sessionStorage; - storage.setItem(this.TOKEN_KEY, token); - localStorage.setItem(this.REMEMBER_ME_KEY, String(rememberMe)); + private isClient = typeof window !== 'undefined'; + + setToken(token: string) { + if (this.isClient) { + localStorage.setItem('token', token); + } } - + getToken(): string | null { - // First check sessionStorage - const sessionToken = sessionStorage.getItem(this.TOKEN_KEY); - if (sessionToken) { - return sessionToken; - } - - // Then check localStorage if user chose "remember me" - const rememberMe = localStorage.getItem(this.REMEMBER_ME_KEY) === 'true'; - if (rememberMe) { - return localStorage.getItem(this.TOKEN_KEY); - } - - return null; + if (!this.isClient) return null; + return localStorage.getItem('token'); } - - clearToken() { - sessionStorage.removeItem(this.TOKEN_KEY); - localStorage.removeItem(this.TOKEN_KEY); - localStorage.removeItem(this.REMEMBER_ME_KEY); + + removeToken() { + if (this.isClient) { + localStorage.removeItem('token'); + } } - - isRememberMe(): boolean { - return localStorage.getItem(this.REMEMBER_ME_KEY) === 'true'; + + hasToken(): boolean { + return !!this.getToken(); } -} - -export const authStorage = new AuthStorage(); \ No newline at end of file + } + + export const authStorage = new AuthStorage(); \ No newline at end of file diff --git a/src/services/orderService.ts b/src/services/orderService.ts index 0a4ab8b..3bdf59d 100644 --- a/src/services/orderService.ts +++ b/src/services/orderService.ts @@ -20,7 +20,7 @@ class OrderService extends BaseApiService { return OrderService.instance; } - async createOrder(userId: number, orderData: CreateOrderRequest): Promise { + async createOrder(userId: string, orderData: CreateOrderRequest): Promise { return this.post(`/orders/${userId}`, orderData, true); } diff --git a/src/services/productsService.ts b/src/services/productsService.ts index 2ba024c..373c762 100644 --- a/src/services/productsService.ts +++ b/src/services/productsService.ts @@ -17,33 +17,28 @@ class ProductService extends BaseApiService { return ProductService.instance; } - // Public endpoint - no auth required async getProducts(params?: PaginationParams): Promise { return this.fetchWithPagination( '/products/active', this.EMBEDDED_KEY, params, - false // doesn't require auth + false ); } - // Public endpoint - no auth required - async getProductById(id: number): Promise { + async getProductById(id: string): Promise { return this.get(`/products/${id}`, false); } - // Protected endpoint - requires auth async createProduct(product: CreateProductRequest): Promise { return this.post('/products', product, true); } - // Protected endpoint - requires auth - async updateProduct(id: number, product: Partial): Promise { + async updateProduct(id: string, product: Partial): Promise { return this.put(`/products/${id}`, product, true); } - // Protected endpoint - requires auth - async deleteProduct(id: number): Promise { + async deleteProduct(id: string): Promise { return this.delete(`/products/${id}`, true); } } diff --git a/src/types/apiBase.ts b/src/types/apiBase.ts index fad6fb5..e226168 100644 --- a/src/types/apiBase.ts +++ b/src/types/apiBase.ts @@ -7,10 +7,12 @@ export class ApiError extends Error { } } -export const getAuthHeader = () => ({ - Authorization: `Bearer ${localStorage.getItem('token')}` -}); +export const getAuthHeader = () => { + if (typeof window === 'undefined') return undefined; + const token = localStorage.getItem('token'); + return token ? { Authorization: `Bearer ${token}` } : undefined; +}; interface RequestOptions { requiresAuth?: boolean; includeContentType?: boolean; diff --git a/src/types/auth.ts b/src/types/auth.ts index 25917fa..460af3a 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -11,6 +11,7 @@ export interface RegisterRequest { password?: string; firstName: string; lastName: string; + isGuest?: boolean; shippingAddress?: Address; billingAddress?: Address; } @@ -23,7 +24,7 @@ export interface LoginRequest { export interface AuthResponse { token: string; user: { - id: number; + id: string; email: string; firstName: string; lastName: string; diff --git a/src/types/checkout.ts b/src/types/checkout.ts new file mode 100644 index 0000000..ca607e7 --- /dev/null +++ b/src/types/checkout.ts @@ -0,0 +1,17 @@ +import { Address } from "./auth"; + +export type CheckoutStep = 'authentication' | 'information' | 'payment'; + +export interface CustomerInfo { + firstName: string; + lastName: string; + email: string; + shippingAddress: Address; + billingAddress?: Address; +} + +export interface CheckoutState { + isGuest: boolean; + customerInfo?: CustomerInfo; + step: CheckoutStep; +} \ No newline at end of file diff --git a/src/types/order.ts b/src/types/order.ts index adfb5c2..127db24 100644 --- a/src/types/order.ts +++ b/src/types/order.ts @@ -1,7 +1,7 @@ export interface OrderLine { - productId: number; + productId: string; quantity: number; - variantId?: number; + variantId?: string; } export interface CreateOrderRequest { @@ -10,8 +10,8 @@ export interface CreateOrderRequest { } export interface Order { - id: number; - userId: number; + id: string; + userId: string; totalPrice: number; currency: string; orderStatus: string; @@ -31,7 +31,7 @@ export interface CreatePaymentRequest { } export interface CreatePaymentResponse { - paymentId: number; + paymentId: string; status: string; redirectUrl: string; } \ No newline at end of file diff --git a/src/types/product.ts b/src/types/product.ts index 618e15b..3e4f6f7 100644 --- a/src/types/product.ts +++ b/src/types/product.ts @@ -6,7 +6,7 @@ export interface ProductOption { } export interface ProductVariant { - id: number; + id: string; sku: string; unitPrice?: number; options: ProductOption[]; @@ -18,7 +18,7 @@ export interface ProductPrice { } export interface Product { - id: number; + id: string; name: string; description?: string; price: ProductPrice; @@ -32,7 +32,7 @@ export interface Product { export interface CartItem { cartItemId: string; // Unique identifier for cart item - id: number; // Product ID + id: string; // Product ID name: string; price: ProductPrice; imageUrl?: string;