This commit is contained in:
2025-08-18 15:23:44 +05:30
parent 5f373cf006
commit a26ca40a5b
40 changed files with 3098 additions and 2232 deletions

62
package-lock.json generated
View File

@@ -61,7 +61,8 @@
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.8",
"zod": "^3.24.3"
"zod": "^3.24.3",
"zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -3390,7 +3391,7 @@
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -3400,7 +3401,7 @@
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
@@ -8666,32 +8667,6 @@
"tiny-warning": "^1.0.3"
}
},
"node_modules/slate-dom": {
"version": "0.114.0",
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.114.0.tgz",
"integrity": "sha512-3LWIfiDPNQSY+SCPsvMTErCkx2gXTViLoWISisw6uM+unwiOkEF9ZmpHp88/SSmcq6k3P4aIquehUNeNUlkdiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4",
"is-hotkey": "^0.2.0",
"is-plain-object": "^5.0.0",
"lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1"
},
"peerDependencies": {
"slate": ">=0.99.0"
}
},
"node_modules/slate-dom/node_modules/tiny-invariant": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
"license": "MIT",
"peer": true
},
"node_modules/slate-history": {
"version": "0.113.1",
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
@@ -9524,6 +9499,35 @@
"peerDependencies": {
"zod": "^3.18.0"
}
},
"node_modules/zustand": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz",
"integrity": "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -63,7 +63,8 @@
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.8",
"zod": "^3.24.3"
"zod": "^3.24.3",
"zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

View File

