@navneet_25/tempjs 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -18
- package/cli/brand-manager.js +183 -96
- package/cli/config.js +9 -1
- package/cli/doctor.js +393 -0
- package/cli/fetch-core-modules.js +130 -0
- package/cli/index.js +141 -10
- package/cli/info.js +28 -1
- package/cli/init-options.js +209 -0
- package/cli/module-manager.js +168 -0
- package/cli/parse-args.js +21 -0
- package/cli/update.js +15 -0
- package/cli/version-manager.js +518 -0
- package/package.json +27 -4
- package/packages/core/modules/README.md +72 -0
- package/packages/core/modules/blog-compose/app/admin/components/blog-compose-form.ts +44 -0
- package/packages/core/modules/blog-compose/app/admin/hooks/useBlogPosts.ts +103 -0
- package/packages/core/modules/blog-compose/app/admin/panels/BlogComposePanel.tsx +388 -0
- package/packages/core/modules/blog-compose/app/api/blog-posts/[id]/route.ts +29 -0
- package/packages/core/modules/blog-compose/app/api/blog-posts/route.ts +21 -0
- package/packages/core/modules/blog-compose/app/blog/[slug]/page.tsx +79 -0
- package/packages/core/modules/blog-compose/app/blog/layout.tsx +22 -0
- package/packages/core/modules/blog-compose/app/blog/page.tsx +102 -0
- package/packages/core/modules/blog-compose/app/components/BlogSection.tsx +74 -0
- package/packages/core/modules/blog-compose/app/components/BlogShell.tsx +36 -0
- package/packages/core/modules/blog-compose/app/components/BlogSidebar.tsx +112 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/annotate-text.tsx +103 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/blocks/media-blocks.tsx +97 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/blocks/text-blocks.tsx +115 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/index.ts +21 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/interpreter.tsx +55 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/registry.ts +276 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/types.ts +128 -0
- package/packages/core/modules/blog-compose/lib/blog/register-sitemap.ts +18 -0
- package/packages/core/modules/blog-compose/lib/controllers/BlogComposeController.ts +2 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.controller.ts +78 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.repository.ts +78 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.service.ts +72 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.types.ts +20 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/index.ts +7 -0
- package/packages/core/modules/blog-compose/prisma/blog-compose.prisma +13 -0
- package/packages/core/modules/enquiry-modal/app/components/EnquiryModal.tsx +308 -0
- package/packages/core/modules/footer/app/components/Footer.tsx +127 -0
- package/packages/core/modules/gallery/app/admin/components/GalleryFormModal.tsx +132 -0
- package/packages/core/modules/gallery/app/admin/components/GalleryList.tsx +142 -0
- package/packages/core/modules/gallery/app/admin/hooks/useGallery.ts +72 -0
- package/packages/core/modules/gallery/app/api/gallery/[id]/route.ts +29 -0
- package/packages/core/modules/gallery/app/api/gallery/route.ts +22 -0
- package/packages/core/modules/gallery/app/components/GallerySection.tsx +92 -0
- package/packages/core/modules/gallery/app/gallery/layout.tsx +12 -0
- package/packages/core/modules/gallery/app/gallery/page.tsx +179 -0
- package/packages/core/modules/gallery/lib/controllers/GalleryController.ts +2 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.controller.ts +78 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.repository.ts +55 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.service.ts +43 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.types.ts +17 -0
- package/packages/core/modules/gallery/lib/features/gallery/index.ts +4 -0
- package/packages/core/modules/gallery/prisma/gallery.prisma +11 -0
- package/packages/core/modules/hero-simple/app/components/Hero.tsx +160 -0
- package/packages/core/modules/legal-pages/app/components/LegalDocumentPage.tsx +61 -0
- package/packages/core/modules/legal-pages/app/privacy-policy/page.tsx +19 -0
- package/packages/core/modules/legal-pages/app/terms-and-conditions/page.tsx +19 -0
- package/packages/core/modules/reviews/app/admin/components/ReviewFormModal.tsx +110 -0
- package/packages/core/modules/reviews/app/admin/components/ReviewsList.tsx +116 -0
- package/packages/core/modules/reviews/app/admin/hooks/useReviews.ts +70 -0
- package/packages/core/modules/reviews/app/api/reviews/[id]/route.ts +29 -0
- package/packages/core/modules/reviews/app/api/reviews/route.ts +17 -0
- package/packages/core/modules/reviews/app/components/ReviewsSection.tsx +100 -0
- package/packages/core/modules/reviews/lib/controllers/ReviewController.ts +2 -0
- package/packages/core/modules/reviews/lib/features/reviews/index.ts +3 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.controller.ts +64 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.repository.ts +50 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.service.ts +38 -0
- package/packages/core/modules/reviews/prisma/reviews.prisma +9 -0
- package/packages/core/modules/seo/app/components/SiteJsonLd.tsx +16 -0
- package/packages/core/modules/seo/app/robots.ts +30 -0
- package/packages/core/modules/seo/app/sitemap.ts +9 -0
- package/packages/core/modules/seo/lib/seo/index.ts +5 -0
- package/packages/core/modules/seo/lib/seo/json-ld.ts +128 -0
- package/packages/core/modules/seo/lib/seo/metadata.ts +65 -0
- package/packages/core/modules/seo/lib/seo/sitemap.ts +82 -0
- package/packages/core/modules/seo/lib/seo/types.ts +30 -0
- package/packages/core/modules/seo/lib/seo/urls.ts +27 -0
- package/packages/core/modules/theme-modes/app/components/ThemeModeInit.tsx +24 -0
- package/packages/core/modules/theme-modes/app/components/ThemeModeToggle.tsx +58 -0
- package/packages/core/modules.json +140 -0
- package/packages/core/prisma/schema.prisma +33 -0
- package/scripts/module-installer-core.mjs +657 -0
- package/scripts/template-modules-admin.mjs +366 -0
- package/scripts/template-modules-home.mjs +120 -0
- package/templates.json +163 -8
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
export interface ReviewDataInput {
|
|
6
|
+
id?: string;
|
|
7
|
+
name: string;
|
|
8
|
+
otherInfo?: string | null;
|
|
9
|
+
description: string;
|
|
10
|
+
sortOrder: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function fetchReviews(): Promise<ReviewDataInput[]> {
|
|
14
|
+
const res = await fetch("/api/reviews");
|
|
15
|
+
const json = await res.json();
|
|
16
|
+
if (!res.ok || !json.success) throw new Error(json.error || "Failed to load reviews");
|
|
17
|
+
return json.data;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useGetReviews() {
|
|
21
|
+
return useQuery({ queryKey: ["reviews"], queryFn: fetchReviews });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useCreateReview() {
|
|
25
|
+
const qc = useQueryClient();
|
|
26
|
+
return useMutation({
|
|
27
|
+
mutationFn: async (data: ReviewDataInput) => {
|
|
28
|
+
const res = await fetch("/api/reviews", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify(data),
|
|
32
|
+
});
|
|
33
|
+
const json = await res.json();
|
|
34
|
+
if (!res.ok || !json.success) throw new Error(json.error || "Failed to create");
|
|
35
|
+
return json.data;
|
|
36
|
+
},
|
|
37
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["reviews"] }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useUpdateReview() {
|
|
42
|
+
const qc = useQueryClient();
|
|
43
|
+
return useMutation({
|
|
44
|
+
mutationFn: async (data: ReviewDataInput) => {
|
|
45
|
+
if (!data.id) throw new Error("Missing id");
|
|
46
|
+
const res = await fetch(`/api/reviews/${data.id}`, {
|
|
47
|
+
method: "PUT",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify(data),
|
|
50
|
+
});
|
|
51
|
+
const json = await res.json();
|
|
52
|
+
if (!res.ok || !json.success) throw new Error(json.error || "Failed to update");
|
|
53
|
+
return json.data;
|
|
54
|
+
},
|
|
55
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["reviews"] }),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useDeleteReview() {
|
|
60
|
+
const qc = useQueryClient();
|
|
61
|
+
return useMutation({
|
|
62
|
+
mutationFn: async (id: string) => {
|
|
63
|
+
const res = await fetch(`/api/reviews/${id}`, { method: "DELETE" });
|
|
64
|
+
const json = await res.json();
|
|
65
|
+
if (!res.ok || !json.success) throw new Error(json.error || "Failed to delete");
|
|
66
|
+
return json.data;
|
|
67
|
+
},
|
|
68
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["reviews"] }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { reviewController } from "@/lib/controllers/ReviewController";
|
|
3
|
+
import { auth } from "@/auth";
|
|
4
|
+
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
export async function PUT(
|
|
8
|
+
req: NextRequest,
|
|
9
|
+
context: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
const session = await auth();
|
|
12
|
+
if (!session) {
|
|
13
|
+
return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
|
14
|
+
}
|
|
15
|
+
const { id } = await context.params;
|
|
16
|
+
return reviewController.updateReview(req, { id });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function DELETE(
|
|
20
|
+
_req: NextRequest,
|
|
21
|
+
context: { params: Promise<{ id: string }> }
|
|
22
|
+
) {
|
|
23
|
+
const session = await auth();
|
|
24
|
+
if (!session) {
|
|
25
|
+
return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
|
26
|
+
}
|
|
27
|
+
const { id } = await context.params;
|
|
28
|
+
return reviewController.deleteReview({ id });
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { reviewController } from "@/lib/controllers/ReviewController";
|
|
3
|
+
import { auth } from "@/auth";
|
|
4
|
+
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
export async function GET() {
|
|
8
|
+
return reviewController.getReviews();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function POST(req: NextRequest) {
|
|
12
|
+
const session = await auth();
|
|
13
|
+
if (!session) {
|
|
14
|
+
return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
|
15
|
+
}
|
|
16
|
+
return reviewController.createReview(req);
|
|
17
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { ChevronLeft, ChevronRight, Quote, Star } from "lucide-react";
|
|
5
|
+
import { SITE } from "@/constants";
|
|
6
|
+
|
|
7
|
+
interface ReviewItem {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
otherInfo?: string | null;
|
|
11
|
+
description: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function ReviewsSection() {
|
|
15
|
+
const [reviews, setReviews] = useState<ReviewItem[]>([]);
|
|
16
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
async function load() {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch("/api/reviews");
|
|
22
|
+
const json = await res.json();
|
|
23
|
+
if (json.success && Array.isArray(json.data) && json.data.length > 0) {
|
|
24
|
+
setReviews(json.data);
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.warn("ReviewsSection: failed to load", err);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
load();
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (reviews.length <= 1) return;
|
|
35
|
+
const interval = setInterval(() => {
|
|
36
|
+
setActiveIndex((prev) => (prev + 1) % reviews.length);
|
|
37
|
+
}, 6000);
|
|
38
|
+
return () => clearInterval(interval);
|
|
39
|
+
}, [reviews.length]);
|
|
40
|
+
|
|
41
|
+
if (reviews.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
const active = reviews[activeIndex];
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<section className="py-20 md:py-24 bg-white border-t border-primary/5">
|
|
47
|
+
<div className="max-w-4xl mx-auto px-6 text-center">
|
|
48
|
+
<span className="text-[10px] font-extrabold tracking-[0.25em] uppercase text-accent-gold-dark">
|
|
49
|
+
{SITE.reviews.sectionEyebrow}
|
|
50
|
+
</span>
|
|
51
|
+
<h2 className="text-2xl md:text-3xl font-bold font-serif text-primary mt-2">
|
|
52
|
+
{SITE.reviews.sectionTitle}
|
|
53
|
+
</h2>
|
|
54
|
+
<p className="text-sm text-text-muted mt-2 mb-10">{SITE.reviews.sectionSubtitle}</p>
|
|
55
|
+
|
|
56
|
+
<div className="relative bg-bg-light rounded-3xl p-8 md:p-12 border border-primary/5 shadow-sm">
|
|
57
|
+
<Quote className="w-8 h-8 text-accent-gold/40 mx-auto mb-4" />
|
|
58
|
+
<p className="text-base md:text-lg text-text-main leading-relaxed font-medium">
|
|
59
|
+
{active.description}
|
|
60
|
+
</p>
|
|
61
|
+
<div className="mt-6 flex flex-col items-center gap-1">
|
|
62
|
+
<div className="flex gap-0.5">
|
|
63
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
64
|
+
<Star key={i} className="w-4 h-4 fill-accent-gold text-accent-gold" />
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
<p className="font-bold text-primary mt-2">{active.name}</p>
|
|
68
|
+
{active.otherInfo && (
|
|
69
|
+
<p className="text-xs text-text-muted">{active.otherInfo}</p>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{reviews.length > 1 && (
|
|
74
|
+
<div className="flex items-center justify-center gap-4 mt-8">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => setActiveIndex((i) => (i - 1 + reviews.length) % reviews.length)}
|
|
78
|
+
className="p-2 rounded-full border border-primary/10 hover:bg-white cursor-pointer"
|
|
79
|
+
aria-label="Previous review"
|
|
80
|
+
>
|
|
81
|
+
<ChevronLeft className="w-5 h-5 text-primary" />
|
|
82
|
+
</button>
|
|
83
|
+
<span className="text-xs text-text-muted font-medium">
|
|
84
|
+
{activeIndex + 1} / {reviews.length}
|
|
85
|
+
</span>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setActiveIndex((i) => (i + 1) % reviews.length)}
|
|
89
|
+
className="p-2 rounded-full border border-primary/10 hover:bg-white cursor-pointer"
|
|
90
|
+
aria-label="Next review"
|
|
91
|
+
>
|
|
92
|
+
<ChevronRight className="w-5 h-5 text-primary" />
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { reviewService, ReviewService } from "./review.service";
|
|
3
|
+
|
|
4
|
+
export class ReviewController {
|
|
5
|
+
private service: ReviewService;
|
|
6
|
+
|
|
7
|
+
constructor(service: ReviewService = reviewService) {
|
|
8
|
+
this.service = service;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async getReviews(): Promise<NextResponse> {
|
|
12
|
+
try {
|
|
13
|
+
const data = await this.service.listReviews();
|
|
14
|
+
return NextResponse.json({ success: true, data }, { status: 200 });
|
|
15
|
+
} catch (error: unknown) {
|
|
16
|
+
const message = error instanceof Error ? error.message : "Failed to fetch reviews";
|
|
17
|
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async createReview(req: Request): Promise<NextResponse> {
|
|
22
|
+
try {
|
|
23
|
+
const body = await req.json();
|
|
24
|
+
const item = await this.service.createReview({
|
|
25
|
+
name: body.name,
|
|
26
|
+
otherInfo: body.otherInfo,
|
|
27
|
+
description: body.description,
|
|
28
|
+
sortOrder: body.sortOrder != null ? Number(body.sortOrder) : 0,
|
|
29
|
+
});
|
|
30
|
+
return NextResponse.json({ success: true, data: item }, { status: 201 });
|
|
31
|
+
} catch (error: unknown) {
|
|
32
|
+
const message = error instanceof Error ? error.message : "Failed to create review";
|
|
33
|
+
return NextResponse.json({ success: false, error: message }, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async updateReview(req: Request, params: { id: string }): Promise<NextResponse> {
|
|
38
|
+
try {
|
|
39
|
+
const body = await req.json();
|
|
40
|
+
const item = await this.service.updateReview(params.id, {
|
|
41
|
+
name: body.name,
|
|
42
|
+
otherInfo: body.otherInfo,
|
|
43
|
+
description: body.description,
|
|
44
|
+
sortOrder: body.sortOrder != null ? Number(body.sortOrder) : undefined,
|
|
45
|
+
});
|
|
46
|
+
return NextResponse.json({ success: true, data: item }, { status: 200 });
|
|
47
|
+
} catch (error: unknown) {
|
|
48
|
+
const message = error instanceof Error ? error.message : "Failed to update review";
|
|
49
|
+
return NextResponse.json({ success: false, error: message }, { status: 400 });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async deleteReview(params: { id: string }): Promise<NextResponse> {
|
|
54
|
+
try {
|
|
55
|
+
const item = await this.service.deleteReview(params.id);
|
|
56
|
+
return NextResponse.json({ success: true, data: item }, { status: 200 });
|
|
57
|
+
} catch (error: unknown) {
|
|
58
|
+
const message = error instanceof Error ? error.message : "Failed to delete review";
|
|
59
|
+
return NextResponse.json({ success: false, error: message }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const reviewController = new ReviewController();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { prisma } from "@/lib/database/prisma";
|
|
2
|
+
import type { Review } from "@prisma/client";
|
|
3
|
+
|
|
4
|
+
export interface ReviewInput {
|
|
5
|
+
name: string;
|
|
6
|
+
otherInfo?: string | null;
|
|
7
|
+
description: string;
|
|
8
|
+
sortOrder?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ReviewRepository {
|
|
12
|
+
async getAll(): Promise<Review[]> {
|
|
13
|
+
return prisma.review.findMany({
|
|
14
|
+
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getById(id: string): Promise<Review | null> {
|
|
19
|
+
return prisma.review.findUnique({ where: { id } });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async create(data: ReviewInput): Promise<Review> {
|
|
23
|
+
return prisma.review.create({
|
|
24
|
+
data: {
|
|
25
|
+
name: data.name,
|
|
26
|
+
otherInfo: data.otherInfo ?? null,
|
|
27
|
+
description: data.description,
|
|
28
|
+
sortOrder: data.sortOrder ?? 0,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async update(id: string, data: Partial<ReviewInput>): Promise<Review> {
|
|
34
|
+
return prisma.review.update({
|
|
35
|
+
where: { id },
|
|
36
|
+
data: {
|
|
37
|
+
...(data.name !== undefined && { name: data.name }),
|
|
38
|
+
...(data.otherInfo !== undefined && { otherInfo: data.otherInfo }),
|
|
39
|
+
...(data.description !== undefined && { description: data.description }),
|
|
40
|
+
...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async delete(id: string): Promise<Review> {
|
|
46
|
+
return prisma.review.delete({ where: { id } });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const reviewRepository = new ReviewRepository();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Review } from "@prisma/client";
|
|
2
|
+
import { reviewRepository, ReviewRepository, type ReviewInput } from "./review.repository";
|
|
3
|
+
|
|
4
|
+
export class ReviewService {
|
|
5
|
+
private repo: ReviewRepository;
|
|
6
|
+
|
|
7
|
+
constructor(repo: ReviewRepository = reviewRepository) {
|
|
8
|
+
this.repo = repo;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async listReviews(): Promise<Review[]> {
|
|
12
|
+
return this.repo.getAll();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async getReview(id: string): Promise<Review | null> {
|
|
16
|
+
return this.repo.getById(id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async createReview(data: ReviewInput): Promise<Review> {
|
|
20
|
+
if (!data.name?.trim()) throw new Error("Reviewer name is required.");
|
|
21
|
+
if (!data.description?.trim()) throw new Error("Review text is required.");
|
|
22
|
+
return this.repo.create(data);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async updateReview(id: string, data: Partial<ReviewInput>): Promise<Review> {
|
|
26
|
+
const existing = await this.repo.getById(id);
|
|
27
|
+
if (!existing) throw new Error("Review not found.");
|
|
28
|
+
return this.repo.update(id, data);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async deleteReview(id: string): Promise<Review> {
|
|
32
|
+
const existing = await this.repo.getById(id);
|
|
33
|
+
if (!existing) throw new Error("Review not found.");
|
|
34
|
+
return this.repo.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const reviewService = new ReviewService();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { buildDefaultJsonLdGraph, serializeJsonLd } from "@/lib/seo/json-ld";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Injects default Organization + WebSite JSON-LD on every page.
|
|
5
|
+
* Add to app/layout.tsx inside <body>.
|
|
6
|
+
*/
|
|
7
|
+
export default function SiteJsonLd() {
|
|
8
|
+
const graph = buildDefaultJsonLdGraph();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<script
|
|
12
|
+
type="application/ld+json"
|
|
13
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(graph) }}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { SITE } from "@/constants";
|
|
3
|
+
import { absoluteUrl } from "@/lib/seo/urls";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Production robots.txt — blocks admin, API, and optional paths from SITE.seo.robots.
|
|
7
|
+
*/
|
|
8
|
+
export default function robots(): MetadataRoute.Robots {
|
|
9
|
+
const disallow = [
|
|
10
|
+
"/admin/",
|
|
11
|
+
"/api/",
|
|
12
|
+
...(SITE.seo.robots?.disallow ?? []),
|
|
13
|
+
].filter(Boolean);
|
|
14
|
+
|
|
15
|
+
const allow = SITE.seo.robots?.allow ?? ["/"];
|
|
16
|
+
|
|
17
|
+
const rules: MetadataRoute.Robots["rules"] = {
|
|
18
|
+
userAgent: "*",
|
|
19
|
+
allow,
|
|
20
|
+
disallow,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const host = absoluteUrl().replace(/^https?:\/\//, "");
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
rules,
|
|
27
|
+
sitemap: absoluteUrl("/sitemap.xml"),
|
|
28
|
+
host,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { buildAppSitemap } from "@/lib/seo/sitemap";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dynamic sitemap.xml — extend with registerDynamicSitemapProvider() in a template bootstrap file.
|
|
6
|
+
*/
|
|
7
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
8
|
+
return buildAppSitemap();
|
|
9
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { SITE } from "@/constants";
|
|
2
|
+
import type { JsonLdObject, SchemaOrgBusinessType } from "./types";
|
|
3
|
+
import { absoluteUrl } from "./urls";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Safe JSON serialization for inline <script type="application/ld+json">.
|
|
7
|
+
* Escapes characters that could break out of a script context.
|
|
8
|
+
*/
|
|
9
|
+
export function serializeJsonLd(data: JsonLdObject | JsonLdObject[]): string {
|
|
10
|
+
return JSON.stringify(data)
|
|
11
|
+
.replace(/</g, "\\u003c")
|
|
12
|
+
.replace(/>/g, "\\u003e")
|
|
13
|
+
.replace(/&/g, "\\u0026")
|
|
14
|
+
.replace(/\u2028/g, "\\u2028")
|
|
15
|
+
.replace(/\u2029/g, "\\u2029");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function businessType(): SchemaOrgBusinessType {
|
|
19
|
+
const t = SITE.seo.schemaType;
|
|
20
|
+
if (
|
|
21
|
+
t === "Organization" ||
|
|
22
|
+
t === "LocalBusiness" ||
|
|
23
|
+
t === "LodgingBusiness" ||
|
|
24
|
+
t === "Hotel" ||
|
|
25
|
+
t === "RealEstateAgent" ||
|
|
26
|
+
t === "Restaurant" ||
|
|
27
|
+
t === "Store"
|
|
28
|
+
) {
|
|
29
|
+
return t;
|
|
30
|
+
}
|
|
31
|
+
return "Organization";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Primary business / organization entity for the site.
|
|
36
|
+
*/
|
|
37
|
+
export function buildOrganizationJsonLd(): JsonLdObject {
|
|
38
|
+
const type = businessType();
|
|
39
|
+
const logo = SITE.seo.openGraph?.image || SITE.assets.logoOfficial || SITE.assets.logo;
|
|
40
|
+
|
|
41
|
+
const payload: JsonLdObject = {
|
|
42
|
+
"@context": "https://schema.org",
|
|
43
|
+
"@type": type,
|
|
44
|
+
name: SITE.brand.name,
|
|
45
|
+
url: absoluteUrl(),
|
|
46
|
+
description: SITE.seo.defaultDescription,
|
|
47
|
+
logo: absoluteUrl(logo),
|
|
48
|
+
image: absoluteUrl(logo),
|
|
49
|
+
telephone: `+${SITE.contact.countryCode}${SITE.contact.phone}`,
|
|
50
|
+
email: SITE.contact.email,
|
|
51
|
+
address: {
|
|
52
|
+
"@type": "PostalAddress",
|
|
53
|
+
streetAddress: SITE.contact.address.full,
|
|
54
|
+
addressLocality: SITE.contact.address.locality,
|
|
55
|
+
addressRegion: SITE.contact.address.region,
|
|
56
|
+
addressCountry: SITE.contact.address.country,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (SITE.seo.priceRange) {
|
|
61
|
+
payload.priceRange = SITE.seo.priceRange;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (SITE.seo.sameAs?.length) {
|
|
65
|
+
payload.sameAs = SITE.seo.sameAs.filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return payload;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* WebSite entity with optional SearchAction when search path is configured.
|
|
73
|
+
*/
|
|
74
|
+
export function buildWebSiteJsonLd(): JsonLdObject {
|
|
75
|
+
const payload: JsonLdObject = {
|
|
76
|
+
"@context": "https://schema.org",
|
|
77
|
+
"@type": "WebSite",
|
|
78
|
+
name: SITE.brand.name,
|
|
79
|
+
alternateName: SITE.brand.shortName,
|
|
80
|
+
url: absoluteUrl(),
|
|
81
|
+
description: SITE.seo.defaultDescription,
|
|
82
|
+
inLanguage: SITE.seo.locale || "en",
|
|
83
|
+
publisher: {
|
|
84
|
+
"@type": "Organization",
|
|
85
|
+
name: SITE.brand.developerName || SITE.brand.name,
|
|
86
|
+
logo: absoluteUrl(SITE.assets.logo),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const searchPath = SITE.seo.searchPath?.trim();
|
|
91
|
+
if (searchPath) {
|
|
92
|
+
payload.potentialAction = {
|
|
93
|
+
"@type": "SearchAction",
|
|
94
|
+
target: {
|
|
95
|
+
"@type": "EntryPoint",
|
|
96
|
+
urlTemplate: absoluteUrl(`${searchPath}?q={search_term_string}`),
|
|
97
|
+
},
|
|
98
|
+
"query-input": "required name=search_term_string",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return payload;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* BreadcrumbList for detail pages.
|
|
107
|
+
*/
|
|
108
|
+
export function buildBreadcrumbJsonLd(
|
|
109
|
+
items: { name: string; path: string }[]
|
|
110
|
+
): JsonLdObject {
|
|
111
|
+
return {
|
|
112
|
+
"@context": "https://schema.org",
|
|
113
|
+
"@type": "BreadcrumbList",
|
|
114
|
+
itemListElement: items.map((item, index) => ({
|
|
115
|
+
"@type": "ListItem",
|
|
116
|
+
position: index + 1,
|
|
117
|
+
name: item.name,
|
|
118
|
+
item: absoluteUrl(item.path),
|
|
119
|
+
})),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Default graph injected on every page (Organization + WebSite).
|
|
125
|
+
*/
|
|
126
|
+
export function buildDefaultJsonLdGraph(): JsonLdObject[] {
|
|
127
|
+
return [buildOrganizationJsonLd(), buildWebSiteJsonLd()];
|
|
128
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { SITE } from "@/constants";
|
|
3
|
+
import type { PageMetadataInput } from "./types";
|
|
4
|
+
import { absoluteUrl } from "./urls";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build Next.js Metadata for a public page with canonical URL, Open Graph, and Twitter cards.
|
|
8
|
+
*/
|
|
9
|
+
export function buildPageMetadata(input: PageMetadataInput = {}): Metadata {
|
|
10
|
+
const title = input.title ?? SITE.seo.defaultTitle;
|
|
11
|
+
const description = input.description ?? SITE.seo.defaultDescription;
|
|
12
|
+
const path = input.path ?? "";
|
|
13
|
+
const canonical = absoluteUrl(path);
|
|
14
|
+
const image =
|
|
15
|
+
input.image ?? SITE.seo.openGraph?.image ?? SITE.assets.logoOfficial ?? SITE.assets.logo;
|
|
16
|
+
const imageUrl = absoluteUrl(image);
|
|
17
|
+
|
|
18
|
+
const fullTitle =
|
|
19
|
+
title === SITE.seo.defaultTitle ? title : `${title} | ${SITE.brand.shortName}`;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
title: fullTitle,
|
|
23
|
+
description,
|
|
24
|
+
metadataBase: new URL(absoluteUrl()),
|
|
25
|
+
alternates: {
|
|
26
|
+
canonical,
|
|
27
|
+
},
|
|
28
|
+
openGraph: {
|
|
29
|
+
type: SITE.seo.openGraph?.type ?? "website",
|
|
30
|
+
locale: SITE.seo.locale?.replace("_", "-") ?? "en_IN",
|
|
31
|
+
url: canonical,
|
|
32
|
+
siteName: SITE.brand.name,
|
|
33
|
+
title: fullTitle,
|
|
34
|
+
description,
|
|
35
|
+
images: [
|
|
36
|
+
{
|
|
37
|
+
url: imageUrl,
|
|
38
|
+
width: 1200,
|
|
39
|
+
height: 630,
|
|
40
|
+
alt: SITE.brand.name,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
twitter: {
|
|
45
|
+
card: "summary_large_image",
|
|
46
|
+
title: fullTitle,
|
|
47
|
+
description,
|
|
48
|
+
images: [imageUrl],
|
|
49
|
+
},
|
|
50
|
+
robots: input.noIndex
|
|
51
|
+
? { index: false, follow: false }
|
|
52
|
+
: {
|
|
53
|
+
index: true,
|
|
54
|
+
follow: true,
|
|
55
|
+
googleBot: {
|
|
56
|
+
index: true,
|
|
57
|
+
follow: true,
|
|
58
|
+
"max-image-preview": "large",
|
|
59
|
+
"max-snippet": -1,
|
|
60
|
+
"max-video-preview": -1,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
keywords: SITE.seo.keywords,
|
|
64
|
+
};
|
|
65
|
+
}
|