fix
This commit is contained in:
62
package-lock.json
generated
62
package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -71,8 +71,8 @@ export default function AdsOnHoldPosterAdsPage() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={ads}
|
||||
searchColumn="title"
|
||||
searchPlaceholder="Search title..."
|
||||
searchColumn="dates"
|
||||
searchPlaceholder="Search dates..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -71,8 +71,8 @@ export default function AdsOnHoldVideoAdsPage() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={ads}
|
||||
searchColumn="title"
|
||||
searchPlaceholder="Search title..."
|
||||
searchColumn="dates"
|
||||
searchPlaceholder="Search dates..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
237
src/components/forms/otp-login-form.tsx
Normal file
237
src/components/forms/otp-login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/forms/phone-input-form.tsx
Normal file
87
src/components/forms/phone-input-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/components/forms/phone-verification-form.tsx
Normal file
219
src/components/forms/phone-verification-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
21
src/components/mgmt/EditAdLink.tsx
Normal file
21
src/components/mgmt/EditAdLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
31
src/hooks/useAdNavigation.ts
Normal file
31
src/hooks/useAdNavigation.ts
Normal 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
49
src/lib/navigation.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
Reference in New Issue
Block a user