From e03f157b92717fc4d7bc224245a132b9655764fb Mon Sep 17 00:00:00 2001 From: Wajahat Islam Gul Date: Thu, 25 Sep 2025 10:58:44 +0500 Subject: [PATCH 1/3] feat: enhance seller management features - Add functionality to update shop cover banner and shop information. - Implement logout feature for sellers with confirmation dialog. - Introduce new routes for shop management and notifications. - Update cookie settings for longer session duration. - Refactor login and signup pages for improved user experience. - Fix refresh token handling for sellers and update related API calls. - Add new components for shop settings and cover banner upload. --- .../src/controllers/auth.controller.ts | 313 ++++++++++- apps/auth-service/src/routes/auth.router.ts | 14 + .../src/utils/cookies/setCookie.ts | 2 +- apps/order-service/src/utils/order.utils.ts | 1 + .../app/(routes)/dashboard/logout/page.tsx | 78 +++ .../(routes)/dashboard/notifications/page.tsx | 74 +++ .../app/(routes)/dashboard/settings/page.tsx | 34 ++ .../seller-ui/src/app/(routes)/login/page.tsx | 236 +++++--- .../src/app/(routes)/signup/page.tsx | 508 +++++++++++------- apps/seller-ui/src/app/page.tsx | 136 ++++- .../seller-ui/src/app/seller/success/page.tsx | 41 +- apps/seller-ui/src/hooks/useLogout.ts | 30 ++ apps/seller-ui/src/hooks/useShop.tsx | 94 ++++ .../molecules/shop/CoverBannerUpload.tsx | 216 ++++++++ .../shared/molecules/shop/ShopInfoForm.tsx | 231 ++++++++ .../src/shared/molecules/shop/index.ts | 2 + .../shared/organisms/home/SellerHomeHero.tsx | 4 +- apps/seller-ui/src/shared/organisms/index.ts | 1 + .../shared/organisms/shop/ShopSettings.tsx | 83 +++ .../src/shared/organisms/shop/index.ts | 1 + apps/seller-ui/src/utils/axiosIsntance.tsx | 6 +- apps/user-ui/src/app/(routes)/login/page.tsx | 248 +++++---- .../src/app/(routes)/payment-success/page.tsx | 2 +- .../src/app/(routes)/shop/[shopId]/page.tsx | 58 ++ apps/user-ui/src/app/(routes)/signup/page.tsx | 284 ++++++---- apps/user-ui/src/configs/constants.ts | 2 +- .../src/shared/components/organisms/index.tsx | 1 + .../components/organisms/shop-details.tsx | 285 ++++++++++ .../shared/widgets/header/header-bottom.tsx | 31 +- prisma/schema.prisma | 6 +- 30 files changed, 2482 insertions(+), 540 deletions(-) create mode 100644 apps/seller-ui/src/app/(routes)/dashboard/logout/page.tsx create mode 100644 apps/seller-ui/src/app/(routes)/dashboard/notifications/page.tsx create mode 100644 apps/seller-ui/src/app/(routes)/dashboard/settings/page.tsx create mode 100644 apps/seller-ui/src/hooks/useLogout.ts create mode 100644 apps/seller-ui/src/hooks/useShop.tsx create mode 100644 apps/seller-ui/src/shared/molecules/shop/CoverBannerUpload.tsx create mode 100644 apps/seller-ui/src/shared/molecules/shop/ShopInfoForm.tsx create mode 100644 apps/seller-ui/src/shared/molecules/shop/index.ts create mode 100644 apps/seller-ui/src/shared/organisms/shop/ShopSettings.tsx create mode 100644 apps/seller-ui/src/shared/organisms/shop/index.ts create mode 100644 apps/user-ui/src/app/(routes)/shop/[shopId]/page.tsx create mode 100644 apps/user-ui/src/shared/components/organisms/shop-details.tsx diff --git a/apps/auth-service/src/controllers/auth.controller.ts b/apps/auth-service/src/controllers/auth.controller.ts index 241cbf0..333c957 100644 --- a/apps/auth-service/src/controllers/auth.controller.ts +++ b/apps/auth-service/src/controllers/auth.controller.ts @@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from 'express'; import { AuthError, ValidationError } from '@packages/error-handler'; import prisma from '@packages/libs/prisma'; +import imageKit from '@packages/libs/imageKit'; import { checkOTPRestrictions, handleForgotPassword, @@ -134,10 +135,7 @@ export const UserLogin = async (request: Request, response: Response, next: Next //Refresh Token User export const RefreshToken = async (request: any, response: Response, next: NextFunction) => { try { - const refreshToken = - request.cookies['refresh_token'] || - request.cookies['seller_refresh_token'] || - request.headers.authorization?.split(' ')[1]; + const refreshToken = request.cookies['refresh_token'] || request.headers.authorization?.split(' ')[1]; if (!refreshToken) { throw new ValidationError('Unauthorized! No refresh token'); @@ -148,16 +146,11 @@ export const RefreshToken = async (request: any, response: Response, next: NextF role: string; }; - if (!decoded || !decoded.id || !decoded.role) { + if (!decoded || !decoded.id || !decoded.role || decoded.role !== 'user') { return new JsonWebTokenError('Forbidden! Invalid refresh token'); } - let account; - if (decoded.role === 'user') { - account = await prisma.users.findUnique({ where: { id: decoded.id } }); - } else if (decoded.role === 'seller') { - account = await prisma.sellers.findUnique({ where: { id: decoded.id }, include: { shop: true } }); - } + const account = await prisma.users.findUnique({ where: { id: decoded.id } }); if (!account) { return new AuthError('User not found!'); @@ -174,15 +167,55 @@ export const RefreshToken = async (request: any, response: Response, next: NextF }, ); - if (decoded.role === 'user') { - setCookie(response, 'access_token', accessToken); - } else if (decoded.role === 'seller') { - setCookie(response, 'seller_access_token', accessToken); + setCookie(response, 'access_token', accessToken); + setCookie(response, 'refresh_token', refreshToken); + + request.role = decoded.role; + response.status(201).json({ success: true }); + } catch (error) { + return next(error); + } +}; + +//Refresh Token Seller +export const RefreshSellerToken = async (request: any, response: Response, next: NextFunction) => { + try { + const refreshToken = request.cookies['seller_refresh_token'] || request.headers.authorization?.split(' ')[1]; + + if (!refreshToken) { + throw new ValidationError('Unauthorized! No refresh token'); + } + + const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET as string) as { + id: string; + role: string; + }; + + if (!decoded || !decoded.id || !decoded.role || decoded.role !== 'seller') { + return new JsonWebTokenError('Forbidden! Invalid refresh token'); + } + + const account = await prisma.sellers.findUnique({ where: { id: decoded.id }, include: { shop: true } }); + + if (!account) { + return new AuthError('Seller not found!'); } + const accessToken = jwt.sign( + { + id: decoded.id, + role: decoded.role, + }, + process.env.ACCESS_TOKEN_SECRET as string, + { + expiresIn: '72h', + }, + ); + + setCookie(response, 'seller_access_token', accessToken); + setCookie(response, 'seller_refresh_token', refreshToken); + request.role = decoded.role; - // store the tokens in httpOnly secure cookie - setCookie(response, 'refresh_token', refreshToken); response.status(201).json({ success: true }); } catch (error) { return next(error); @@ -291,7 +324,14 @@ export const verifySeller = async (request: Request, response: Response, next: N await verifyOTP(email, otp, next); const hashedPassword = await bcrypt.hash(password, 10); const seller = await prisma.sellers.create({ - data: { name, email, password: hashedPassword, phone_number, country }, + data: { + name, + email, + password: hashedPassword, + phone_number, + country, + shopId: null, // Explicitly set to null initially + }, }); response.status(200).json({ @@ -334,7 +374,7 @@ export const sellerLogin = async (request: Request, response: Response, next: Ne role: 'seller', }, process.env.ACCESS_TOKEN_SECRET as string, - { expiresIn: '24h' }, + { expiresIn: '72h' }, ); const refreshToken = jwt.sign( @@ -343,7 +383,7 @@ export const sellerLogin = async (request: Request, response: Response, next: Ne role: 'seller', }, process.env.REFRESH_TOKEN_SECRET as string, - { expiresIn: '7d' }, + { expiresIn: '30d' }, ); //Because we set same cookies for user and seller, only one type of account can be logged in at a time. @@ -402,6 +442,12 @@ export const createNewShop = async (request: Request, response: Response, next: data: shopData, }); + // Update the seller's shopId to link them + await prisma.sellers.update({ + where: { id: sellerId }, + data: { shopId: shop.id }, + }); + response.status(200).json({ success: true, shop, @@ -429,6 +475,24 @@ export const logoutUser = async (request: any, response: Response, next: NextFun } }; +//Logout seller +export const logoutSeller = async (request: any, response: Response, next: NextFunction) => { + try { + // Clear all authentication cookies + response.clearCookie('access_token'); + response.clearCookie('refresh_token'); + response.clearCookie('seller_access_token'); + response.clearCookie('seller_refresh_token'); + + response.status(200).json({ + success: true, + message: 'Seller logged out successfully!', + }); + } catch (error) { + return next(error); + } +}; + //Get user addresses export const getUserAddresses = async (request: any, response: Response, next: NextFunction) => { try { @@ -659,6 +723,211 @@ export const changeUserPassword = async (request: any, response: Response, next: } }; +//Get shop information +export const getShopInfo = async (request: any, response: Response, next: NextFunction) => { + try { + const sellerId = request.seller.id; + + const shop = await prisma.shops.findUnique({ + where: { sellerId }, + include: { + avatar: true, + productAnalytics: true, + }, + }); + + if (!shop) { + return next(new ValidationError('Shop not found!')); + } + + response.status(200).json({ + success: true, + shop, + }); + } catch (error) { + return next(error); + } +}; + +//Get shop information by id for public access +export const getShopInfoById = async (request: Request, response: Response, next: NextFunction) => { + try { + const { shopId } = request.params; + + if (!shopId) { + return next(new ValidationError('Shop ID is required!')); + } + + const shop = await prisma.shops.findUnique({ + where: { id: shopId }, + include: { + avatar: true, + productAnalytics: true, + products: { + where: { + isDeleted: false, + status: 'ACTIVE', + }, + include: { + images: true, + shop: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + reviews: { + include: { + user: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + sellers: { + select: { + id: true, + name: true, + email: true, + country: true, + phone_number: true, + }, + }, + }, + }); + + if (!shop) { + return next(new ValidationError('Shop not found!')); + } + + const totalProducts = shop.products?.length || 0; + const totalReviews = shop.reviews?.length || 0; + const averageRating = + totalReviews > 0 + ? shop.reviews.reduce((sum, review) => sum + (review.rating || 0), 0) / totalReviews + : shop.ratings || 0; + + return response.status(200).json({ + success: true, + shop: { + ...shop, + totalProducts, + totalReviews, + averageRating, + }, + }); + } catch (error) { + return next(error); + } +}; + +//Update shop information +export const updateShopInfo = async (request: any, response: Response, next: NextFunction) => { + try { + const sellerId = request.seller.id; + const { name, bio, address, opening_hours, website, category, social_links } = request.body; + + // Validate required fields + if (!name || !bio || !address || !opening_hours || !category) { + return next(new ValidationError('Missing required fields!')); + } + + const shop = await prisma.shops.findUnique({ + where: { sellerId }, + }); + + if (!shop) { + return next(new ValidationError('Shop not found!')); + } + + const updateData: any = { + name, + bio, + address, + opening_hours, + category, + }; + + if (website && website.trim()) { + updateData.website = website; + } + + if (social_links && Array.isArray(social_links)) { + updateData.social_links = social_links; + } + + const updatedShop = await prisma.shops.update({ + where: { sellerId }, + data: updateData, + include: { + avatar: true, + productAnalytics: true, + }, + }); + + response.status(200).json({ + success: true, + message: 'Shop information updated successfully!', + shop: updatedShop, + }); + } catch (error) { + return next(error); + } +}; + +//Update shop cover banner +export const updateShopCoverBanner = async (request: any, response: Response, next: NextFunction) => { + try { + const sellerId = request.seller.id; + const { coverBanner } = request.body; + + if (!coverBanner || typeof coverBanner !== 'string') { + return next(new ValidationError('Cover banner file is required!')); + } + + const shop = await prisma.shops.findUnique({ + where: { sellerId }, + }); + + if (!shop) { + return next(new ValidationError('Shop not found!')); + } + + const uploadResponse = await imageKit.upload({ + file: coverBanner, + fileName: `shop-cover-${sellerId}-${Date.now()}.jpg`, + folder: '/shops/cover-banners', + }); + + const updatedShop = await prisma.shops.update({ + where: { sellerId }, + data: { coverBanner: uploadResponse.url }, + include: { + avatar: true, + productAnalytics: true, + }, + }); + + response.status(200).json({ + success: true, + message: 'Cover banner updated successfully!', + shop: updatedShop, + }); + } catch (error) { + return next(error); + } +}; + //Create stripe connect account link export const createStripeConnectLink = async (request: Request, response: Response, next: NextFunction) => { try { @@ -708,8 +977,8 @@ export const createStripeConnectLink = async (request: Request, response: Respon const accountLink = await stripe.accountLinks.create({ account: account.id, - refresh_url: `http://localhost:3000/seller/success`, - return_url: `http://localhost:3000/seller/success`, + refresh_url: `https://nextcart-seller.vercel.app/seller/success`, + return_url: `https://nextcart-seller.vercel.app/seller/success`, type: 'account_onboarding', }); diff --git a/apps/auth-service/src/routes/auth.router.ts b/apps/auth-service/src/routes/auth.router.ts index 79232cb..dfc3c82 100644 --- a/apps/auth-service/src/routes/auth.router.ts +++ b/apps/auth-service/src/routes/auth.router.ts @@ -7,16 +7,22 @@ import { createStripeConnectLink, deleteUserAddress, getSeller, + getShopInfo, + getShopInfoById, getUser, getUserAddresses, logoutUser, + logoutSeller, sellerLogin, SellerRegistration, setDefaultAddress, + updateShopCoverBanner, + updateShopInfo, updateUserAddress, UserForgotPassword, UserLogin, RefreshToken, + RefreshSellerToken, UserRegistration, UserResetPassword, UserVerification, @@ -32,6 +38,7 @@ router.post('/user-registration', UserRegistration); router.post('/verify-user', UserVerification); router.post('/login-user', UserLogin); router.post('/refresh-token', RefreshToken); +router.post('/refresh-seller-token', RefreshSellerToken); router.get('/logged-in-user', isAuthenticated, getUser); router.post('/forgot-password-user', UserForgotPassword); router.post('/verify-forgot-password-otp', UserVerifyForgotPasswordOTP); @@ -43,6 +50,7 @@ router.post('/login-seller', sellerLogin); router.get('/logged-in-seller', isAuthenticated, isSeller, getSeller); router.post('/create-stripe-link', createStripeConnectLink); router.post('/logout', logoutUser); +router.post('/logout-seller', logoutSeller); // User Address Management router.get('/user-addresses', isAuthenticated, getUserAddresses); @@ -54,4 +62,10 @@ router.patch('/user-addresses/:id/set-default', isAuthenticated, setDefaultAddre // User Password Management router.put('/change-password', isAuthenticated, changeUserPassword); +// Shop Management +router.get('/shop-info/:shopId', getShopInfoById); +router.get('/shop-info', isAuthenticated, isSeller, getShopInfo); +router.put('/shop-info', isAuthenticated, isSeller, updateShopInfo); +router.put('/shop-cover-banner', isAuthenticated, isSeller, updateShopCoverBanner); + export default router; diff --git a/apps/auth-service/src/utils/cookies/setCookie.ts b/apps/auth-service/src/utils/cookies/setCookie.ts index fbf027c..0fb1d71 100644 --- a/apps/auth-service/src/utils/cookies/setCookie.ts +++ b/apps/auth-service/src/utils/cookies/setCookie.ts @@ -5,6 +5,6 @@ export const setCookie = (response: Response, name: string, value: string) => { httpOnly: true, secure: true, sameSite: 'none', - maxAge: 7 * 24 * 60 * 60 * 1000, //7 days + maxAge: 30 * 24 * 60 * 60 * 1000, //30 days }); }; diff --git a/apps/order-service/src/utils/order.utils.ts b/apps/order-service/src/utils/order.utils.ts index 72790db..4cc724e 100644 --- a/apps/order-service/src/utils/order.utils.ts +++ b/apps/order-service/src/utils/order.utils.ts @@ -149,6 +149,7 @@ export const sendOrderConfirmationEmail = async ( name, cart, totalAmount, + sessionId, trackingUrl: `https://nextcart.com/order/${sessionId}`, }); }; diff --git a/apps/seller-ui/src/app/(routes)/dashboard/logout/page.tsx b/apps/seller-ui/src/app/(routes)/dashboard/logout/page.tsx new file mode 100644 index 0000000..09e585c --- /dev/null +++ b/apps/seller-ui/src/app/(routes)/dashboard/logout/page.tsx @@ -0,0 +1,78 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import useLogout from '../../../../hooks/useLogout'; +import { LogOut, AlertTriangle } from 'lucide-react'; + +const LogoutPage = () => { + const [showConfirmation, setShowConfirmation] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + const { logout } = useLogout(); + const router = useRouter(); + + useEffect(() => { + // Show confirmation dialog immediately when page loads + setShowConfirmation(true); + }, []); + + const handleLogoutConfirm = async () => { + setIsLoggingOut(true); + await logout(); + }; + + const handleCancel = () => { + // Go back to dashboard + router.push('/'); + }; + + if (!showConfirmation) { + return null; + } + + return ( +
+
+
+
+ +
+

Confirm Logout

+
+ +

+ Are you sure you want to logout? You will need to login again to access your seller account. +

+ +
+ + +
+
+
+ ); +}; + +export default LogoutPage; diff --git a/apps/seller-ui/src/app/(routes)/dashboard/notifications/page.tsx b/apps/seller-ui/src/app/(routes)/dashboard/notifications/page.tsx new file mode 100644 index 0000000..ed63cd3 --- /dev/null +++ b/apps/seller-ui/src/app/(routes)/dashboard/notifications/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React from 'react'; +import { Bell, BellRing, ArrowLeft, Mail, MessageSquare, ShoppingCart } from 'lucide-react'; +import Link from 'next/link'; + +const NotificationsPage = () => { + return ( +
+
+ {/* Header */} +
+ + + Back to Dashboard + +

Notifications

+

Stay updated with your shop activity and customer interactions

+
+ + {/* Under Construction Card */} +
+
+
+ +
+
+ +

Notifications Coming Soon

+ +

+ We're building a comprehensive notification system to keep you informed about orders, customer messages, and + important shop updates. +

+ +
+

What's Coming:

+
+
+ + New order notifications +
+
+ + Customer messages +
+
+ + Email notifications +
+
+ + Push notifications +
+
+
+ + + + Return to Dashboard + +
+
+
+ ); +}; + +export default NotificationsPage; diff --git a/apps/seller-ui/src/app/(routes)/dashboard/settings/page.tsx b/apps/seller-ui/src/app/(routes)/dashboard/settings/page.tsx new file mode 100644 index 0000000..7845895 --- /dev/null +++ b/apps/seller-ui/src/app/(routes)/dashboard/settings/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import React from 'react'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import ShopSettings from '../../../../shared/organisms/shop/ShopSettings'; + +const SettingsPage = () => { + return ( +
+
+ {/* Header */} +
+ + + Back to Dashboard + +

Settings

+

Manage your account and shop preferences

+
+ + {/* Shop Settings */} +
+ +
+
+
+ ); +}; + +export default SettingsPage; diff --git a/apps/seller-ui/src/app/(routes)/login/page.tsx b/apps/seller-ui/src/app/(routes)/login/page.tsx index 39a497e..81827ba 100644 --- a/apps/seller-ui/src/app/(routes)/login/page.tsx +++ b/apps/seller-ui/src/app/(routes)/login/page.tsx @@ -1,8 +1,9 @@ 'use client'; import { useRouter } from 'next/navigation'; -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import Link from 'next/link'; +import { twMerge } from 'tailwind-merge'; import { Eye, EyeOff } from 'lucide-react'; import { useMutation } from '@tanstack/react-query'; @@ -14,10 +15,12 @@ type FormData = { email: string; password: string; }; -const page = () => { +const LoginPage = () => { const [passwordVisible, setPasswordVisible] = useState(false); const [serverError, setServerError] = useState(null); const [rememberMe, setRememberMe] = useState(false); + const [isReady, setIsReady] = useState(false); + const [transitionState, setTransitionState] = useState<'idle' | 'toSignup'>('idle'); const router = useRouter(); const queryClient = useQueryClient(); @@ -27,6 +30,11 @@ const page = () => { formState: { errors }, } = useForm(); + useEffect(() => { + const frame = requestAnimationFrame(() => setIsReady(true)); + return () => cancelAnimationFrame(frame); + }, []); + const loginMutation = useMutation({ mutationFn: async (data: FormData) => { const response = await axios.post(`${process.env.NEXT_PUBLIC_SERVER_URI}/api/login-seller`, data, { @@ -49,104 +57,164 @@ const page = () => { const onSubmit = (data: FormData) => { loginMutation.mutate(data); }; + const handleSwitch = () => { + if (transitionState !== 'idle') return; + setTransitionState('toSignup'); + setTimeout(() => { + router.push('/signup'); + }, 380); + }; + + const cardClass = useMemo( + () => + twMerge( + 'relative w-full max-w-5xl overflow-hidden rounded-[32px] border border-border/60 bg-card text-foreground shadow-[0_40px_80px_-40px_rgba(15,23,42,0.45)] transition-all duration-500 ease-[cubic-bezier(0.68,-0.55,0.265,1.35)] dark:shadow-[0_40px_80px_-40px_rgba(2,6,23,0.65)] will-change-transform', + !isReady && 'translate-y-10 opacity-0', + isReady && transitionState === 'idle' && 'translate-y-0 opacity-100', + transitionState === 'toSignup' && '-translate-x-[18%] rotate-1 scale-95 opacity-0', + ), + [isReady, transitionState], + ); + + const inputClasses = + 'mt-2 w-full rounded-xl border border-border/70 bg-background px-4 py-3 text-sm font-medium text-foreground shadow-sm transition-shadow focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/70'; + return ( -
-

Login

-

Home . Login

-
-
-

Login to NextCart

-

- Don't have an account?{' '} - - Sign up - -

- -
-
- Or Sign in with email -
-
- -
-
- - - {errors.email && {String(errors.email.message)}} +
+
+
+ + +
+
+

Welcome back

+

Login to NextCart

+

+ We're glad to see you again. Enter your details to access your seller hub. +

+
+ + +
+ setRememberMe(e.target.checked)} - className="mr-2" + type="email" + id="email" + {...register('email', { + required: 'Email is required.', + pattern: { + value: EMAIL_REGEX, + message: 'Invalid Email', + }, + })} + className={inputClasses} /> -
+ +
+ +
+ + +
+ {errors.password && ( + + {String(errors.password.message)} + + )} +
+ +
+ + + Forgot password? +
- - Forgot Password? - + + + {serverError &&

{serverError}

} + + +
+

Don't have an account?

+

Join the marketplace — where vinyl meets value and discovery.

+
- - {serverError &&

{serverError}

} - +
); }; -export default page; +export default LoginPage; diff --git a/apps/seller-ui/src/app/(routes)/signup/page.tsx b/apps/seller-ui/src/app/(routes)/signup/page.tsx index ce69752..c410f62 100644 --- a/apps/seller-ui/src/app/(routes)/signup/page.tsx +++ b/apps/seller-ui/src/app/(routes)/signup/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; -import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { twMerge } from 'tailwind-merge'; import { EMAIL_REGEX, PHONE_REGEX } from 'apps/seller-ui/src/configs/constants'; import { Eye, EyeOff } from 'lucide-react'; @@ -16,15 +17,13 @@ type FormData = { name: string; email: string; password: string; + phone_number: string; + country: string; }; -type UserData = { - name: string; - email: string; - password: string; -}; +type UserData = FormData; -const page = () => { +const SignupPage = () => { const [activeStep, setActiveStep] = useState(1); const [passwordVisible, setPasswordVisible] = useState(false); const [serverError, setServerError] = useState(null); @@ -32,13 +31,22 @@ const page = () => { const [canResend, setCanResend] = useState(false); const [timer, setTimer] = useState(0); const [sellerData, setSellerData] = useState(null); - const [sellerId, setSellerId] = useState(); + const [sellerId, setSellerId] = useState(); + const [isReady, setIsReady] = useState(false); + const [transitionState, setTransitionState] = useState<'idle' | 'toLogin'>('idle'); + + const router = useRouter(); const { register, handleSubmit, formState: { errors }, - } = useForm(); + } = useForm(); + + useEffect(() => { + const frame = requestAnimationFrame(() => setIsReady(true)); + return () => cancelAnimationFrame(frame); + }, []); const signUpMutation = useMutation({ mutationFn: async (data: FormData) => { @@ -69,15 +77,24 @@ const page = () => { }, onSuccess: (data) => { + setServerError(null); setSellerId(data?.seller?.id); setActiveStep(2); }, + + onError: (error: AxiosError) => { + console.error('OTP Verification Error:', error); + const errorMessage = + (error.response?.data as any)?.message || error.message || 'Something went wrong. Please try again.'; + setServerError(errorMessage); + }, }); - const onSubmit = (data: any) => { + const onSubmit = (data: FormData) => { signUpMutation.mutate(data); }; const handleOtpComplete = (otp: string) => { + setServerError(null); // Clear any previous errors verifyOtpMutation.mutate(otp); }; @@ -87,196 +104,305 @@ const page = () => { } }; + const handleSwitch = () => { + if (transitionState !== 'idle') return; + setTransitionState('toLogin'); + setTimeout(() => { + router.push('/login'); + }, 380); + }; + + const steps = [ + { id: 1, label: 'Create Account' }, + { id: 2, label: 'Setup Shop' }, + { id: 3, label: 'Connect Bank' }, + ]; + + const cardClass = useMemo( + () => + twMerge( + 'relative w-full max-w-5xl overflow-hidden rounded-[32px] border border-border/60 bg-card text-foreground shadow-[0_40px_90px_-45px_rgba(15,23,42,0.55)] transition-all duration-500 ease-[cubic-bezier(0.68,-0.55,0.265,1.35)] dark:shadow-[0_40px_90px_-45px_rgba(2,6,23,0.65)] will-change-transform', + !isReady && 'translate-y-10 opacity-0', + isReady && transitionState === 'idle' && 'translate-y-0 opacity-100', + transitionState === 'toLogin' && 'translate-x-[18%] -rotate-1 scale-95 opacity-0', + ), + [isReady, transitionState], + ); + + const inputClasses = + 'mt-2 w-full rounded-xl border border-border/70 bg-background px-4 py-3 text-sm font-medium text-foreground shadow-sm transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/70'; + + const selectClasses = + 'mt-2 w-full rounded-xl border border-border/70 bg-background px-4 py-3 text-sm font-medium text-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/70'; + return ( -
- {/**Stepper */} -
-
- {[1, 2, 3].map((step) => { - return ( -
-
- {step} -
+
+
+
+
+
+

Create your shop

+

Become a NextCart Seller

+

+ Complete the steps below to open your storefront and start selling. +

+
- - {step === 1 ? 'Create Account' : step === 2 ? 'Setup Shop' : 'Connect bank'} - +
+
+ {steps.map((step, index) => { + const isCompleted = step.id < activeStep; + const isActive = step.id === activeStep; + return ( +
+
+ {step.id} +
+

{step.label}

+ {index < steps.length - 1 && ( +
+ )} +
+ ); + })}
- ); - })} -
- {/**Steps Content */} -
- {activeStep === 1 && ( - <> - {showOtpInput ? ( -
-

- A verification code has been sent to {sellerData?.email}. -

- - -
- ) : ( -
-

Create Account

-
- - - {errors.name && {String(errors.name.message)}} -
-
- - - {errors.email && {String(errors.email.message)}} -
-
- - - {errors.phone_number && ( - {String(errors.phone_number.message)} +
+ {activeStep === 1 && ( + <> + {showOtpInput ? ( +
+

+ A verification code has been sent to{' '} + {sellerData?.email}. +

+ + +
+ ) : ( + +
+ + + {errors.name && ( + + {String(errors.name.message)} + + )} +
+ +
+ + + {errors.email && ( + + {String(errors.email.message)} + + )} +
+ +
+ + + {errors.phone_number && ( + + {String(errors.phone_number.message)} + + )} +
+ +
+ + + {errors.country && ( + + {String(errors.country.message)} + + )} +
+ +
+ +
+ + +
+ {errors.password && ( + + {String(errors.password.message)} + + )} +
+ + + {serverError && ( +

{serverError}

+ )} + )} + + )} + + {activeStep === 2 && sellerId ? ( +
+
-
- - - - {errors.country && {String(errors.country.message)}} -
-
- - - - {errors.password && {String(errors.password.message)}} + ) : null} + {activeStep === 3 && sellerId ? ( +
+
- - {serverError &&

{serverError}

} - -

- Already have an account?{' '} - - Login - -

- - )} - - )} + ) : null} +
+ +
+

Already have an account?

+

+ Log in to manage your profile, view orders, and shop easily. +

+ +
+
- {activeStep === 2 && sellerId && } - {activeStep === 3 && sellerId && } + +
); }; -export default page; +export default SignupPage; diff --git a/apps/seller-ui/src/app/page.tsx b/apps/seller-ui/src/app/page.tsx index db5997b..4ab6af1 100644 --- a/apps/seller-ui/src/app/page.tsx +++ b/apps/seller-ui/src/app/page.tsx @@ -31,20 +31,132 @@ const HomePage = () => { type HomeProduct = { id: string; title: string; starting_date?: string | null; ending_date?: string | null }; const events = useMemo(() => (products as HomeProduct[]).filter((p) => p.starting_date), [products]); - // Unauthenticated friendly page - if (!seller && !sellerLoading) { + // Show loading state while checking authentication + if (sellerLoading) { return (
-
-

Welcome to Seller Center

-

Sign in to view your shop overview, products, orders, and more.

-
- - Login - +
+
+
+

Loading...

+
+
+
+ ); + } + + // Unauthenticated friendly page + if (!seller) { + return ( +
+
+ {/* Hero Section */} +
+
+ S +
+

+ Welcome to{' '} + + Seller Center + +

+

+ Your gateway to building and growing your online business. Manage products, track orders, and connect with + customers all in one place. +

+
+ + {/* Features Grid */} +
+
+
+ + + +
+

Product Management

+

+ Easily add, edit, and organize your product catalog with powerful tools. +

+
+ +
+
+ + + +
+

Analytics & Insights

+

+ Track your sales performance and understand your customers better. +

+
+ +
+
+ + + +
+

Customer Support

+

Connect with customers through our integrated messaging system.

+
+
+ + {/* CTA Section */} +
+
+

Ready to start selling?

+

+ Join thousands of successful sellers who are already growing their business with NextCart. +

+
+ + + + + Sign In to Your Account + + + + + + Create New Account + +
+
diff --git a/apps/seller-ui/src/app/seller/success/page.tsx b/apps/seller-ui/src/app/seller/success/page.tsx index 874b7e6..bf1c074 100644 --- a/apps/seller-ui/src/app/seller/success/page.tsx +++ b/apps/seller-ui/src/app/seller/success/page.tsx @@ -1,18 +1,39 @@ import Link from 'next/link'; import React from 'react'; +import { CheckCircle } from 'lucide-react'; const page = () => { return ( -
-

- You have successfully connected your bank account. And crated Your account. -

- - Go to Dashboard - +
+
+ {/* Success Icon */} +
+
+ +
+
+ + {/* Success Message */} +

Account Setup Complete!

+ +

+ You have successfully connected your bank account and created your seller account. You're now ready to start + selling on NextCart! +

+ + {/* Action Button */} + + Go to Dashboard + + + {/* Additional Info */} +

+ You can start by creating your first product or setting up your shop details. +

+
); }; diff --git a/apps/seller-ui/src/hooks/useLogout.ts b/apps/seller-ui/src/hooks/useLogout.ts new file mode 100644 index 0000000..3cbf323 --- /dev/null +++ b/apps/seller-ui/src/hooks/useLogout.ts @@ -0,0 +1,30 @@ +import { useRouter } from 'next/navigation'; +import axiosInstance from '../utils/axiosIsntance'; + +const useLogout = () => { + const router = useRouter(); + + const logout = async () => { + try { + // Call backend logout endpoint to clear cookies + await axiosInstance.post('/api/logout-seller'); + + // Clear any session storage if needed + sessionStorage.clear(); + + // Redirect to login page + router.push('/login'); + router.refresh(); + } catch (error) { + console.error('Logout failed:', error); + // Even if logout fails, redirect to login page + // Backend will handle clearing cookies + router.push('/login'); + router.refresh(); + } + }; + + return { logout }; +}; + +export default useLogout; diff --git a/apps/seller-ui/src/hooks/useShop.tsx b/apps/seller-ui/src/hooks/useShop.tsx new file mode 100644 index 0000000..9974da8 --- /dev/null +++ b/apps/seller-ui/src/hooks/useShop.tsx @@ -0,0 +1,94 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import axiosInstance from '../utils/axiosIsntance'; +import toast from 'react-hot-toast'; + +export interface Shop { + id: string; + name: string; + bio: string; + category: string; + address: string; + opening_hours: string; + website?: string; + social_links: any[]; + ratings: number; + coverBanner?: string; + avatar?: { + id: string; + url: string; + }; + productAnalytics?: { + views: number; + cartAdds: number; + purchases: number; + }; + createdAt: string; + updatedAt: string; +} + +export interface UpdateShopInfoData { + name: string; + bio: string; + address: string; + opening_hours: string; + website?: string; + category: string; + social_links?: any[]; +} + +export interface UpdateCoverBannerData { + coverBanner: string; +} + +// Get shop information +export const useShopInfo = () => { + return useQuery({ + queryKey: ['shop-info'], + queryFn: async (): Promise => { + const response = await axiosInstance.get('/api/shop-info'); + return response.data.shop; + }, + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +// Update shop information +export const useUpdateShopInfo = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: UpdateShopInfoData): Promise => { + const response = await axiosInstance.put('/api/shop-info', data); + return response.data.shop; + }, + onSuccess: (data) => { + queryClient.setQueryData(['shop-info'], data); + toast.success('Shop information updated successfully!'); + }, + onError: (error: any) => { + const message = error.response?.data?.message || 'Failed to update shop information'; + toast.error(message); + }, + }); +}; + +// Update shop cover banner +export const useUpdateCoverBanner = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: UpdateCoverBannerData): Promise => { + const response = await axiosInstance.put('/api/shop-cover-banner', data); + return response.data.shop; + }, + onSuccess: (data) => { + queryClient.setQueryData(['shop-info'], data); + toast.success('Cover banner updated successfully!'); + }, + onError: (error: any) => { + const message = error.response?.data?.message || 'Failed to update cover banner'; + toast.error(message); + }, + }); +}; diff --git a/apps/seller-ui/src/shared/molecules/shop/CoverBannerUpload.tsx b/apps/seller-ui/src/shared/molecules/shop/CoverBannerUpload.tsx new file mode 100644 index 0000000..ffc5dee --- /dev/null +++ b/apps/seller-ui/src/shared/molecules/shop/CoverBannerUpload.tsx @@ -0,0 +1,216 @@ +'use client'; + +import React, { useState, useRef } from 'react'; +import { useUpdateCoverBanner, useShopInfo } from '../../../hooks/useShop'; +import { Upload, Image as ImageIcon, Loader2, X } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { convertFileToBase64 } from 'apps/seller-ui/src/utils/helpers'; + +const CoverBannerUpload: React.FC = () => { + const { data: shop, isLoading: isLoadingShop } = useShopInfo(); + const updateCoverBanner = useUpdateCoverBanner(); + const [isDragging, setIsDragging] = useState(false); + const [selectedBanner, setSelectedBanner] = useState(null); + const fileInputRef = useRef(null); + + const handleFileSelect = async (file: File) => { + if (!file.type.startsWith('image/')) { + toast.error('Please select a valid image file.'); + return; + } + + const maxSizeInMB = 5; + if (file.size > maxSizeInMB * 1024 * 1024) { + toast.error(`Image must be smaller than ${maxSizeInMB}MB.`); + return; + } + + try { + const base64 = (await convertFileToBase64(file)) as string; + setSelectedBanner(base64); + } catch (error) { + toast.error('Failed to read image file. Please try again.'); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + void handleFileSelect(files[0]); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + void handleFileSelect(files[0]); + } + }; + + const handleUpload = async () => { + if (!selectedBanner || updateCoverBanner.isPending) return; + + updateCoverBanner.mutate( + { coverBanner: selectedBanner }, + { + onSuccess: () => { + setSelectedBanner(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, + }, + ); + }; + + const handleRemove = () => { + setSelectedBanner(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const currentBanner = selectedBanner || shop?.coverBanner || null; + const hasExistingBanner = Boolean(shop?.coverBanner); + const hasSelectedBanner = Boolean(selectedBanner); + + if (isLoadingShop) { + return ( +
+
+ +

Loading shop information...

+
+
+ ); + } + + if (!shop) { + return ( +
+

No shop information found. Please create a shop first.

+
+ ); + } + + return ( +
+
+

Cover Banner

+

Upload a banner image for your shop. Recommended size: 1200x300px

+
+ + {/* Hidden file input - always present */} + + + {/* Current Banner Display */} + {currentBanner ? ( +
+ Shop cover banner + {hasSelectedBanner && ( + + )} +
+ ) : ( +
fileInputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + fileInputRef.current?.click(); + } + }} + className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${ + isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400' + }`} + > +
+
+ {isDragging ? ( + + ) : ( + + )} +
+ +
+

+ {isDragging ? 'Drop your image here' : 'Upload a cover banner'} +

+

Drag and drop an image, or click to browse

+
+ + + + Choose File + +
+
+ )} + + {/* Actions */} + {hasSelectedBanner ? ( +
+ + + +
+ ) : ( + hasExistingBanner && ( + + ) + )} +
+ ); +}; + +export default CoverBannerUpload; diff --git a/apps/seller-ui/src/shared/molecules/shop/ShopInfoForm.tsx b/apps/seller-ui/src/shared/molecules/shop/ShopInfoForm.tsx new file mode 100644 index 0000000..dc7291b --- /dev/null +++ b/apps/seller-ui/src/shared/molecules/shop/ShopInfoForm.tsx @@ -0,0 +1,231 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useUpdateShopInfo, useShopInfo, UpdateShopInfoData } from '../../../hooks/useShop'; +import { Save, Loader2 } from 'lucide-react'; + +const ShopInfoForm: React.FC = () => { + const { data: shop, isLoading: isLoadingShop } = useShopInfo(); + const updateShopInfo = useUpdateShopInfo(); + const [socialLinks, setSocialLinks] = useState>([]); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm(); + + useEffect(() => { + if (shop) { + reset({ + name: shop.name, + bio: shop.bio, + address: shop.address, + opening_hours: shop.opening_hours, + website: shop.website || '', + category: shop.category, + }); + + // Initialize social links + if (shop.social_links && Array.isArray(shop.social_links)) { + setSocialLinks(shop.social_links); + } + } + }, [shop, reset]); + + const onSubmit = (data: UpdateShopInfoData) => { + const submitData = { + ...data, + social_links: socialLinks, + }; + updateShopInfo.mutate(submitData); + }; + + const addSocialLink = () => { + setSocialLinks([...socialLinks, { platform: '', url: '' }]); + }; + + const removeSocialLink = (index: number) => { + setSocialLinks(socialLinks.filter((_, i) => i !== index)); + }; + + const updateSocialLink = (index: number, field: 'platform' | 'url', value: string) => { + const updated = [...socialLinks]; + updated[index][field] = value; + setSocialLinks(updated); + }; + + if (isLoadingShop) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {/* Shop Name */} +
+ + + {errors.name &&

{errors.name.message}

} +
+ + {/* Category */} +
+ + + {errors.category &&

{errors.category.message}

} +
+ + {/* Website */} +
+ + +
+ + {/* Address */} +
+ +