@@ -249,7 +249,7 @@ export default function LineAdCard({
<p className={cn("text-sm mb-2 text-justify")}>{ad.content}</p>
{/* Contact Information */}
<div className="space-y-2 flex gap-2 justify-between items-start">
<div className="space-y-1 pb-2 justify-between items-start">
{/* Combined Posted By and Contact */}
{(ad.postedBy || ad.contactOne) && (
<div className="flex items-center text-xs text-muted-foreground">

View File

@@ -8,6 +8,7 @@ import LineAds from "./components/line-ad/line-ads";
import PosterAds from "./components/poster-ad/poster-ads";
import VideoPosterAd from "./components/video-ad/video-ads";
import Link from "next/link";
import Image from "next/image";
interface ContactInfo {
companyName: string;
@@ -83,9 +84,15 @@ export default function RegisterLayout({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Company Info */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white">
{contactInfo?.companyName || "PaisaAds"}
</h3>
<div className="flex items-center">
<Image
src="/logo.png"
alt="PaisaAds - Broadcast Brilliance"
width={160}
height={53}
className="h-10 w-auto"
/>
</div>
<p className="text-gray-400 text-sm leading-relaxed">
Your trusted platform for classified advertisements. Connect
buyers and sellers across various categories with ease and

View File

@@ -37,10 +37,6 @@ export default function Home() {
// console.log("Combined Result:", result);
const [lineAds, videoAds, posterAds] = result;
console.log("Line Ads:", lineAds.data);
console.log("Video Ads:", videoAds.data);
console.log("Poster Ads:", posterAds.data);
const filteredPosterAds = posterAds.data?.filter((ad: PosterAd) => {
return (
ad.position?.side !== "CENTER_BOTTOM" &&
@@ -77,8 +73,7 @@ export default function Home() {
bottomAds={data.centerBottomPosterAd}
>
<Suspense fallback={null}>
<LineAds />
<LineAds />
</Suspense>
</PosterAdCenterBottom>

View File

@@ -1,49 +0,0 @@
import api from "@/lib/api";
import type { LineAd } from "@/lib/types/lineAd";
import type { SubCategory } from "@/lib/types/category";
import type { AdComment } from "@/lib/types/ad-comment";
import { AdType } from "@/lib/enum/ad-type";
import { AdStatus } from "@/lib/enum/ad-status";
export interface EditLineAdData {
ad: LineAd;
categories: SubCategory[];
holdComment: AdComment | null;
}
export async function fetchEditLineAdData(adId: string): Promise<EditLineAdData> {
try {
// Fetch the advertisement
const adResponse = await api.get<LineAd>(`/line-ad/${adId}`);
const ad = adResponse.data;
// Fetch categories tree
const categoriesResponse = await api.get<SubCategory[]>("/categories/tree");
const categories = categoriesResponse.data;
// Fetch hold comment if ad is on hold
let holdComment: AdComment | null = null;
if (ad.status === AdStatus.HOLD) {
try {
const commentResponse = await api.get<AdComment[]>(
`ad-comments/ad/${AdType.LINE}/${adId}?actionType=${AdStatus.HOLD}`
);
if (commentResponse.data.length > 0) {
holdComment = commentResponse.data[0];
}
} catch (error) {
// Silently fail for hold comment - it's not critical
console.warn("Failed to fetch hold comment:", error);
}
}
return {
ad,
categories,
holdComment,
};
} catch (error) {
console.error("Error fetching edit line ad data:", error);
throw error;
}
}

View File

@@ -1,18 +1,18 @@
"use client";
import { EditLineAdForm } from "@/components/forms/edit-line-ad-form";
import { EditPosterAd } from "@/components/forms/edit-poster-ad-form";
import EditPosterAd from "@/app/mgmt/dashboard/review-ads/poster/edit/[id]/page";
import EditPosterAdForm from "@/components/forms/edit-poster-ad-form";
import type { Metadata } from "next";
import { useParams } from "next/navigation";
export default function EditAdPage() {
const params = useParams();
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="">
<div className="space-y-2">
<h1 className="text-2xl font-bold">Edit Line Ad</h1>
</div>
<EditPosterAd adId={params.id as string} />
<EditPosterAdForm adId={params.id as string} />
</div>
);
}

View File

@@ -1,7 +1,5 @@
"use client";
import { EditLineAdForm } from "@/components/forms/edit-line-ad-form";
import { EditPosterAd } from "@/components/forms/edit-poster-ad-form";
import { EditVideoAd } from "@/components/forms/edit-video-ad-form";
import { EditVideoAdForm } from "@/components/forms/edit-video-ad-form";
import type { Metadata } from "next";
import { useParams } from "next/navigation";
@@ -13,7 +11,7 @@ export default function EditAdPage() {
<h1 className="text-2xl font-bold">Edit Video Ad</h1>
</div>
<EditVideoAd adId={params.id as string} />
<EditVideoAdForm adId={params.id as string} />
</div>
);
}

View File

@@ -19,7 +19,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "lucide-react";
import Zoom from 'react-medium-image-zoom'
export const columns: ColumnDef<LineAd>[] = [
{
accessorKey: "sequenceNumber",
@@ -76,6 +76,7 @@ export const columns: ColumnDef<LineAd>[] = [
);
},
},
{
accessorKey: "content",
header: ({ column }) => {
@@ -98,6 +99,21 @@ export const columns: ColumnDef<LineAd>[] = [
</div>
),
},
{
accessorKey:'images',
header:()=>{
return "Images"
},
cell:({row}) =>{
{console.log(row.original.images)}
return <div className="flex">
{row.original.images.map((image) => <Zoom>
<img className="size-12 aspect-square" src={`/api/images?imageName=${image.fileName}`}></img>
</Zoom>)}
</div>
}
},
{
accessorKey: "dates",
header: ({ column }) => {

View File

@@ -8,6 +8,8 @@ import { ArrowUpDown, Eye, Edit, FileImage, Calendar } from "lucide-react";
import { format } from "date-fns";
import Link from "next/link";
import type { LineAd } from "@/lib/types/lineAd";
import { EditAdLink } from "@/components/mgmt/EditAdLink";
import { AdType } from "@/lib/enum/ad-type";
import Image from "next/image";
import {
Popover,
@@ -407,10 +409,10 @@ export const columns: ColumnDef<LineAd>[] = [
</Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/mgmt/dashboard/review-ads/line/edit/${ad.id}`}>
<EditAdLink adId={ad.id} adType={AdType.LINE} from="ads-on-hold">
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Link>
</EditAdLink>
</Button>
</div>
);

View File

@@ -16,6 +16,8 @@ import Image from "next/image";
import Zoom from "react-medium-image-zoom";
import { PaymentDetailsDialog } from "@/components/payment/payment-details-dialog";
import { PosterAd } from "@/lib/types/posterAd";
import { EditAdLink } from "@/components/mgmt/EditAdLink";
import { AdType } from "@/lib/enum/ad-type";
export const columns: ColumnDef<PosterAd>[] = [
{
@@ -320,10 +322,10 @@ export const columns: ColumnDef<PosterAd>[] = [
</Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/mgmt/dashboard/review-ads/poster/edit/${ad.id}`}>
<EditAdLink adId={ad.id} adType={AdType.POSTER} from="ads-on-hold">
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Link>
</EditAdLink>
</Button>
</div>
);

View File

@@ -71,8 +71,8 @@ export default function AdsOnHoldPosterAdsPage() {
<DataTable
columns={columns}
data={ads}
searchColumn="title"
searchPlaceholder="Search title..."
searchColumn="dates"
searchPlaceholder="Search dates..."
/>
)}
</div>

View File

@@ -9,6 +9,8 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { VideoAd } from "@/lib/types/videoAd";
import { EditAdLink } from "@/components/mgmt/EditAdLink";
import { AdType } from "@/lib/enum/ad-type";
import { getStatusVariant } from "@/lib/utils";
import type { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
@@ -317,10 +319,10 @@ export const columns: ColumnDef<VideoAd>[] = [
</Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/mgmt/dashboard/review-ads/video/edit/${ad.id}`}>
<EditAdLink adId={ad.id} adType={AdType.VIDEO} from="ads-on-hold">
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Link>
</EditAdLink>
</Button>
</div>
);

View File

@@ -71,8 +71,8 @@ export default function AdsOnHoldVideoAdsPage() {
<DataTable
columns={columns}
data={ads}
searchColumn="title"
searchPlaceholder="Search title..."
searchColumn="dates"
searchPlaceholder="Search dates..."
/>
)}
</div>

View File

@@ -16,6 +16,8 @@ import {
import { format } from "date-fns";
import Link from "next/link";
import type { LineAd } from "@/lib/types/lineAd";
import { EditAdLink } from "@/components/mgmt/EditAdLink";
import { AdType } from "@/lib/enum/ad-type";
import Image from "next/image";
import {
Popover,
@@ -381,10 +383,10 @@ export const columns: ColumnDef<LineAd>[] = [
</Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/mgmt/dashboard/review-ads/line/edit/${ad.id}`}>
<EditAdLink adId={ad.id} adType={AdType.LINE} from="published-ads">
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Link>
</EditAdLink>
</Button>
</div>
);

View File

@@ -24,6 +24,8 @@ import {
import Zoom from "react-medium-image-zoom";
import { PaymentDetailsDialog } from "@/components/payment/payment-details-dialog";
import { PosterAd } from "@/lib/types/posterAd";
import { EditAdLink } from "@/components/mgmt/EditAdLink";
import { AdType } from "@/lib/enum/ad-type";
export const columns: ColumnDef<PosterAd>[] = [
{
accessorKey: "sequenceNumber",
@@ -301,10 +303,10 @@ export const columns: ColumnDef<PosterAd>[] = [
</Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/mgmt/dashboard/review-ads/poster/edit/${ad.id}`}>
<EditAdLink adId={ad.id} adType={AdType.POSTER} from="published-ads">
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Link>
</EditAdLink>
</Button>
</div>
);

View File

@@ -9,6 +9,8 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { VideoAd } from "@/lib/types/videoAd";
import { EditAdLink } from "@/components/mgmt/EditAdLink";
import { AdType } from "@/lib/enum/ad-type";
import { getStatusVariant } from "@/lib/utils";
import type { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
@@ -287,10 +289,10 @@ export const columns: ColumnDef<VideoAd>[] = [
</Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/mgmt/dashboard/review-ads/video/edit/${ad.id}`}>
<EditAdLink adId={ad.id} adType={AdType.VIDEO} from="published-ads">
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Link>
</EditAdLink>
</Button>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,7 @@ import {
} from "lucide-react";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { useAdNavigation } from "@/hooks/useAdNavigation";
import type React from "react";
import { useEffect, useState, useRef } from "react";
import { CitySelect, StateSelect } from "react-country-state-city";
@@ -82,6 +83,7 @@ import { toast } from "sonner";
export default function EditPosterAd() {
const params = useParams();
const router = useRouter();
const { goBack } = useAdNavigation(AdType.POSTER, params.id as string);
const queryClient = useQueryClient();
const [comment, setComment] = useState("");
const [selectedStatus, setSelectedStatus] = useState<AdStatus | "">("");
@@ -285,6 +287,8 @@ export default function EditPosterAd() {
setSelectedStatus("");
refetchAd();
queryClient.invalidateQueries({ queryKey: ["adComments", params.id] });
// Navigate back to the source page after status update
setTimeout(() => goBack(), 1000);
},
onError: (error) => {
toast.error("Failed to update ad status");
@@ -624,6 +628,11 @@ export default function EditPosterAd() {
try {
await handleAdUpdate();
await handlePaymentUpdate();
toast.success("All changes saved successfully");
// Navigate back to source page after save
setTimeout(() => goBack(), 1000);
} catch (error) {
toast.error("Failed to save changes");
} finally {
setIsSavingAll(false);
}
@@ -657,9 +666,7 @@ export default function EditPosterAd() {
<h3 className="text-lg font-semibold">Failed to load ad details</h3>
<p className="text-gray-600">Please try again later</p>
</div>
<Button
onClick={() => router.push("/mgmt/dashboard/review-ads/poster")}
>
<Button onClick={goBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Poster Ads
</Button>
@@ -697,7 +704,7 @@ export default function EditPosterAd() {
<Button
variant="outline"
size="sm"
onClick={() => router.push("/mgmt/dashboard/review-ads/poster")}
onClick={goBack}
className="h-8 px-3"
>
<ArrowLeft className="h-3 w-3 mr-1" />
@@ -1402,7 +1409,7 @@ export default function EditPosterAd() {
<div className="flex justify-between items-center mt-6 pb-6">
<Button
variant="outline"
onClick={() => router.back()}
onClick={goBack}
disabled={isSavingAll || isUpdatingAd || isUpdatingPayment}
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back

View File

@@ -69,6 +69,7 @@ import {
Phone,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useAdNavigation } from "@/hooks/useAdNavigation";
import type React from "react";
import { useEffect, useState, useRef } from "react";
import { CitySelect, StateSelect } from "react-country-state-city";
@@ -79,6 +80,7 @@ import { toast } from "sonner";
export default function EditVideoAd() {
const params = useParams();
const router = useRouter();
const { goBack } = useAdNavigation(AdType.VIDEO, params.id as string);
const queryClient = useQueryClient();
const [comment, setComment] = useState("");
const [selectedStatus, setSelectedStatus] = useState<AdStatus | "">("");
@@ -283,6 +285,8 @@ export default function EditVideoAd() {
setSelectedStatus("");
refetchAd();
queryClient.invalidateQueries({ queryKey: ["adComments", params.id] });
// Navigate back to the source page after status update
setTimeout(() => goBack(), 1000);
},
onError: (error) => {
toast.error("Failed to update ad status");
@@ -606,6 +610,11 @@ export default function EditVideoAd() {
try {
await handleAdUpdate();
await handlePaymentUpdate();
toast.success("All changes saved successfully");
// Navigate back to source page after save
setTimeout(() => goBack(), 1000);
} catch (error) {
toast.error("Failed to save changes");
} finally {
setIsSavingAll(false);
}
@@ -649,7 +658,7 @@ export default function EditVideoAd() {
variant="outline"
size="sm"
onClick={() =>
router.push("/mgmt/dashboard/review-ads/video")
goBack()
}
className="h-8 px-3"
>
@@ -1241,7 +1250,7 @@ export default function EditVideoAd() {
<div className="flex justify-between items-center mt-6 pb-6">
<Button
variant="outline"
onClick={() => router.back()}
onClick={goBack}
disabled={isSavingAll || isUpdatingAd || isUpdatingPayment}
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back
@@ -1276,7 +1285,7 @@ export default function EditVideoAd() {
<p className="text-gray-600">Please try again later</p>
</div>
<Button
onClick={() => router.push("/mgmt/dashboard/review-ads/video")}
onClick={goBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Video Ads
@@ -1296,7 +1305,7 @@ export default function EditVideoAd() {
<Button
variant="outline"
size="sm"
onClick={() => router.push("/mgmt/dashboard/review-ads/video")}
onClick={goBack}
className="h-8 px-3"
>
<ArrowLeft className="h-3 w-3 mr-1" />
@@ -1930,7 +1939,7 @@ export default function EditVideoAd() {
<div className="flex justify-between items-center mt-6 pb-6">
<Button
variant="outline"
onClick={() => router.back()}
onClick={goBack}
disabled={isSavingAll || isUpdatingAd || isUpdatingPayment}
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back

View File

@@ -1,18 +1,19 @@
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import api from "@/lib/api";
import { User } from "@/lib/types/user";
import OtpVerification from "@/components/forms/otp-verification";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PhoneVerificationForm } from "@/components/forms/phone-verification-form";
export default function VerifyOtpPage() {
const router = useRouter();
const [step, setStep] = useState<"loading" | "verify">("loading");
// Check if user is already logged in
const { data: user, isLoading: isCheckingAuth } = useQuery<User>({
@@ -30,26 +31,25 @@ export default function VerifyOtpPage() {
staleTime: 5 * 60 * 1000, // 5 minutes
});
// If user is already logged in, redirect to appropriate dashboard
// If user is already logged in and verified, redirect to dashboard
useEffect(() => {
if (user && !isCheckingAuth) {
if (user.role === "USER" && user.otp_verified === true) {
if (!isCheckingAuth) {
if (user && user.role === "USER" && user.otp_verified === true) {
router.push("/dashboard");
} else if (user && user.role === "USER" && user.otp_verified === false) {
// User exists but not verified - show verification form
setStep("verify");
} else {
// router.push("/mgmt/dashboard");
// No user found, redirect to register
router.push("/register");
}
}
}, [user, isCheckingAuth, router]);
// Handle successful OTP verification
const handleVerificationSuccess = () => {
toast.success("Phone number verified successfully!");
router.push("/login");
};
// Handle OTP resend
const handleResendOtp = () => {
// toast.success("OTP resent to your phone number");
const handleVerificationComplete = () => {
toast.success("Account verified and logged in successfully!");
router.push("/dashboard");
};
if (isCheckingAuth) {
@@ -69,45 +69,45 @@ export default function VerifyOtpPage() {
);
}
// If user is already logged in, don't show OTP form
if (user && user.otp_verified !== false) {
// Show verification form only if user is unverified
if (step === "loading") {
return null;
}
// If no phone number provided, redirect to register
// if (!phoneNumber) {
// router.push("/register");
// return null;
// }
if (!user || !user.phone_number) {
return null;
}
return (
<div className="flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">
Verify Phone Number
Verify Your Account
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Complete your registration by verifying your phone number
</p>
</div>
<Card className="w-full">
<CardHeader>
<CardTitle className="text-center text-xl">Enter OTP</CardTitle>
<CardTitle className="text-center text-xl">Phone Verification</CardTitle>
</CardHeader>
<CardContent>
<OtpVerification
phoneNumber={user!.phone_number}
onVerificationSuccess={handleVerificationSuccess}
onResendOtp={handleResendOtp}
title="Verify Your Registration"
description="Enter the 6-digit OTP sent to your phone number to complete registration"
<PhoneVerificationForm
phone={user.phone_number}
onVerificationComplete={handleVerificationComplete}
onCancel={() => router.push("/register")}
/>
</CardContent>
</Card>
<div className="text-center">
<Button
variant="ghost"
onClick={() => router.push("/register")}
// className="font-medium text-primary hover:text-primary/80 transition-colors"
className="text-sm"
>
Back to registration
</Button>

View File

@@ -123,10 +123,12 @@ export default function DashboardSidebar({ pathname }: ManagementSidebarProps) {
>
<item.icon className="h-4 w-4 mr-3" />
<span className="flex-1 text-left">{item.title}</span>
<ChevronRight className={cn(
"h-4 w-4 transition-transform",
isSubmenuOpen && "rotate-90"
)} />
<ChevronRight
className={cn(
"h-4 w-4 transition-transform",
isSubmenuOpen && "rotate-90"
)}
/>
</button>
{isSubmenuOpen && (
<ul className="pl-6 space-y-1">
@@ -179,9 +181,12 @@ export default function DashboardSidebar({ pathname }: ManagementSidebarProps) {
{/* Mobile sidebar overlay */}
{isMobileOpen && (
<div className="lg:hidden fixed inset-0 z-40 bg-black bg-opacity-50" onClick={handleMobileClose}>
<div
className="fixed left-0 top-0 h-full w-64 bg-white shadow-xl"
<div
className="lg:hidden fixed inset-0 z-40 bg-black bg-opacity-50"
onClick={handleMobileClose}
>
<div
className="fixed left-0 top-0 h-full w-64 bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{sidebarContent}

View File

@@ -305,7 +305,22 @@ export default function EditLineAdForm({ adId }: EditAdFormProps) {
if (ad?.mainCategory) {
form.setValue("mainCategoryId", ad.mainCategory.id, {
shouldValidate: true,
// shouldValidate: true,
});
}
if (ad?.categoryOne) {
form.setValue("categoryOneId", ad.categoryOne.id, {
// shouldValidate: true,
});
}
if (ad?.categoryTwo) {
form.setValue("categoryTwoId", ad.categoryTwo.id, {
// shouldValidate: true,
});
}
if (ad?.categoryThree) {
form.setValue("categoryThreeId", ad.categoryThree.id, {
// shouldValidate: true,
});
}
}, [ad, form]);
@@ -319,6 +334,9 @@ export default function EditLineAdForm({ adId }: EditAdFormProps) {
setCity(null);
}
}, [state, form]);
useEffect(() => {
router.refresh()
},[])
useEffect(() => {
if (city) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import api from "@/lib/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Phone, Shield } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
interface OTPLoginFormProps {
onSuccess: () => void;
onVerificationRequired?: (phone: string) => void;
}
export function OTPLoginForm({ onSuccess, onVerificationRequired }: OTPLoginFormProps) {
const [phone, setPhone] = useState("");
const [otp, setOtp] = useState("");
const [step, setStep] = useState<"phone" | "otp">("phone");
const queryClient = useQueryClient();
// Send OTP mutation
const { mutate: sendOTP, isPending: isSendingOTP } = useMutation({
mutationFn: async (phoneNumber: string) => {
const { data } = await api.post("/auth/send-otp", {
phone: phoneNumber,
});
return data;
},
onSuccess: () => {
toast.success("OTP sent successfully to your phone");
setStep("otp");
},
onError: (error: any) => {
const errorMessage = error.response?.data?.message || "Failed to send OTP";
if (errorMessage.includes("verify your account")) {
toast.error("Please verify your account first");
onVerificationRequired?.(phone.replace(/\D/g, ""));
} else if (errorMessage.includes("Admin users should use regular login")) {
toast.error("Admin users should use email/password login");
} else {
toast.error(errorMessage);
}
},
});
// Verify OTP mutation
const { mutate: verifyOTP, isPending: isVerifyingOTP } = useMutation({
mutationFn: async ({ phone, otp }: { phone: string; otp: string }) => {
const { data } = await api.post("/auth/verify-otp", {
phone,
otp,
});
return data;
},
onSuccess: () => {
toast.success("Login successful!");
// Invalidate user query to refetch user data
queryClient.invalidateQueries({ queryKey: ["user"] });
onSuccess();
// Reset form
setPhone("");
setOtp("");
setStep("phone");
},
onError: (error: any) => {
const errorMessage = error.response?.data?.message || "Invalid or expired OTP";
if (errorMessage.includes("verify your account")) {
toast.error("Please verify your account first");
onVerificationRequired?.(phone.replace(/\D/g, ""));
} else {
toast.error(errorMessage);
}
},
});
const handleSendOTP = (e: React.FormEvent) => {
e.preventDefault();
if (!phone.trim()) {
toast.error("Phone number is required");
return;
}
// Basic phone validation
if (!/^\d{10}$/.test(phone.replace(/\D/g, ""))) {
toast.error("Please enter a valid 10-digit phone number");
return;
}
sendOTP(phone.replace(/\D/g, ""));
};
const handleVerifyOTP = (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) {
toast.error("OTP is required");
return;
}
if (!/^\d{6}$/.test(otp)) {
toast.error("Please enter a valid 6-digit OTP");
return;
}
verifyOTP({ phone: phone.replace(/\D/g, ""), otp });
};
const handleBack = () => {
setStep("phone");
setOtp("");
};
const formatPhoneNumber = (value: string) => {
// Remove all non-digits and limit to 10 digits
const digits = value.replace(/\D/g, "").slice(0, 10);
return digits;
};
if (step === "phone") {
return (
<form onSubmit={handleSendOTP} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone" className="text-sm font-medium">
Phone Number
</Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="phone"
type="tel"
placeholder="9876543210"
value={formatPhoneNumber(phone)}
onChange={(e) => setPhone(e.target.value)}
className="pl-10"
disabled={isSendingOTP}
/>
</div>
<p className="text-xs text-muted-foreground">
We'll send you a 6-digit verification code
</p>
</div>
<Button
type="submit"
className="w-full"
disabled={isSendingOTP || !phone.trim()}
>
{isSendingOTP ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending OTP...
</>
) : (
<>
<Phone className="mr-2 h-4 w-4" />
Send OTP
</>
)}
</Button>
</form>
);
}
return (
<form onSubmit={handleVerifyOTP} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="otp" className="text-sm font-medium">
Verification Code
</Label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="otp"
type="text"
placeholder="123456"
value={otp}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
setOtp(value);
}}
className="pl-10 text-center text-lg tracking-widest"
disabled={isVerifyingOTP}
maxLength={6}
/>
</div>
<p className="text-xs text-muted-foreground">
Enter the 6-digit code sent to {phone}
</p>
</div>
<div className="space-y-2">
<Button
type="submit"
className="w-full"
disabled={isVerifyingOTP || otp.length !== 6}
>
{isVerifyingOTP ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
<>
<Shield className="mr-2 h-4 w-4" />
Verify & Login
</>
)}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleBack}
disabled={isVerifyingOTP}
>
Back to Phone Number
</Button>
<Button
type="button"
variant="link"
className="w-full text-sm"
onClick={() => sendOTP(phone.replace(/\D/g, ""))}
disabled={isSendingOTP || isVerifyingOTP}
>
{isSendingOTP ? "Sending..." : "Resend OTP"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Phone } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
interface PhoneInputFormProps {
onPhoneSubmit: (phone: string) => void;
onCancel?: () => void;
}
export function PhoneInputForm({ onPhoneSubmit, onCancel }: PhoneInputFormProps) {
const [phone, setPhone] = useState("");
const formatPhoneNumber = (value: string) => {
// Remove all non-digits and limit to 10 digits
const digits = value.replace(/\D/g, "").slice(0, 10);
return digits;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!phone.trim()) {
toast.error("Phone number is required");
return;
}
// Basic phone validation
if (!/^\d{10}$/.test(phone.replace(/\D/g, ""))) {
toast.error("Please enter a valid 10-digit phone number");
return;
}
onPhoneSubmit(phone.replace(/\D/g, ""));
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="text-center">
<Phone className="mx-auto h-12 w-12 text-primary mb-4" />
<h3 className="text-lg font-semibold mb-2">Verification Required</h3>
<p className="text-sm text-muted-foreground mb-4">
Please enter your registered phone number to verify your account.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="phone" className="text-sm font-medium">
Phone Number
</Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="phone"
type="tel"
placeholder="9876543210"
value={formatPhoneNumber(phone)}
onChange={(e) => setPhone(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Button type="submit" className="w-full">
<Phone className="mr-2 h-4 w-4" />
Continue Verification
</Button>
{onCancel && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={onCancel}
>
Cancel
</Button>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import api from "@/lib/api";
import { useMutation } from "@tanstack/react-query";
import { Loader2, Phone, Shield, CheckCircle } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
interface PhoneVerificationFormProps {
phone: string;
onVerificationComplete: () => void;
onCancel?: () => void;
}
export function PhoneVerificationForm({
phone,
onVerificationComplete,
onCancel,
}: PhoneVerificationFormProps) {
const [otp, setOtp] = useState("");
const [step, setStep] = useState<"send" | "verify">("send");
// Send verification OTP mutation
const { mutate: sendVerificationOTP, isPending: isSendingOTP } = useMutation({
mutationFn: async (phoneNumber: string) => {
const { data } = await api.post("/auth/send-verification-otp", {
emailOrPhone: phoneNumber,
type: "phone",
});
return data;
},
onSuccess: () => {
toast.success("Verification OTP sent to your phone");
setStep("verify");
},
onError: (error: any) => {
toast.error(
error.response?.data?.message || "Failed to send verification OTP"
);
},
});
const { mutate: autoVerifyPhone, isPending: isVerifyingAccount } =
useMutation({
mutationFn: async ({ phone, otp }: { phone: string; otp: string }) => {
const { data } = await api.post("/auth/auto-verify-phone", {
phone,
otp,
});
return data;
},
onSuccess: (data) => {
toast.success("Phone number verified and logged in successfully!");
// Invalidate user query to refetch user data since they're now logged in
// queryClient.invalidateQueries({ queryKey: ["user"] });
onVerificationComplete();
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Invalid or expired OTP");
},
});
const handleSendOTP = () => {
sendVerificationOTP(phone);
};
const handleVerifyOTP = (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) {
toast.error("OTP is required");
return;
}
if (!/^\d{6}$/.test(otp)) {
toast.error("Please enter a valid 6-digit OTP");
return;
}
autoVerifyPhone({ phone, otp });
};
const handleBack = () => {
setStep("send");
setOtp("");
};
if (step === "send") {
return (
<div className="space-y-4">
<div className="text-center">
<Shield className="mx-auto h-12 w-12 text-primary mb-4" />
<h3 className="text-lg font-semibold mb-2">
Phone Verification Required
</h3>
<p className="text-sm text-muted-foreground mb-4">
We need to verify your phone number before you can login.
</p>
<div className="bg-muted/50 p-3 rounded-lg mb-4">
<p className="text-sm font-medium">Phone Number:</p>
<p className="text-lg">{phone}</p>
</div>
</div>
<div className="space-y-2">
<Button
onClick={handleSendOTP}
className="w-full"
disabled={isSendingOTP}
>
{isSendingOTP ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending OTP...
</>
) : (
<>
<Phone className="mr-2 h-4 w-4" />
Send Verification OTP
</>
)}
</Button>
{onCancel && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={onCancel}
disabled={isSendingOTP}
>
Cancel
</Button>
)}
</div>
</div>
);
}
return (
<form onSubmit={handleVerifyOTP} className="space-y-4">
<div className="text-center">
<Shield className="mx-auto h-12 w-12 text-primary mb-4" />
<h3 className="text-lg font-semibold mb-2">Enter Verification Code</h3>
<p className="text-sm text-muted-foreground mb-4">
We've sent a 6-digit verification code to {phone}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="verification-otp" className="text-sm font-medium">
Verification Code
</Label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="verification-otp"
type="text"
placeholder="123456"
value={otp}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
setOtp(value);
}}
className="pl-10 text-center text-lg tracking-widest"
disabled={isVerifyingAccount}
maxLength={6}
/>
</div>
<p className="text-xs text-muted-foreground">
Code expires in 10 minutes
</p>
</div>
<div className="space-y-2">
<Button
type="submit"
className="w-full"
disabled={isVerifyingAccount || otp.length !== 6}
>
{isVerifyingAccount ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Verify Phone
</>
)}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleBack}
disabled={isVerifyingAccount}
>
Back
</Button>
<Button
type="button"
variant="link"
className="w-full text-sm"
onClick={() => sendVerificationOTP(phone)}
disabled={isSendingOTP || isVerifyingAccount}
>
{isSendingOTP ? "Sending..." : "Resend Verification Code"}
</Button>
</div>
</form>
);
}

View File

@@ -151,13 +151,9 @@ export function PostAdForm() {
postedBy: "",
sid: 0,
cid: 0,
pageType: PageType.HOME,
},
});
console.log(form.getValues());
console.log(form.formState.errors);
// Update subcategories when main category changes
useEffect(() => {
const mainCategoryId = form.watch("mainCategoryId");
@@ -451,6 +447,7 @@ export function PostAdForm() {
<FormItem>
<FormControl>
<Textarea
maxLength={250}
className="min-h-[120px] resize-none bg-white border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Provide details about what you're advertising..."
{...field}

View File

@@ -19,8 +19,9 @@ import api from "@/lib/api";
import { useRouter } from "next/navigation";
interface PostAdLoginFormProps {
onSuccess: () => void;
onVerificationRequired?: (emailOrPhone: string) => void;
}
export function PostAdLoginForm({ onSuccess }: PostAdLoginFormProps) {
export function PostAdLoginForm({ onSuccess, onVerificationRequired }: PostAdLoginFormProps) {
const router = useRouter();
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginFormSchema),
@@ -46,11 +47,21 @@ export function PostAdLoginForm({ onSuccess }: PostAdLoginFormProps) {
router.push("/dashboard");
onSuccess();
},
onError: (error) => {
onError: (error: any) => {
console.error("Login error:", error);
toast.error("Login failed", {
description: "Invalid email or password. Please try again.",
});
const errorMessage = error.response?.data?.message || "Login failed";
if (errorMessage.includes("verify your account")) {
toast.error("Account verification required", {
description: "Please verify your account before logging in.",
});
const emailOrPhone = form.getValues("email");
onVerificationRequired?.(emailOrPhone);
} else {
toast.error("Login failed", {
description: errorMessage || "Invalid email or password. Please try again.",
});
}
},
});

View File

@@ -142,7 +142,7 @@ export default function RegisterForm() {
description: "Please verify your phone number with OTP",
});
// Redirect to OTP verification with phone number
router.push(`/register/verify-otp?phone=${form.getValues("phone_number")}`);
router.push(`/register/verify-otp`);
},
onError: (error) => {
console.error("Registration error:", error);

View File

@@ -0,0 +1,21 @@
import Link from "next/link";
import { AdType } from "@/lib/enum/ad-type";
import { getEditRoute, type AdStatusPage } from "@/lib/navigation";
interface EditAdLinkProps {
adId: string;
adType: AdType;
from: AdStatusPage;
children: React.ReactNode;
className?: string;
}
export function EditAdLink({ adId, adType, from, children, className }: EditAdLinkProps) {
const href = getEditRoute(from, adType, adId);
return (
<Link href={href} className={className}>
{children}
</Link>
);
}

View File

@@ -124,8 +124,8 @@ export function ManagementSidebar({
roles: [Role.SUPER_ADMIN, Role.EDITOR, Role.REVIEWER],
submenu: [
{ title: "Line Ads", href: "/mgmt/dashboard/review-ads/line" },
{ title: "Video Ads", href: "/mgmt/dashboard/review-ads/video" },
{ title: "Poster Ads", href: "/mgmt/dashboard/review-ads/poster" },
{ title: "Video Ads", href: "/mgmt/dashboard/review-ads/video" },
],
},
{
@@ -135,8 +135,8 @@ export function ManagementSidebar({
roles: [Role.SUPER_ADMIN, Role.EDITOR, Role.REVIEWER],
submenu: [
{ title: "Line Ads", href: "/mgmt/dashboard/published-ads/line" },
{ title: "Video Ads", href: "/mgmt/dashboard/published-ads/video" },
{ title: "Poster Ads", href: "/mgmt/dashboard/published-ads/poster" },
{ title: "Video Ads", href: "/mgmt/dashboard/published-ads/video" },
],
},
{
@@ -146,8 +146,8 @@ export function ManagementSidebar({
roles: [Role.SUPER_ADMIN, Role.EDITOR, Role.REVIEWER],
submenu: [
{ title: "Line Ads", href: "/mgmt/dashboard/ads-on-hold/line" },
{ title: "Video Ads", href: "/mgmt/dashboard/ads-on-hold/video" },
{ title: "Poster Ads", href: "/mgmt/dashboard/ads-on-hold/poster" },
{ title: "Video Ads", href: "/mgmt/dashboard/ads-on-hold/video" },
],
},
{
@@ -157,8 +157,8 @@ export function ManagementSidebar({
roles: [Role.SUPER_ADMIN, Role.EDITOR, Role.REVIEWER],
submenu: [
{ title: "Line Ads", href: "/mgmt/dashboard/rejected-ads/line" },
{ title: "Video Ads", href: "/mgmt/dashboard/rejected-ads/video" },
{ title: "Poster Ads", href: "/mgmt/dashboard/rejected-ads/poster" },
{ title: "Video Ads", href: "/mgmt/dashboard/rejected-ads/video" },
],
},
{

View File

@@ -25,12 +25,18 @@ import {
LogOut,
Settings,
UserIcon,
Menu,
X,
} from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { PostAdLoginForm } from "./forms/post-an-ad-login-form";
import { OTPLoginForm } from "./forms/otp-login-form";
import { PhoneVerificationForm } from "./forms/phone-verification-form";
import { PhoneInputForm } from "./forms/phone-input-form";
import { logoutFromServer } from "@/logout";
import { User } from "@/lib/types/user";
@@ -39,6 +45,11 @@ export default function Navbar() {
const router = useRouter();
const [isLoginOpen, setIsLoginOpen] = useState(false);
const [isViewAdOpen, setIsViewAdOpen] = useState(false);
const [loginMethod, setLoginMethod] = useState<"password" | "otp">("password");
const [showVerification, setShowVerification] = useState(false);
const [showPhoneInput, setShowPhoneInput] = useState(false);
const [verificationPhone, setVerificationPhone] = useState("");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Fetch current user
const { data: user, isLoading } = useQuery<User>({
@@ -82,38 +93,108 @@ export default function Navbar() {
.toUpperCase();
};
// Handle verification requirement
const handleVerificationRequired = (emailOrPhone: string) => {
// Extract phone number if it's a phone number (contains only digits)
const cleanInput = emailOrPhone.replace(/\D/g, "");
if (cleanInput.length === 10) {
setVerificationPhone(cleanInput);
setShowVerification(true);
} else {
// If it's an email, show phone input form to get their phone number
setShowPhoneInput(true);
}
};
// Handle phone number submission from phone input form
const handlePhoneSubmit = (phone: string) => {
setVerificationPhone(phone);
setShowPhoneInput(false);
setShowVerification(true);
};
// Handle verification completion
const handleVerificationComplete = () => {
setShowVerification(false);
setVerificationPhone("");
setIsLoginOpen(false); // Close the login dialog since user is now logged in
// No need for additional toast since the verification form already shows success
};
// Reset all states when dialog closes
const handleDialogClose = (open: boolean) => {
setIsLoginOpen(open);
if (!open) {
setLoginMethod("password");
setShowVerification(false);
setShowPhoneInput(false);
setVerificationPhone("");
}
};
return (
<header className="w-full sticky top-0 z-50 bg-white/80 backdrop-blur border-b shadow-sm">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<div className="flex items-center gap-8">
<Link
href="/"
className="text-2xl font-bold tracking-tight text-primary"
>
PaisaAds
</Link>
<nav className="hidden md:flex gap-6">
{[
{ href: "/", label: "Home" },
{ href: "/about-us", label: "About" },
{ href: "/contact-us", label: "Contact" },
{ href: "/faq", label: "FAQ" },
].map((item) => (
<Link
key={item.href}
href={item.href}
className={`text-sm font-medium transition-colors px-2 py-1 rounded-md ${
pathname === item.href
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-primary hover:bg-primary/5"
}`}
>
{item.label}
</Link>
))}
<header className="w-full sticky top-0 z-50 bg-white/95 backdrop-blur border-b shadow-sm">
<div className="container mx-auto px-4">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<div className="flex-shrink-0">
<Link
href="/"
className="flex items-center"
>
<Image
src="/logo.png"
alt="PaisaAds - Broadcast Brilliance"
width={150}
height={50}
className="h-10 w-auto"
priority
/>
</Link>
</div>
{/* Center Navigation */}
<nav className="hidden md:flex items-center justify-center flex-1">
<div className="flex items-center space-x-8">
{[
{ href: "/", label: "Home" },
{ href: "/about-us", label: "About" },
{ href: "/contact-us", label: "Contact" },
{ href: "/faq", label: "FAQ" },
].map((item) => (
<Link
key={item.href}
href={item.href}
className={`text-sm font-semibold transition-all duration-200 px-2 py-1 rounded-lg ${
pathname === item.href
? "text-white bg-primary shadow-lg"
: "text-gray-700 hover:text-primary hover:bg-primary/5 hover:shadow-sm"
}`}
>
{item.label}
</Link>
))}
</div>
</nav>
</div>
<div className="flex items-center gap-3">
{/* Mobile menu button */}
<div className="md:hidden">
<Button
variant="ghost"
size="sm"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="p-2"
>
{isMobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</Button>
</div>
{/* Right side - Auth buttons */}
<div className="hidden md:flex items-center gap-3 flex-shrink-0">
{isLoading ? (
// Show loading state
<Button variant="outline" size="sm" disabled className="gap-2">
@@ -197,13 +278,34 @@ export default function Navbar() {
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>View Advertisements</DialogTitle>
<DialogTitle>Quick Access - View Advertisements</DialogTitle>
</DialogHeader>
<ViewAdForm onSuccess={() => setIsViewAdOpen(false)} />
<OTPLoginForm
onSuccess={() => setIsViewAdOpen(false)}
onVerificationRequired={handleVerificationRequired}
/>
<div className="mt-4 text-center">
<span className="text-sm text-muted-foreground">
Don't have an account?{" "}
</span>
<Button
variant="link"
className="p-0 h-auto text-sm"
onClick={() => {
setIsViewAdOpen(false);
router.push("/register");
}}
>
Create an account
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={isLoginOpen} onOpenChange={setIsLoginOpen}>
<Dialog
open={isLoginOpen}
onOpenChange={handleDialogClose}
>
<DialogTrigger asChild>
<Button
className="rounded-full px-4 font-semibold shadow-md border-2 border-primary text-white bg-primary hover:bg-primary/90 transition-colors"
@@ -214,42 +316,190 @@ export default function Navbar() {
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Login to Post an Advertisement</DialogTitle>
<DialogTitle>
{showVerification ? "Verify Your Account" :
showPhoneInput ? "Phone Number Required" :
"Login to Post an Advertisement"}
</DialogTitle>
</DialogHeader>
<PostAdLoginForm onSuccess={() => setIsLoginOpen(false)} />
<div className="mt-4 text-center">
<span className="text-sm text-muted-foreground">
Don't have an account?{" "}
</span>
<Button
variant="link"
className="p-0 h-auto text-sm"
onClick={() => {
setIsLoginOpen(false);
router.push("/register");
}}
>
Create an account
</Button>
<div className="flex items-center justify-center">
<div className="h-[1px] w-80 bg-black/30 my-2"></div>
</div>
<Button
variant="link"
className="p-0 h-auto text-xs"
onClick={() => {
// setIsLoginOpen(false);
// router.push("/register");
}}
>
Forgot password?
</Button>
</div>
{showVerification ? (
<PhoneVerificationForm
phone={verificationPhone}
onVerificationComplete={handleVerificationComplete}
onCancel={() => setShowVerification(false)}
/>
) : showPhoneInput ? (
<PhoneInputForm
onPhoneSubmit={handlePhoneSubmit}
onCancel={() => setShowPhoneInput(false)}
/>
) : (
<>
{/* Login Method Toggle */}
<div className="flex items-center justify-center space-x-1 bg-muted p-1 rounded-lg">
<Button
type="button"
variant={loginMethod === "password" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setLoginMethod("password")}
>
Password
</Button>
<Button
type="button"
variant={loginMethod === "otp" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setLoginMethod("otp")}
>
OTP
</Button>
</div>
{/* Login Forms */}
{loginMethod === "password" ? (
<PostAdLoginForm
onSuccess={() => setIsLoginOpen(false)}
onVerificationRequired={handleVerificationRequired}
/>
) : (
<OTPLoginForm
onSuccess={() => setIsLoginOpen(false)}
onVerificationRequired={handleVerificationRequired}
/>
)}
{/* Footer Options */}
<div className="mt-4 text-center">
<span className="text-sm text-muted-foreground">
Don't have an account?{" "}
</span>
<Button
variant="link"
className="p-0 h-auto text-sm"
onClick={() => {
setIsLoginOpen(false);
router.push("/register");
}}
>
Create an account
</Button>
{loginMethod === "password" && (
<>
<div className="flex items-center justify-center">
<div className="h-[1px] w-80 bg-black/30 my-2"></div>
</div>
<Button
variant="link"
className="p-0 h-auto text-xs"
onClick={() => {
// setIsLoginOpen(false);
// router.push("/register");
}}
>
Forgot password?
</Button>
</>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
</>
)}
</div>
</div>
{/* Mobile Navigation Menu */}
{isMobileMenuOpen && (
<div className="md:hidden border-t bg-white/95 backdrop-blur">
<div className="px-4 py-4 space-y-2">
{/* Mobile Navigation Links */}
<div className="space-y-1">
{[
{ href: "/", label: "Home" },
{ href: "/about-us", label: "About" },
{ href: "/contact-us", label: "Contact" },
{ href: "/faq", label: "FAQ" },
].map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={`block px-4 py-3 rounded-lg text-base font-semibold transition-all duration-200 ${
pathname === item.href
? "text-white bg-primary shadow-lg"
: "text-gray-700 hover:text-primary hover:bg-primary/5"
}`}
>
{item.label}
</Link>
))}
</div>
{/* Mobile Auth Buttons */}
<div className="pt-4 border-t space-y-2">
{isLoading ? (
<Button variant="outline" disabled className="w-full gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</Button>
) : isLoggedIn ? (
<div className="space-y-2">
<Link
href={
user.role === Role.USER
? "/dashboard/"
: "/mgmt/dashboard"
}
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center gap-2 px-4 py-3 rounded-lg bg-primary/5 text-primary font-semibold"
>
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
<Button
onClick={() => {
handleLogout();
setIsMobileMenuOpen(false);
}}
variant="outline"
className="w-full justify-start gap-2 text-red-500 border-red-200 hover:bg-red-50"
>
<LogOut className="h-4 w-4" />
Logout
</Button>
</div>
) : (
<div className="space-y-2">
<Button
onClick={() => {
setIsViewAdOpen(true);
setIsMobileMenuOpen(false);
}}
variant="outline"
className="w-full"
>
View Ads
</Button>
<Button
onClick={() => {
setIsLoginOpen(true);
setIsMobileMenuOpen(false);
}}
className="w-full"
>
Post Ad
</Button>
</div>
)}
</div>
</div>
</div>
)}
</div>
</header>
);

View File

@@ -87,7 +87,7 @@ export function PaymentDetailsDialog({ payment }: PaymentDetailsDialogProps) {
<img
src={`/api/images?imageName=${payment.proof.fileName}&loadProof=true`}
alt="Payment proof"
className="object-contain w-full h-[500px]"
className="object-contain w-full h-72"
/>
</Zoom>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import type React from "react";
import Zoom from 'react-medium-image-zoom'
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -254,11 +254,13 @@ export function PaymentDialog({ adId, type }: PaymentDialogProps) {
<div className="mt-2">
{uploadedImage ? (
<div className="relative rounded-md border overflow-hidden">
<img
<Zoom>
<img
src={`/api/images?imageName=${uploadedImage.url}`}
alt="Payment proof"
className="w-full h-40 object-cover"
/>
</Zoom>
<Button
type="button"
variant="destructive"

View File

@@ -0,0 +1,31 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { AdType } from "@/lib/enum/ad-type";
import { getBackRoute, parseNavigationContext, type AdStatusPage } from "@/lib/navigation";
export function useAdNavigation(adType: AdType, adId: string) {
const router = useRouter();
const searchParams = useSearchParams();
const navigationContext = parseNavigationContext(searchParams, adType, adId);
const goBack = useCallback(() => {
const backRoute = getBackRoute(navigationContext.from, adType);
router.push(backRoute);
}, [navigationContext.from, adType, router]);
const goToStatus = useCallback((statusPage: AdStatusPage) => {
const statusRoute = getBackRoute(statusPage, adType);
router.push(statusRoute);
}, [adType, router]);
return {
goBack,
goToStatus,
from: navigationContext.from,
isFromReviewAds: navigationContext.from === 'review-ads',
isFromPublishedAds: navigationContext.from === 'published-ads',
isFromAdsOnHold: navigationContext.from === 'ads-on-hold',
isFromRejectedAds: navigationContext.from === 'rejected-ads'
};
}

49
src/lib/navigation.ts Normal file
View File

@@ -0,0 +1,49 @@
import { AdType } from "./enum/ad-type";
export type AdStatusPage = 'review-ads' | 'published-ads' | 'ads-on-hold' | 'rejected-ads';
export interface NavigationContext {
from?: AdStatusPage;
adType: AdType;
adId: string;
}
export function getStatusPageRoute(statusPage: AdStatusPage, adType: AdType): string {
const adTypeMap = {
[AdType.POSTER]: 'poster',
[AdType.LINE]: 'line',
[AdType.VIDEO]: 'video'
};
return `/mgmt/dashboard/${statusPage}/${adTypeMap[adType]}`;
}
export function getEditRoute(statusPage: AdStatusPage, adType: AdType, adId: string): string {
const basePage = statusPage === 'review-ads' ? 'review-ads' : 'review-ads'; // All edit routes go through review-ads
const adTypeMap = {
[AdType.POSTER]: 'poster',
[AdType.LINE]: 'line',
[AdType.VIDEO]: 'video'
};
return `/mgmt/dashboard/${basePage}/${adTypeMap[adType]}/edit/${adId}?from=${statusPage}`;
}
export function getBackRoute(from: AdStatusPage | undefined, adType: AdType): string {
if (!from) {
// Default fallback to review-ads
return getStatusPageRoute('review-ads', adType);
}
return getStatusPageRoute(from, adType);
}
export function parseNavigationContext(searchParams: URLSearchParams, adType: AdType, adId: string): NavigationContext {
const from = searchParams.get('from') as AdStatusPage | null;
return {
from: from || undefined,
adType,
adId
};
}

View File

@@ -18,39 +18,41 @@ export const loginFormSchema = z.object({
.min(6, { message: "Password must be at least 6 characters" }),
});
export const registerFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters" }),
email: z.string().email({ message: "Please enter a valid email address" }),
phone_number: z
.string()
.min(10, { message: "Phone number must be at least 10 digits" })
.max(15, { message: "Phone number must not exceed 15 digits" }),
secondary_number: z
.string()
.min(10, { message: "Secondary number must be at least 10 digits" })
.max(15, { message: "Secondary number must not exceed 15 digits" })
.optional()
.or(z.literal("")),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters" }),
confirm_password: z
.string()
.min(1, { message: "Please confirm your password" }),
country: z.string().min(1, { message: "Country is required" }),
country_id: z.string().min(1, { message: "Country is required" }),
state: z.string().min(1, { message: "State is required" }),
state_id: z.string().min(1, { message: "State is required" }),
city: z.string().min(1, { message: "City is required" }),
city_id: z.string().min(1, { message: "City is required" }),
proof: z.string().min(1, { message: "Proof document is required" }),
gender: z.coerce.number({
message: "Please select a valid gender",
}),
}).refine((data) => data.password === data.confirm_password, {
message: "Passwords do not match",
path: ["confirm_password"],
});
export const registerFormSchema = z
.object({
name: z.string().min(2, { message: "Name must be at least 2 characters" }),
email: z.string().email({ message: "Please enter a valid email address" }),
phone_number: z
.string()
.min(10, { message: "Phone number must be at least 10 digits" })
.max(15, { message: "Phone number must not exceed 15 digits" }),
secondary_number: z
.string()
.min(10, { message: "Secondary number must be at least 10 digits" })
.max(15, { message: "Secondary number must not exceed 15 digits" })
.optional()
.or(z.literal("")),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters" }),
confirm_password: z
.string()
.min(1, { message: "Please confirm your password" }),
country: z.string().min(1, { message: "Country is required" }),
country_id: z.string().min(1, { message: "Country is required" }),
state: z.string().min(1, { message: "State is required" }),
state_id: z.string().min(1, { message: "State is required" }),
city: z.string().min(1, { message: "City is required" }),
city_id: z.string().min(1, { message: "City is required" }),
proof: z.string().min(1, { message: "Proof document is required" }),
gender: z.coerce.number({
message: "Please select a valid gender",
}),
})
.refine((data) => data.password === data.confirm_password, {
message: "Passwords do not match",
path: ["confirm_password"],
});
export type ViewAdFormValues = z.infer<typeof viewAdFormSchema>;
export type LoginFormValues = z.infer<typeof loginFormSchema>;
@@ -81,74 +83,90 @@ export const lineAdFormSchema = z.object({
export type LineAdFormValues = z.infer<typeof lineAdFormSchema>;
// Poster ad - page-type, position-type (CENTER_TOP and CENTER_BOTTOM don't have position number), position
export const posterAdFormSchema = z.object({
mainCategoryId: z.string().min(1, { message: "Main category is required" }),
categoryOneId: z.string().optional(),
categoryTwoId: z.string().optional(),
categoryThreeId: z.string().optional(),
imageId: z.string().min(1, { message: "Image is required" }),
state: z.string().min(1, { message: "State is required" }),
sid: z.number().optional(),
city: z.string().min(1, { message: "City is required" }),
cid: z.number().optional(),
postedBy: z.string().min(1, { message: "Posted by is required" }),
dates: z.array(z.date()).min(1, "At least one date is required"),
// Poster ad positioning
pageType: z.nativeEnum(PageType),
side: z.nativeEnum(PositionType),
position: z.number().min(1).max(6).optional(), // Only for LEFT_SIDE and RIGHT_SIDE
}).refine((data) => {
// Position number is required for LEFT_SIDE and RIGHT_SIDE, not allowed for CENTER positions
if (data.side === PositionType.LEFT_SIDE || data.side === PositionType.RIGHT_SIDE) {
return data.position !== undefined;
}
return true; // CENTER_TOP and CENTER_BOTTOM don't require position number
}, {
message: "Position number is required for side positions",
path: ["position"],
});
export const posterAdFormSchema = z
.object({
mainCategoryId: z.string().min(1, { message: "Main category is required" }),
categoryOneId: z.string().optional(),
categoryTwoId: z.string().optional(),
categoryThreeId: z.string().optional(),
imageId: z.string().min(1, { message: "Image is required" }),
state: z.string().min(1, { message: "State is required" }),
sid: z.number().optional(),
city: z.string().min(1, { message: "City is required" }),
cid: z.number().optional(),
postedBy: z.string().min(1, { message: "Posted by is required" }),
dates: z.array(z.date()).min(1, "At least one date is required"),
// Poster ad positioning
pageType: z.nativeEnum(PageType),
side: z.nativeEnum(PositionType),
position: z.number().min(1).max(6).optional(), // Only for LEFT_SIDE and RIGHT_SIDE
})
.refine(
(data) => {
// Position number is required for LEFT_SIDE and RIGHT_SIDE, not allowed for CENTER positions
if (
data.side === PositionType.LEFT_SIDE ||
data.side === PositionType.RIGHT_SIDE
) {
return data.position !== undefined;
}
return true; // CENTER_TOP and CENTER_BOTTOM don't require position number
},
{
message: "Position number is required for side positions",
path: ["position"],
}
);
export type PosterAdFormValues = z.infer<typeof posterAdFormSchema>;
// Video ad - everything except CENTER_TOP and CENTER_BOTTOM position
export const videoAdFormSchema = z.object({
mainCategoryId: z.string().min(1, { message: "Main category is required" }),
categoryOneId: z.string().optional(),
categoryTwoId: z.string().optional(),
categoryThreeId: z.string().optional(),
imageId: z.string().min(1, { message: "Video is required" }),
state: z.string().min(1, { message: "State is required" }),
sid: z.number().optional(),
city: z.string().min(1, { message: "City is required" }),
cid: z.number().optional(),
postedBy: z.string().min(1, { message: "Posted by is required" }),
dates: z.array(z.date()).min(1, "At least one date is required"),
// Video ad positioning - everything except CENTER_TOP and CENTER_BOTTOM
pageType: z.nativeEnum(PageType),
side: z.nativeEnum(PositionType).refine(
(val) => val !== PositionType.CENTER_TOP && val !== PositionType.CENTER_BOTTOM,
{ message: "Video ads cannot use CENTER_TOP or CENTER_BOTTOM positions" }
),
position: z.number().min(1).max(6), // Required for video ads
});
export const videoAdFormSchema = z
.object({
mainCategoryId: z.string().min(1, { message: "Main category is required" }),
categoryOneId: z.string().optional(),
categoryTwoId: z.string().optional(),
categoryThreeId: z.string().optional(),
imageId: z.string().min(1, { message: "Video is required" }),
state: z.string().min(1, { message: "State is required" }),
sid: z.number().optional(),
city: z.string().min(1, { message: "City is required" }),
cid: z.number().optional(),
postedBy: z.string().min(1, { message: "Posted by is required" }),
dates: z.array(z.date()).min(1, "At least one date is required"),
// Video ad positioning
pageType: z.nativeEnum(PageType),
side: z.nativeEnum(PositionType),
position: z.number().min(1).max(8).optional(), // Optional for video ads, max 8 positions
})
.refine(
(data) => {
// Position number is required for LEFT_SIDE and RIGHT_SIDE, not allowed for CENTER positions
if (data.side === PositionType.LEFT_SIDE || data.side === PositionType.RIGHT_SIDE) {
return data.position !== undefined && data.position >= 1 && data.position <= 8;
}
// For CENTER positions, position should not be required
return true;
},
{
message: "Position number is required for left and right side placements",
path: ["position"],
}
);
export type VideoAdFormValues = z.infer<typeof videoAdFormSchema>;
// OTP and Password Reset schemas
export const sendOtpFormSchema = z.object({
phone_number: z
.string()
.regex(/^[6-9]\d{9}$/, {
message: "Phone number must be a valid 10-digit Indian mobile number",
}),
phone_number: z.string().regex(/^[6-9]\d{9}$/, {
message: "Phone number must be a valid 10-digit Indian mobile number",
}),
});
export const verifyOtpFormSchema = z.object({
phone_number: z
.string()
.regex(/^[6-9]\d{9}$/, {
message: "Phone number must be a valid 10-digit Indian mobile number",
}),
phone_number: z.string().regex(/^[6-9]\d{9}$/, {
message: "Phone number must be a valid 10-digit Indian mobile number",
}),
otp: z
.string()
.length(6, { message: "OTP must be exactly 6 digits" })
@@ -156,19 +174,15 @@ export const verifyOtpFormSchema = z.object({
});
export const forgotPasswordFormSchema = z.object({
phone_number: z
.string()
.regex(/^[6-9]\d{9}$/, {
message: "Phone number must be a valid 10-digit Indian mobile number",
}),
phone_number: z.string().regex(/^[6-9]\d{9}$/, {
message: "Phone number must be a valid 10-digit Indian mobile number",
}),
});
export const resetPasswordFormSchema = z.object({
phone_number: z
.string()
.regex(/^[6-9]\d{9}$/, {
message: "Phone number must be a valid 10-digit Indian mobile number",
}),
phone_number: z.string().regex(/^[6-9]\d{9}$/, {
message: "Phone number must be a valid 10-digit Indian mobile number",
}),
otp: z
.string()
.length(6, { message: "OTP must be exactly 6 digits" })