@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.
Files changed (90) hide show
  1. package/README.md +66 -18
  2. package/cli/brand-manager.js +183 -96
  3. package/cli/config.js +9 -1
  4. package/cli/doctor.js +393 -0
  5. package/cli/fetch-core-modules.js +130 -0
  6. package/cli/index.js +141 -10
  7. package/cli/info.js +28 -1
  8. package/cli/init-options.js +209 -0
  9. package/cli/module-manager.js +168 -0
  10. package/cli/parse-args.js +21 -0
  11. package/cli/update.js +15 -0
  12. package/cli/version-manager.js +518 -0
  13. package/package.json +27 -4
  14. package/packages/core/modules/README.md +72 -0
  15. package/packages/core/modules/blog-compose/app/admin/components/blog-compose-form.ts +44 -0
  16. package/packages/core/modules/blog-compose/app/admin/hooks/useBlogPosts.ts +103 -0
  17. package/packages/core/modules/blog-compose/app/admin/panels/BlogComposePanel.tsx +388 -0
  18. package/packages/core/modules/blog-compose/app/api/blog-posts/[id]/route.ts +29 -0
  19. package/packages/core/modules/blog-compose/app/api/blog-posts/route.ts +21 -0
  20. package/packages/core/modules/blog-compose/app/blog/[slug]/page.tsx +79 -0
  21. package/packages/core/modules/blog-compose/app/blog/layout.tsx +22 -0
  22. package/packages/core/modules/blog-compose/app/blog/page.tsx +102 -0
  23. package/packages/core/modules/blog-compose/app/components/BlogSection.tsx +74 -0
  24. package/packages/core/modules/blog-compose/app/components/BlogShell.tsx +36 -0
  25. package/packages/core/modules/blog-compose/app/components/BlogSidebar.tsx +112 -0
  26. package/packages/core/modules/blog-compose/lib/blog/compose/annotate-text.tsx +103 -0
  27. package/packages/core/modules/blog-compose/lib/blog/compose/blocks/media-blocks.tsx +97 -0
  28. package/packages/core/modules/blog-compose/lib/blog/compose/blocks/text-blocks.tsx +115 -0
  29. package/packages/core/modules/blog-compose/lib/blog/compose/index.ts +21 -0
  30. package/packages/core/modules/blog-compose/lib/blog/compose/interpreter.tsx +55 -0
  31. package/packages/core/modules/blog-compose/lib/blog/compose/registry.ts +276 -0
  32. package/packages/core/modules/blog-compose/lib/blog/compose/types.ts +128 -0
  33. package/packages/core/modules/blog-compose/lib/blog/register-sitemap.ts +18 -0
  34. package/packages/core/modules/blog-compose/lib/controllers/BlogComposeController.ts +2 -0
  35. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.controller.ts +78 -0
  36. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.repository.ts +78 -0
  37. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.service.ts +72 -0
  38. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.types.ts +20 -0
  39. package/packages/core/modules/blog-compose/lib/features/blog-compose/index.ts +7 -0
  40. package/packages/core/modules/blog-compose/prisma/blog-compose.prisma +13 -0
  41. package/packages/core/modules/enquiry-modal/app/components/EnquiryModal.tsx +308 -0
  42. package/packages/core/modules/footer/app/components/Footer.tsx +127 -0
  43. package/packages/core/modules/gallery/app/admin/components/GalleryFormModal.tsx +132 -0
  44. package/packages/core/modules/gallery/app/admin/components/GalleryList.tsx +142 -0
  45. package/packages/core/modules/gallery/app/admin/hooks/useGallery.ts +72 -0
  46. package/packages/core/modules/gallery/app/api/gallery/[id]/route.ts +29 -0
  47. package/packages/core/modules/gallery/app/api/gallery/route.ts +22 -0
  48. package/packages/core/modules/gallery/app/components/GallerySection.tsx +92 -0
  49. package/packages/core/modules/gallery/app/gallery/layout.tsx +12 -0
  50. package/packages/core/modules/gallery/app/gallery/page.tsx +179 -0
  51. package/packages/core/modules/gallery/lib/controllers/GalleryController.ts +2 -0
  52. package/packages/core/modules/gallery/lib/features/gallery/gallery.controller.ts +78 -0
  53. package/packages/core/modules/gallery/lib/features/gallery/gallery.repository.ts +55 -0
  54. package/packages/core/modules/gallery/lib/features/gallery/gallery.service.ts +43 -0
  55. package/packages/core/modules/gallery/lib/features/gallery/gallery.types.ts +17 -0
  56. package/packages/core/modules/gallery/lib/features/gallery/index.ts +4 -0
  57. package/packages/core/modules/gallery/prisma/gallery.prisma +11 -0
  58. package/packages/core/modules/hero-simple/app/components/Hero.tsx +160 -0
  59. package/packages/core/modules/legal-pages/app/components/LegalDocumentPage.tsx +61 -0
  60. package/packages/core/modules/legal-pages/app/privacy-policy/page.tsx +19 -0
  61. package/packages/core/modules/legal-pages/app/terms-and-conditions/page.tsx +19 -0
  62. package/packages/core/modules/reviews/app/admin/components/ReviewFormModal.tsx +110 -0
  63. package/packages/core/modules/reviews/app/admin/components/ReviewsList.tsx +116 -0
  64. package/packages/core/modules/reviews/app/admin/hooks/useReviews.ts +70 -0
  65. package/packages/core/modules/reviews/app/api/reviews/[id]/route.ts +29 -0
  66. package/packages/core/modules/reviews/app/api/reviews/route.ts +17 -0
  67. package/packages/core/modules/reviews/app/components/ReviewsSection.tsx +100 -0
  68. package/packages/core/modules/reviews/lib/controllers/ReviewController.ts +2 -0
  69. package/packages/core/modules/reviews/lib/features/reviews/index.ts +3 -0
  70. package/packages/core/modules/reviews/lib/features/reviews/review.controller.ts +64 -0
  71. package/packages/core/modules/reviews/lib/features/reviews/review.repository.ts +50 -0
  72. package/packages/core/modules/reviews/lib/features/reviews/review.service.ts +38 -0
  73. package/packages/core/modules/reviews/prisma/reviews.prisma +9 -0
  74. package/packages/core/modules/seo/app/components/SiteJsonLd.tsx +16 -0
  75. package/packages/core/modules/seo/app/robots.ts +30 -0
  76. package/packages/core/modules/seo/app/sitemap.ts +9 -0
  77. package/packages/core/modules/seo/lib/seo/index.ts +5 -0
  78. package/packages/core/modules/seo/lib/seo/json-ld.ts +128 -0
  79. package/packages/core/modules/seo/lib/seo/metadata.ts +65 -0
  80. package/packages/core/modules/seo/lib/seo/sitemap.ts +82 -0
  81. package/packages/core/modules/seo/lib/seo/types.ts +30 -0
  82. package/packages/core/modules/seo/lib/seo/urls.ts +27 -0
  83. package/packages/core/modules/theme-modes/app/components/ThemeModeInit.tsx +24 -0
  84. package/packages/core/modules/theme-modes/app/components/ThemeModeToggle.tsx +58 -0
  85. package/packages/core/modules.json +140 -0
  86. package/packages/core/prisma/schema.prisma +33 -0
  87. package/scripts/module-installer-core.mjs +657 -0
  88. package/scripts/template-modules-admin.mjs +366 -0
  89. package/scripts/template-modules-home.mjs +120 -0
  90. package/templates.json +163 -8
@@ -0,0 +1,55 @@
1
+ import { prisma } from "@/lib/database/prisma";
2
+ import type { GalleryImage } from "@prisma/client";
3
+ import type { GalleryImageInput } from "./gallery.types";
4
+
5
+ export class GalleryRepository {
6
+ async listPublished(): Promise<GalleryImage[]> {
7
+ return prisma.galleryImage.findMany({
8
+ where: { published: true },
9
+ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
10
+ });
11
+ }
12
+
13
+ async listAll(): Promise<GalleryImage[]> {
14
+ return prisma.galleryImage.findMany({
15
+ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
16
+ });
17
+ }
18
+
19
+ async getById(id: string): Promise<GalleryImage | null> {
20
+ return prisma.galleryImage.findUnique({ where: { id } });
21
+ }
22
+
23
+ async create(data: GalleryImageInput): Promise<GalleryImage> {
24
+ return prisma.galleryImage.create({
25
+ data: {
26
+ title: data.title,
27
+ category: data.category ?? "general",
28
+ description: data.description ?? null,
29
+ imageUrl: data.imageUrl,
30
+ sortOrder: data.sortOrder ?? 0,
31
+ published: data.published ?? true,
32
+ },
33
+ });
34
+ }
35
+
36
+ async update(id: string, data: Partial<GalleryImageInput>): Promise<GalleryImage> {
37
+ return prisma.galleryImage.update({
38
+ where: { id },
39
+ data: {
40
+ ...(data.title !== undefined && { title: data.title }),
41
+ ...(data.category !== undefined && { category: data.category }),
42
+ ...(data.description !== undefined && { description: data.description }),
43
+ ...(data.imageUrl !== undefined && { imageUrl: data.imageUrl }),
44
+ ...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }),
45
+ ...(data.published !== undefined && { published: data.published }),
46
+ },
47
+ });
48
+ }
49
+
50
+ async delete(id: string): Promise<GalleryImage> {
51
+ return prisma.galleryImage.delete({ where: { id } });
52
+ }
53
+ }
54
+
55
+ export const galleryRepository = new GalleryRepository();
@@ -0,0 +1,43 @@
1
+ import type { GalleryImage } from "@prisma/client";
2
+ import { galleryRepository, GalleryRepository } from "./gallery.repository";
3
+ import type { GalleryImageInput } from "./gallery.types";
4
+
5
+ export class GalleryService {
6
+ private repo: GalleryRepository;
7
+
8
+ constructor(repo: GalleryRepository = galleryRepository) {
9
+ this.repo = repo;
10
+ }
11
+
12
+ async listPublic(): Promise<GalleryImage[]> {
13
+ return this.repo.listPublished();
14
+ }
15
+
16
+ async listAll(): Promise<GalleryImage[]> {
17
+ return this.repo.listAll();
18
+ }
19
+
20
+ async getById(id: string): Promise<GalleryImage | null> {
21
+ return this.repo.getById(id);
22
+ }
23
+
24
+ async create(data: GalleryImageInput): Promise<GalleryImage> {
25
+ if (!data.title?.trim()) throw new Error("Title is required.");
26
+ if (!data.imageUrl?.trim()) throw new Error("Image URL is required.");
27
+ return this.repo.create(data);
28
+ }
29
+
30
+ async update(id: string, data: Partial<GalleryImageInput>): Promise<GalleryImage> {
31
+ const existing = await this.repo.getById(id);
32
+ if (!existing) throw new Error("Gallery image not found.");
33
+ return this.repo.update(id, data);
34
+ }
35
+
36
+ async delete(id: string): Promise<GalleryImage> {
37
+ const existing = await this.repo.getById(id);
38
+ if (!existing) throw new Error("Gallery image not found.");
39
+ return this.repo.delete(id);
40
+ }
41
+ }
42
+
43
+ export const galleryService = new GalleryService();
@@ -0,0 +1,17 @@
1
+ export interface GalleryImageInput {
2
+ title: string;
3
+ category?: string;
4
+ description?: string | null;
5
+ imageUrl: string;
6
+ sortOrder?: number;
7
+ published?: boolean;
8
+ }
9
+
10
+ export interface GalleryImageUpdate {
11
+ title?: string;
12
+ category?: string;
13
+ description?: string | null;
14
+ imageUrl?: string;
15
+ sortOrder?: number;
16
+ published?: boolean;
17
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./gallery.types";
2
+ export * from "./gallery.repository";
3
+ export * from "./gallery.service";
4
+ export * from "./gallery.controller";
@@ -0,0 +1,11 @@
1
+ model GalleryImage {
2
+ id String @id @default(uuid())
3
+ title String
4
+ category String @default("general")
5
+ description String? @db.Text
6
+ imageUrl String @db.Text
7
+ sortOrder Int @default(0)
8
+ published Boolean @default(true)
9
+ createdAt DateTime @default(now())
10
+ updatedAt DateTime @updatedAt
11
+ }
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import Image from "next/image";
5
+ import { MapPin, Sparkles, ShieldCheck, Tag, Calendar, Download, Phone } from "lucide-react";
6
+ import { SITE } from "@/constants";
7
+
8
+ interface HeroProps {
9
+ onOpenEnquiry: (label?: string) => void;
10
+ }
11
+
12
+ const featureIcons = {
13
+ map: MapPin,
14
+ sparkles: Sparkles,
15
+ shield: ShieldCheck,
16
+ tag: Tag,
17
+ } as const;
18
+
19
+ export default function Hero({ onOpenEnquiry }: HeroProps) {
20
+ const [currentBgIndex, setCurrentBgIndex] = useState(0);
21
+
22
+ const desktopImages = SITE.assets.heroDesktop;
23
+ const phoneImages = SITE.assets.heroMobile;
24
+ const hasImages = desktopImages.length > 0 || phoneImages.length > 0;
25
+ const slideCount = Math.max(desktopImages.length, phoneImages.length, 1);
26
+
27
+ useEffect(() => {
28
+ if (!hasImages || slideCount <= 1) return;
29
+
30
+ const interval = setInterval(() => {
31
+ setCurrentBgIndex((prev) => (prev + 1) % slideCount);
32
+ }, SITE.hero.slideDuration || 5000);
33
+
34
+ return () => clearInterval(interval);
35
+ }, [hasImages, slideCount]);
36
+
37
+ return (
38
+ <section className="relative min-h-screen md:h-screen w-full flex items-center justify-start px-6 md:px-16 lg:px-24 py-24 md:py-0 overflow-x-hidden bg-bg-light hero-gradient">
39
+ <div className="absolute inset-0 z-0">
40
+ {hasImages ? (
41
+ <>
42
+ <div className="hidden md:block absolute inset-0">
43
+ {desktopImages.map((src, index) => (
44
+ <Image
45
+ key={`desktop-${src}`}
46
+ src={src}
47
+ alt={`${SITE.brand.name} desktop`}
48
+ fill
49
+ priority={index === 0}
50
+ sizes="100vw"
51
+ className={`object-cover object-center transition-opacity duration-1000 ease-in-out ${
52
+ index === currentBgIndex ? "opacity-100" : "opacity-0"
53
+ }`}
54
+ />
55
+ ))}
56
+ </div>
57
+
58
+ <div className="block md:hidden absolute inset-0">
59
+ {(phoneImages.length > 0 ? phoneImages : desktopImages).map((src, index) => (
60
+ <Image
61
+ key={`phone-${src}`}
62
+ src={src}
63
+ alt={`${SITE.brand.name} mobile`}
64
+ fill
65
+ priority={index === 0}
66
+ sizes="100vw"
67
+ className={`object-cover object-center transition-opacity duration-1000 ease-in-out ${
68
+ index === currentBgIndex ? "opacity-100" : "opacity-0"
69
+ }`}
70
+ />
71
+ ))}
72
+ </div>
73
+
74
+ <div className="absolute inset-0 z-10 bg-white/30 md:bg-gradient-to-r md:from-white/80 md:via-white/50 md:to-white/20" />
75
+ </>
76
+ ) : (
77
+ <div className="absolute inset-0 hero-gradient" />
78
+ )}
79
+ </div>
80
+
81
+ <div className="relative z-20 w-full max-w-4xl mt-20 text-left flex flex-col items-start space-y-6">
82
+ <span className="text-primary font-extrabold text-[10px] md:text-xs tracking-[0.25em] uppercase font-sans">
83
+ {SITE.hero.eyebrow}
84
+ </span>
85
+
86
+ <h1 className="text-3xl sm:text-5xl md:text-[56px] font-extrabold tracking-tight font-serif text-primary max-w-3xl leading-tight">
87
+ {SITE.hero.headline}
88
+ </h1>
89
+
90
+ <p className="text-text-main/90 text-xs sm:text-sm md:text-base max-w-2xl leading-relaxed font-medium">
91
+ {SITE.hero.subheadline}
92
+ </p>
93
+
94
+ {SITE.hero.features.length > 0 && (
95
+ <div className="grid grid-cols-2 gap-y-3.5 gap-x-4 md:flex md:flex-wrap md:items-center md:gap-x-5 md:gap-y-2.5 pt-1 text-text-main w-full">
96
+ {SITE.hero.features.map((feature, idx) => {
97
+ const Icon = featureIcons[feature.icon as keyof typeof featureIcons] || Sparkles;
98
+ return (
99
+ <React.Fragment key={feature.label}>
100
+ {idx > 0 && <span className="text-primary/20 hidden md:inline">|</span>}
101
+ <div className="flex items-center space-x-2">
102
+ <Icon className="w-4 h-4 text-accent-gold flex-shrink-0" />
103
+ <span className="text-[10px] font-extrabold tracking-wider uppercase">{feature.label}</span>
104
+ </div>
105
+ </React.Fragment>
106
+ );
107
+ })}
108
+ </div>
109
+ )}
110
+
111
+ {SITE.hero.ctaButtons.length > 0 && (
112
+ <div className="flex flex-col sm:flex-row gap-3.5 w-full sm:w-auto pt-2">
113
+ {SITE.hero.ctaButtons.map((cta) => (
114
+ <button
115
+ key={cta.label}
116
+ onClick={() => onOpenEnquiry(cta.enquiryLabel)}
117
+ className={
118
+ cta.label.toLowerCase().includes("book")
119
+ ? "w-full sm:w-auto justify-center px-5 py-3.5 bg-primary hover:bg-primary-hover text-white font-extrabold text-[10px] uppercase tracking-widest rounded-lg transition-all duration-300 shadow-md flex items-center space-x-2 cursor-pointer hover:scale-105"
120
+ : "w-full sm:w-auto justify-center px-5 py-3.5 bg-white/80 hover:bg-white text-primary border border-primary/20 hover:border-primary/40 font-extrabold text-[10px] uppercase tracking-widest rounded-lg transition-all duration-300 flex items-center space-x-2 cursor-pointer shadow-sm hover:scale-105"
121
+ }
122
+ >
123
+ {cta.label.toLowerCase().includes("book") ? (
124
+ <Calendar className="w-3.5 h-3.5" />
125
+ ) : cta.label.toLowerCase().includes("brochure") ? (
126
+ <Download className="w-3.5 h-3.5" />
127
+ ) : (
128
+ <Phone className="w-3.5 h-3.5 text-accent-gold" />
129
+ )}
130
+ <span>{cta.label}</span>
131
+ </button>
132
+ ))}
133
+ </div>
134
+ )}
135
+
136
+ {SITE.hero.locations.length > 0 && (
137
+ <div className="glass-card-light rounded-xl p-4 md:p-5 w-full max-w-3xl mt-6 shadow-sm mb-8 sm:mb-0">
138
+ <span className="block text-[9px] font-extrabold tracking-widest text-accent-gold-dark uppercase mb-2">
139
+ {SITE.hero.locationsTitle}
140
+ </span>
141
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2.5 text-[11px] font-bold text-text-main">
142
+ {SITE.hero.locations.map((loc, idx) => (
143
+ <React.Fragment key={loc.label}>
144
+ {idx > 0 && <span className="text-primary/20 hidden sm:inline">|</span>}
145
+ <button
146
+ className="flex items-center space-x-1 hover:text-primary transition-colors cursor-pointer"
147
+ onClick={() => onOpenEnquiry(loc.enquiryLabel)}
148
+ >
149
+ <MapPin className="w-3 h-3 text-accent-gold" />
150
+ <span>{loc.label}</span>
151
+ </button>
152
+ </React.Fragment>
153
+ ))}
154
+ </div>
155
+ </div>
156
+ )}
157
+ </div>
158
+ </section>
159
+ );
160
+ }
@@ -0,0 +1,61 @@
1
+ import Link from "next/link";
2
+ import { SITE } from "@/constants";
3
+
4
+ interface LegalSection {
5
+ heading?: string;
6
+ paragraphs: readonly string[];
7
+ }
8
+
9
+ interface LegalDocumentPageProps {
10
+ title: string;
11
+ lastUpdated?: string;
12
+ sections: readonly LegalSection[];
13
+ backLabel?: string;
14
+ }
15
+
16
+ export default function LegalDocumentPage({
17
+ title,
18
+ lastUpdated,
19
+ sections,
20
+ backLabel,
21
+ }: LegalDocumentPageProps) {
22
+ return (
23
+ <div className="min-h-screen bg-bg-main">
24
+ <header className="border-b border-primary/10 bg-white/90 backdrop-blur-md sticky top-0 z-10">
25
+ <div className="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
26
+ <Link href="/" className="text-sm font-semibold text-primary hover:text-primary-hover">
27
+ {backLabel ?? SITE.navigation.backToHome}
28
+ </Link>
29
+ <h1 className="text-lg font-bold font-serif text-primary text-right">{title}</h1>
30
+ </div>
31
+ </header>
32
+
33
+ <main className="max-w-3xl mx-auto px-6 py-12">
34
+ {lastUpdated && (
35
+ <p className="text-xs text-text-muted mb-8 uppercase tracking-wider">
36
+ Last updated: {lastUpdated}
37
+ </p>
38
+ )}
39
+
40
+ <article className="prose prose-slate max-w-none space-y-8">
41
+ {sections.map((section, idx) => (
42
+ <section key={idx}>
43
+ {section.heading && (
44
+ <h2 className="text-xl font-bold text-primary font-serif mb-3">{section.heading}</h2>
45
+ )}
46
+ {section.paragraphs.map((paragraph, pIdx) => (
47
+ <p key={pIdx} className="text-sm text-text-main/90 leading-relaxed mb-3">
48
+ {paragraph}
49
+ </p>
50
+ ))}
51
+ </section>
52
+ ))}
53
+ </article>
54
+
55
+ <p className="mt-12 text-xs text-text-muted border-t border-primary/10 pt-6">
56
+ Questions? Contact us at {SITE.contact.email} or {SITE.contact.phoneDisplay}.
57
+ </p>
58
+ </main>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,19 @@
1
+ import type { Metadata } from "next";
2
+ import LegalDocumentPage from "@/app/components/LegalDocumentPage";
3
+ import { SITE } from "@/constants";
4
+
5
+ export const metadata: Metadata = {
6
+ title: `${SITE.privacyPage.title} | ${SITE.brand.shortName}`,
7
+ description: `Privacy policy for ${SITE.brand.name}.`,
8
+ alternates: { canonical: `${SITE.domain.baseUrl.replace(/\/$/, "")}${SITE.legal.privacyPolicyPath}` },
9
+ };
10
+
11
+ export default function PrivacyPolicyPage() {
12
+ return (
13
+ <LegalDocumentPage
14
+ title={SITE.privacyPage.title}
15
+ lastUpdated={SITE.privacyPage.lastUpdated || undefined}
16
+ sections={SITE.privacyPage.sections}
17
+ />
18
+ );
19
+ }
@@ -0,0 +1,19 @@
1
+ import type { Metadata } from "next";
2
+ import LegalDocumentPage from "@/app/components/LegalDocumentPage";
3
+ import { SITE } from "@/constants";
4
+
5
+ export const metadata: Metadata = {
6
+ title: `${SITE.termsPage.title} | ${SITE.brand.shortName}`,
7
+ description: `Terms and conditions for ${SITE.brand.name}.`,
8
+ alternates: { canonical: `${SITE.domain.baseUrl.replace(/\/$/, "")}${SITE.legal.termsPath}` },
9
+ };
10
+
11
+ export default function TermsPage() {
12
+ return (
13
+ <LegalDocumentPage
14
+ title={SITE.termsPage.title}
15
+ lastUpdated={SITE.termsPage.lastUpdated || undefined}
16
+ sections={SITE.termsPage.sections}
17
+ />
18
+ );
19
+ }
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import type { ReviewDataInput } from "../hooks/useReviews";
5
+
6
+ interface ReviewFormModalProps {
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ onSubmit: (data: ReviewDataInput) => void;
10
+ initialData: ReviewDataInput | null;
11
+ error?: string;
12
+ isSubmitting?: boolean;
13
+ }
14
+
15
+ export default function ReviewFormModal({
16
+ isOpen,
17
+ onClose,
18
+ onSubmit,
19
+ initialData,
20
+ error,
21
+ isSubmitting,
22
+ }: ReviewFormModalProps) {
23
+ const [form, setForm] = useState<ReviewDataInput>({
24
+ name: "",
25
+ otherInfo: "",
26
+ description: "",
27
+ sortOrder: 0,
28
+ });
29
+
30
+ useEffect(() => {
31
+ if (isOpen) {
32
+ setForm(
33
+ initialData ?? {
34
+ name: "",
35
+ otherInfo: "",
36
+ description: "",
37
+ sortOrder: 0,
38
+ }
39
+ );
40
+ }
41
+ }, [isOpen, initialData]);
42
+
43
+ if (!isOpen) return null;
44
+
45
+ return (
46
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40" onClick={onClose}>
47
+ <div
48
+ className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl border border-primary/10"
49
+ onClick={(e) => e.stopPropagation()}
50
+ >
51
+ <h3 className="text-lg font-bold text-primary mb-4">
52
+ {initialData?.id ? "Edit review" : "Add review"}
53
+ </h3>
54
+
55
+ {error && (
56
+ <div className="mb-4 p-3 text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg">{error}</div>
57
+ )}
58
+
59
+ <form
60
+ className="space-y-4"
61
+ onSubmit={(e) => {
62
+ e.preventDefault();
63
+ onSubmit(form);
64
+ }}
65
+ >
66
+ <input
67
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
68
+ placeholder="Name*"
69
+ value={form.name}
70
+ onChange={(e) => setForm({ ...form, name: e.target.value })}
71
+ required
72
+ />
73
+ <input
74
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
75
+ placeholder="Role / location (optional)"
76
+ value={form.otherInfo ?? ""}
77
+ onChange={(e) => setForm({ ...form, otherInfo: e.target.value })}
78
+ />
79
+ <textarea
80
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm resize-none"
81
+ placeholder="Review text*"
82
+ rows={4}
83
+ value={form.description}
84
+ onChange={(e) => setForm({ ...form, description: e.target.value })}
85
+ required
86
+ />
87
+ <input
88
+ type="number"
89
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
90
+ placeholder="Sort order"
91
+ value={form.sortOrder}
92
+ onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) || 0 })}
93
+ />
94
+ <div className="flex gap-3 pt-2">
95
+ <button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-lg border text-sm font-bold cursor-pointer">
96
+ Cancel
97
+ </button>
98
+ <button
99
+ type="submit"
100
+ disabled={isSubmitting}
101
+ className="flex-1 py-2.5 rounded-lg bg-primary text-white text-sm font-bold disabled:opacity-50 cursor-pointer"
102
+ >
103
+ {isSubmitting ? "Saving..." : "Save"}
104
+ </button>
105
+ </div>
106
+ </form>
107
+ </div>
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { Plus, Edit2, Trash2, MessageSquareQuote } from "lucide-react";
5
+ import ReviewFormModal from "./ReviewFormModal";
6
+ import {
7
+ useGetReviews,
8
+ useCreateReview,
9
+ useUpdateReview,
10
+ useDeleteReview,
11
+ ReviewDataInput,
12
+ } from "../hooks/useReviews";
13
+
14
+ export default function ReviewsList() {
15
+ const { data: reviews = [], isLoading, error } = useGetReviews();
16
+ const createMutation = useCreateReview();
17
+ const updateMutation = useUpdateReview();
18
+ const deleteMutation = useDeleteReview();
19
+
20
+ const [modalOpen, setModalOpen] = useState(false);
21
+ const [selected, setSelected] = useState<ReviewDataInput | null>(null);
22
+ const [submitError, setSubmitError] = useState("");
23
+
24
+ const handleSubmit = async (data: ReviewDataInput) => {
25
+ setSubmitError("");
26
+ try {
27
+ if (data.id) await updateMutation.mutateAsync(data);
28
+ else await createMutation.mutateAsync(data);
29
+ setModalOpen(false);
30
+ } catch (err: unknown) {
31
+ setSubmitError(err instanceof Error ? err.message : "Failed to save");
32
+ }
33
+ };
34
+
35
+ if (isLoading) {
36
+ return <div className="p-8 text-sm text-text-muted">Loading reviews...</div>;
37
+ }
38
+
39
+ return (
40
+ <div className="space-y-6">
41
+ <div className="flex items-center justify-between">
42
+ <div>
43
+ <h2 className="text-xl font-bold font-serif text-primary flex items-center gap-2">
44
+ <MessageSquareQuote className="w-5 h-5" />
45
+ Reviews
46
+ </h2>
47
+ <p className="text-xs text-text-muted mt-1">Testimonials shown on the homepage.</p>
48
+ </div>
49
+ <button
50
+ type="button"
51
+ onClick={() => {
52
+ setSelected(null);
53
+ setSubmitError("");
54
+ setModalOpen(true);
55
+ }}
56
+ className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white text-xs font-bold rounded-lg cursor-pointer"
57
+ >
58
+ <Plus className="w-4 h-4" />
59
+ Add review
60
+ </button>
61
+ </div>
62
+
63
+ {error && (
64
+ <div className="p-3 text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg">
65
+ {(error as Error).message}
66
+ </div>
67
+ )}
68
+
69
+ {reviews.length === 0 ? (
70
+ <div className="text-center py-12 border border-dashed rounded-2xl text-xs text-text-muted">
71
+ No reviews yet.
72
+ </div>
73
+ ) : (
74
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
75
+ {reviews.map((rev) => (
76
+ <div key={rev.id} className="rounded-2xl border border-primary/10 bg-white p-5 shadow-sm">
77
+ <p className="text-sm text-text-main leading-relaxed line-clamp-4">{rev.description}</p>
78
+ <p className="font-bold text-primary mt-3 text-sm">{rev.name}</p>
79
+ {rev.otherInfo && <p className="text-xs text-text-muted">{rev.otherInfo}</p>}
80
+ <div className="flex gap-3 mt-4">
81
+ <button
82
+ type="button"
83
+ onClick={() => {
84
+ setSelected(rev);
85
+ setModalOpen(true);
86
+ }}
87
+ className="text-xs font-bold text-primary flex items-center gap-1 cursor-pointer"
88
+ >
89
+ <Edit2 className="w-3.5 h-3.5" />
90
+ Edit
91
+ </button>
92
+ <button
93
+ type="button"
94
+ onClick={() => rev.id && deleteMutation.mutateAsync(rev.id)}
95
+ className="text-xs font-bold text-red-600 flex items-center gap-1 cursor-pointer"
96
+ >
97
+ <Trash2 className="w-3.5 h-3.5" />
98
+ Delete
99
+ </button>
100
+ </div>
101
+ </div>
102
+ ))}
103
+ </div>
104
+ )}
105
+
106
+ <ReviewFormModal
107
+ isOpen={modalOpen}
108
+ onClose={() => setModalOpen(false)}
109
+ onSubmit={handleSubmit}
110
+ initialData={selected}
111
+ error={submitError}
112
+ isSubmitting={createMutation.isPending || updateMutation.isPending}
113
+ />
114
+ </div>
115
+ );
116
+ }