@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,72 @@
1
+ import type { BlogPost } from "@prisma/client";
2
+ import { slugify } from "@/lib/utils/slugify";
3
+ import {
4
+ createEmptyComposeDocument,
5
+ isComposeDocument,
6
+ type ComposeDocument,
7
+ } from "@/lib/blog/compose";
8
+ import { blogComposeRepository, BlogComposeRepository } from "./blog-compose.repository";
9
+ import type { BlogPostInput, BlogPostUpdate } from "./blog-compose.types";
10
+
11
+ export class BlogComposeService {
12
+ private repo: BlogComposeRepository;
13
+
14
+ constructor(repo: BlogComposeRepository = blogComposeRepository) {
15
+ this.repo = repo;
16
+ }
17
+
18
+ private parseContent(json: unknown): ComposeDocument {
19
+ if (isComposeDocument(json)) return json;
20
+ return createEmptyComposeDocument();
21
+ }
22
+
23
+ private resolveSlug(title: string, slug?: string): string {
24
+ const base = slug?.trim() || slugify(title);
25
+ if (!base) throw new Error("Slug is required.");
26
+ return base;
27
+ }
28
+
29
+ async listPublic(): Promise<BlogPost[]> {
30
+ return this.repo.listPublished();
31
+ }
32
+
33
+ async listAll(): Promise<BlogPost[]> {
34
+ return this.repo.listAll();
35
+ }
36
+
37
+ async getById(id: string): Promise<BlogPost | null> {
38
+ return this.repo.getById(id);
39
+ }
40
+
41
+ async getPublishedBySlug(slug: string): Promise<BlogPost | null> {
42
+ return this.repo.getBySlug(slug, true);
43
+ }
44
+
45
+ async create(data: BlogPostInput): Promise<BlogPost> {
46
+ if (!data.title?.trim()) throw new Error("Title is required.");
47
+ if (!isComposeDocument(data.content)) throw new Error("Invalid compose document.");
48
+ const slug = this.resolveSlug(data.title, data.slug);
49
+ return this.repo.create({ ...data, slug });
50
+ }
51
+
52
+ async update(id: string, data: BlogPostUpdate): Promise<BlogPost> {
53
+ if (data.content && !isComposeDocument(data.content)) {
54
+ throw new Error("Invalid compose document.");
55
+ }
56
+ const slug =
57
+ data.slug !== undefined
58
+ ? this.resolveSlug(data.title ?? "post", data.slug)
59
+ : undefined;
60
+ return this.repo.update(id, { ...data, slug });
61
+ }
62
+
63
+ async delete(id: string): Promise<BlogPost> {
64
+ return this.repo.delete(id);
65
+ }
66
+
67
+ contentFromPost(post: BlogPost): ComposeDocument {
68
+ return this.parseContent(post.contentJson);
69
+ }
70
+ }
71
+
72
+ export const blogComposeService = new BlogComposeService();
@@ -0,0 +1,20 @@
1
+ import type { ComposeDocument } from "@/lib/blog/compose";
2
+
3
+ export interface BlogPostInput {
4
+ title: string;
5
+ slug?: string;
6
+ excerpt?: string | null;
7
+ coverImage?: string | null;
8
+ engine?: string;
9
+ content: ComposeDocument;
10
+ published?: boolean;
11
+ }
12
+
13
+ export interface BlogPostUpdate {
14
+ title?: string;
15
+ slug?: string;
16
+ excerpt?: string | null;
17
+ coverImage?: string | null;
18
+ content?: ComposeDocument;
19
+ published?: boolean;
20
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./blog-compose.types";
2
+ export {
3
+ BlogComposeRepository,
4
+ blogComposeRepository,
5
+ } from "./blog-compose.repository";
6
+ export { BlogComposeService, blogComposeService } from "./blog-compose.service";
7
+ export { BlogComposeController, blogComposeController } from "./blog-compose.controller";
@@ -0,0 +1,13 @@
1
+ model BlogPost {
2
+ id String @id @default(uuid())
3
+ slug String @unique
4
+ title String
5
+ excerpt String? @db.Text
6
+ coverImage String? @db.Text
7
+ engine String @default("compose")
8
+ contentJson Json
9
+ published Boolean @default(false)
10
+ publishedAt DateTime?
11
+ createdAt DateTime @default(now())
12
+ updatedAt DateTime @updatedAt
13
+ }
@@ -0,0 +1,308 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import { SITE } from "@/constants";
5
+
6
+ export interface ListingOption {
7
+ id?: string;
8
+ name: string;
9
+ }
10
+
11
+ interface EnquiryModalProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ defaultSelection?: string;
15
+ }
16
+
17
+ export default function EnquiryModal({
18
+ isOpen,
19
+ onClose,
20
+ defaultSelection = "",
21
+ }: EnquiryModalProps) {
22
+ const [name, setName] = useState("");
23
+ const [formData, setFormData] = useState({
24
+ email: "",
25
+ phone: "",
26
+ message: "",
27
+ selection: "",
28
+ });
29
+
30
+ const [options, setOptions] = useState<ListingOption[]>([]);
31
+ const [isSubmitting, setIsSubmitting] = useState(false);
32
+ const [isSuccess, setIsSuccess] = useState(false);
33
+ const [error, setError] = useState("");
34
+
35
+ const staticOptions: ListingOption[] = SITE.enquiry.selectionOptions.map((opt) => ({
36
+ name: opt.value,
37
+ }));
38
+
39
+ useEffect(() => {
40
+ const apiPath = SITE.enquiry.listingsApiPath?.trim();
41
+ if (!apiPath) {
42
+ setOptions(staticOptions);
43
+ return;
44
+ }
45
+
46
+ async function fetchListings() {
47
+ try {
48
+ const res = await fetch(apiPath);
49
+ const json = await res.json();
50
+ if (json.success && Array.isArray(json.data) && json.data.length > 0) {
51
+ setOptions(
52
+ json.data.map((item: { id?: string; name: string }) => ({
53
+ id: item.id,
54
+ name: item.name,
55
+ }))
56
+ );
57
+ } else {
58
+ setOptions(staticOptions);
59
+ }
60
+ } catch (err) {
61
+ console.warn("Failed to fetch listings for enquiry modal:", err);
62
+ setOptions(staticOptions);
63
+ }
64
+ }
65
+
66
+ fetchListings();
67
+ }, []);
68
+
69
+ useEffect(() => {
70
+ if (!isOpen) return;
71
+
72
+ const available = options.length > 0 ? options : staticOptions;
73
+ const isValid = available.some((o) => o.name === defaultSelection);
74
+ const fallback = available[0]?.name ?? "";
75
+
76
+ setFormData((prev) => ({
77
+ ...prev,
78
+ selection: defaultSelection && isValid ? defaultSelection : prev.selection || fallback,
79
+ message:
80
+ defaultSelection && !isValid && defaultSelection
81
+ ? `Request for: ${defaultSelection}`
82
+ : prev.message,
83
+ }));
84
+
85
+ setName("");
86
+ setIsSuccess(false);
87
+ setError("");
88
+ }, [isOpen, defaultSelection, options]);
89
+
90
+ if (!isOpen) return null;
91
+
92
+ const selectionOptions = options.length > 0 ? options : staticOptions;
93
+ const showSelection = selectionOptions.length > 0;
94
+ const selectionLabel = SITE.enquiry.selectionLabel || "Interest";
95
+
96
+ const handleSubmit = async (e: React.FormEvent) => {
97
+ e.preventDefault();
98
+ setError("");
99
+
100
+ if (!name.trim()) return setError("Please enter your name.");
101
+ if (showSelection && !formData.selection) {
102
+ return setError(`Please select ${selectionLabel.toLowerCase()}.`);
103
+ }
104
+ if (!formData.phone.trim() || formData.phone.replace(/\D/g, "").length < 10) {
105
+ return setError("Please enter a valid 10-digit phone number.");
106
+ }
107
+
108
+ setIsSubmitting(true);
109
+
110
+ try {
111
+ const projectName = showSelection
112
+ ? formData.selection
113
+ : formData.message.trim() || SITE.brand.name;
114
+
115
+ const res = await fetch("/api/leads", {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({
119
+ projectName,
120
+ name: name.trim(),
121
+ email: formData.email,
122
+ phone: formData.phone,
123
+ message: formData.message || `Request callback for ${projectName}`,
124
+ }),
125
+ });
126
+
127
+ const json = await res.json();
128
+ if (!res.ok || !json.success) {
129
+ throw new Error(json.error || "Failed to submit enquiry.");
130
+ }
131
+
132
+ setIsSuccess(true);
133
+ setFormData({ email: "", phone: "", message: "", selection: "" });
134
+ setName("");
135
+ } catch (err: unknown) {
136
+ const message = err instanceof Error ? err.message : "An unexpected error occurred.";
137
+ setError(message);
138
+ } finally {
139
+ setIsSubmitting(false);
140
+ }
141
+ };
142
+
143
+ const handleChange = (
144
+ e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
145
+ ) => {
146
+ const { name: field, value } = e.target;
147
+ setFormData((prev) => ({ ...prev, [field]: value }));
148
+ };
149
+
150
+ return (
151
+ <div
152
+ className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm animate-fade-in"
153
+ onClick={onClose}
154
+ >
155
+ <div
156
+ className="relative w-full max-w-md overflow-hidden rounded-3xl bg-white p-8 sm:p-10 shadow-2xl border border-primary/10 animate-scale-up"
157
+ onClick={(e) => e.stopPropagation()}
158
+ >
159
+ <button
160
+ onClick={onClose}
161
+ className="absolute top-6 right-6 text-text-muted hover:text-primary transition-colors cursor-pointer"
162
+ aria-label="Close modal"
163
+ >
164
+ <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
165
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
166
+ </svg>
167
+ </button>
168
+
169
+ {isSuccess ? (
170
+ <div className="flex flex-col items-center justify-center text-center py-8 space-y-4">
171
+ <div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center text-green-600 animate-bounce">
172
+ <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
173
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
174
+ </svg>
175
+ </div>
176
+ <h4 className="text-xl font-bold text-primary">Request submitted!</h4>
177
+ <p className="text-sm text-text-muted max-w-sm leading-relaxed">{SITE.enquiry.successMessage}</p>
178
+ <button
179
+ onClick={onClose}
180
+ className="mt-6 px-8 py-2.5 bg-cta-primary hover:bg-cta-primary-hover text-white font-bold text-xs rounded-xl shadow-md transition-colors"
181
+ >
182
+ Close
183
+ </button>
184
+ </div>
185
+ ) : (
186
+ <form onSubmit={handleSubmit} className="space-y-5">
187
+ <div className="text-center mb-6">
188
+ <h3 className="text-2xl font-bold text-primary tracking-tight leading-tight font-serif">
189
+ {SITE.enquiry.modalTitle}
190
+ </h3>
191
+ <p className="text-xs text-text-muted font-semibold mt-1.5">{SITE.enquiry.modalSubtitle}</p>
192
+ </div>
193
+
194
+ {error && (
195
+ <div className="p-3 text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg">{error}</div>
196
+ )}
197
+
198
+ <div>
199
+ <input
200
+ type="text"
201
+ value={name}
202
+ onChange={(e) => setName(e.target.value)}
203
+ placeholder="Name*"
204
+ className="w-full bg-transparent border-b border-primary/20 focus:border-primary focus:outline-none py-2 text-sm text-text-main placeholder-text-muted/60 font-medium transition-colors"
205
+ required
206
+ />
207
+ </div>
208
+
209
+ <div>
210
+ <div className="flex items-center border-b border-primary/20 focus-within:border-primary py-1 transition-colors">
211
+ <div className="flex items-center space-x-1 pr-2 select-none mr-2 border-r border-primary/10">
212
+ <span className="text-sm">🇮🇳</span>
213
+ </div>
214
+ <input
215
+ type="tel"
216
+ name="phone"
217
+ maxLength={10}
218
+ value={formData.phone}
219
+ onChange={handleChange}
220
+ placeholder="Phone number*"
221
+ className="w-full bg-transparent focus:outline-none text-sm text-text-main placeholder-text-muted/60 font-medium"
222
+ required
223
+ />
224
+ </div>
225
+ </div>
226
+
227
+ <div>
228
+ <input
229
+ type="email"
230
+ name="email"
231
+ value={formData.email}
232
+ onChange={handleChange}
233
+ placeholder="Email (optional)"
234
+ className="w-full bg-transparent border-b border-primary/20 focus:border-primary focus:outline-none py-2 text-sm text-text-main placeholder-text-muted/60 font-medium transition-colors"
235
+ />
236
+ </div>
237
+
238
+ {showSelection && (
239
+ <div className="space-y-1">
240
+ <label className="block text-xs font-bold text-text-muted">{selectionLabel}*</label>
241
+ <div className="relative">
242
+ <select
243
+ name="selection"
244
+ value={formData.selection}
245
+ onChange={handleChange}
246
+ className="w-full bg-transparent border-b border-primary/20 focus:border-primary focus:outline-none py-2 text-sm text-text-main font-medium appearance-none cursor-pointer"
247
+ required
248
+ >
249
+ <option value="" disabled>Select</option>
250
+ {selectionOptions.map((opt) => (
251
+ <option key={opt.id ?? opt.name} value={opt.name}>
252
+ {opt.name}
253
+ </option>
254
+ ))}
255
+ </select>
256
+ </div>
257
+ </div>
258
+ )}
259
+
260
+ {!showSelection && (
261
+ <div>
262
+ <textarea
263
+ name="message"
264
+ value={formData.message}
265
+ onChange={handleChange}
266
+ placeholder="Your message (optional)"
267
+ rows={2}
268
+ className="w-full bg-transparent border-b border-primary/20 focus:border-primary focus:outline-none py-2 text-sm text-text-main placeholder-text-muted/60 font-medium transition-colors resize-none"
269
+ />
270
+ </div>
271
+ )}
272
+
273
+ <div className="flex items-start mt-6">
274
+ <input
275
+ type="checkbox"
276
+ id="consent"
277
+ required
278
+ defaultChecked
279
+ className="mt-1 mr-3 rounded border-primary/20 text-primary focus:ring-primary w-4 h-4 cursor-pointer"
280
+ />
281
+ <label htmlFor="consent" className="text-[10px] text-text-muted leading-normal font-medium select-none">
282
+ I consent to processing of my data per{" "}
283
+ <a href={SITE.legal.privacyPolicyPath} target="_blank" rel="noopener noreferrer" className="underline hover:text-primary">
284
+ Privacy Policy
285
+ </a>
286
+ {" | "}
287
+ <a href={SITE.legal.termsPath} target="_blank" rel="noopener noreferrer" className="underline hover:text-primary">
288
+ Terms
289
+ </a>
290
+ . {SITE.legal.consentText}
291
+ </label>
292
+ </div>
293
+
294
+ <div className="flex justify-center pt-4">
295
+ <button
296
+ type="submit"
297
+ disabled={isSubmitting}
298
+ className="w-full max-w-xs py-3 px-8 bg-cta-primary hover:bg-cta-primary-hover text-white font-bold text-sm rounded-xl transition-all duration-300 disabled:opacity-50 cursor-pointer shadow-md text-center transform hover:scale-[1.02] active:scale-[0.98]"
299
+ >
300
+ {isSubmitting ? "Submitting..." : "Enquire now"}
301
+ </button>
302
+ </div>
303
+ </form>
304
+ )}
305
+ </div>
306
+ </div>
307
+ );
308
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { SITE } from "@/constants";
5
+
6
+ interface ReraDisplay {
7
+ name: string;
8
+ rera: string;
9
+ reraId?: string | null;
10
+ reraLabel?: string | null;
11
+ reraQrImage?: string | null;
12
+ }
13
+
14
+ interface FooterProps {
15
+ reraItems?: ReraDisplay[] | null;
16
+ /** @deprecated Use reraItems — single listing on detail pages */
17
+ singleProject?: ReraDisplay | null;
18
+ }
19
+
20
+ function ReraQr({ name, qrImage }: { name: string; qrImage?: string | null }) {
21
+ if (qrImage) {
22
+ return (
23
+ <div className="flex flex-col items-center justify-center p-2 rounded-xl bg-white border border-primary/10 shadow-sm w-36 h-36 overflow-hidden">
24
+ {/* eslint-disable-next-line @next/next/no-img-element */}
25
+ <img src={qrImage} alt={`QR code for ${name}`} className="w-28 h-28 object-contain" />
26
+ </div>
27
+ );
28
+ }
29
+
30
+ return (
31
+ <div className="flex flex-col items-center justify-center p-3.5 rounded-xl bg-white border border-primary/10 shadow-sm text-primary w-36 h-36">
32
+ <svg className="w-20 h-20 text-primary/60" viewBox="0 0 100 100" fill="currentColor">
33
+ <rect x="0" y="0" width="30" height="30" />
34
+ <rect x="5" y="5" width="20" height="20" fill="white" />
35
+ <rect x="10" y="10" width="10" height="10" />
36
+ <rect x="70" y="0" width="30" height="30" />
37
+ <rect x="75" y="5" width="20" height="20" fill="white" />
38
+ <rect x="80" y="10" width="10" height="10" />
39
+ <rect x="0" y="70" width="30" height="30" />
40
+ <rect x="5" y="75" width="20" height="20" fill="white" />
41
+ <rect x="10" y="80" width="10" height="10" />
42
+ <rect x="70" y="70" width="10" height="10" />
43
+ <rect x="85" y="85" width="15" height="15" />
44
+ </svg>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ export default function Footer({ reraItems, singleProject }: FooterProps) {
50
+ const [loadedItems, setLoadedItems] = useState<ReraDisplay[]>([]);
51
+ const listingsApiPath = SITE.footer.listingsApiPath?.trim();
52
+ const detailItems = reraItems ?? (singleProject ? [singleProject] : null);
53
+
54
+ useEffect(() => {
55
+ if (detailItems || !listingsApiPath) return;
56
+
57
+ async function loadListings() {
58
+ try {
59
+ const res = await fetch(listingsApiPath);
60
+ const json = await res.json();
61
+ if (json.success && Array.isArray(json.data) && json.data.length > 0) {
62
+ setLoadedItems(
63
+ json.data.map((item: ReraDisplay) => ({
64
+ name: item.name,
65
+ rera: item.rera,
66
+ reraId: item.reraId,
67
+ reraLabel: item.reraLabel,
68
+ reraQrImage: item.reraQrImage,
69
+ }))
70
+ );
71
+ }
72
+ } catch (error) {
73
+ console.warn("Footer: failed to load listing RERA data", error);
74
+ }
75
+ }
76
+
77
+ loadListings();
78
+ }, [detailItems, listingsApiPath]);
79
+
80
+ const displayQrs: ReraDisplay[] =
81
+ detailItems && detailItems.length > 0
82
+ ? detailItems
83
+ : loadedItems.length > 0
84
+ ? loadedItems
85
+ : SITE.footer.reraFallbacks;
86
+
87
+ return (
88
+ <footer className="bg-footer-bg text-text-main py-16 px-4 md:px-8 border-t border-primary/10">
89
+ <div className="max-w-7xl mx-auto space-y-12">
90
+ {SITE.legal.agentRera && (
91
+ <div className="text-center">
92
+ <span className="text-sm font-bold tracking-widest text-accent-gold-dark uppercase border-b border-accent-gold/20 pb-2 px-4">
93
+ {SITE.footer.reraLabel}: {SITE.legal.agentRera}
94
+ </span>
95
+ </div>
96
+ )}
97
+
98
+ {displayQrs.length > 0 && (
99
+ <div className="flex flex-wrap justify-center gap-6 pt-4">
100
+ {displayQrs.map((qr, index) => (
101
+ <div key={index} className="flex flex-col items-center space-y-2">
102
+ <ReraQr name={qr.name} qrImage={qr.reraQrImage} />
103
+ <div className="text-center w-full flex flex-col items-center">
104
+ <span className="block text-[10px] font-bold text-primary truncate max-w-[144px] text-center mx-auto">
105
+ {qr.reraLabel || qr.name}
106
+ </span>
107
+ <span className="text-[9px] font-medium text-text-muted block select-all text-center mx-auto">
108
+ {qr.reraId || qr.rera}
109
+ </span>
110
+ </div>
111
+ </div>
112
+ ))}
113
+ </div>
114
+ )}
115
+
116
+ <div className="text-[10px] text-text-muted leading-relaxed border-t border-primary/10 pt-8 space-y-3 font-light text-center">
117
+ {SITE.legal.disclaimer.map((paragraph, idx) => (
118
+ <p key={idx} className="max-w-4xl mx-auto">{paragraph}</p>
119
+ ))}
120
+ <p className="text-center pt-4 text-text-main/70 text-[11px] font-medium max-w-4xl mx-auto">
121
+ &copy; {new Date().getFullYear()} {SITE.brand.copyright} {SITE.brand.managedBy}
122
+ </p>
123
+ </div>
124
+ </div>
125
+ </footer>
126
+ );
127
+ }
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import type { GalleryDataInput } from "../hooks/useGallery";
5
+
6
+ interface GalleryFormModalProps {
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ onSubmit: (data: GalleryDataInput) => void;
10
+ initialData: GalleryDataInput | null;
11
+ error?: string;
12
+ isSubmitting?: boolean;
13
+ }
14
+
15
+ export default function GalleryFormModal({
16
+ isOpen,
17
+ onClose,
18
+ onSubmit,
19
+ initialData,
20
+ error,
21
+ isSubmitting,
22
+ }: GalleryFormModalProps) {
23
+ const [form, setForm] = useState<GalleryDataInput>({
24
+ title: "",
25
+ category: "general",
26
+ description: "",
27
+ imageUrl: "",
28
+ sortOrder: 0,
29
+ published: true,
30
+ });
31
+
32
+ useEffect(() => {
33
+ if (isOpen) {
34
+ setForm(
35
+ initialData ?? {
36
+ title: "",
37
+ category: "general",
38
+ description: "",
39
+ imageUrl: "",
40
+ sortOrder: 0,
41
+ published: true,
42
+ }
43
+ );
44
+ }
45
+ }, [isOpen, initialData]);
46
+
47
+ if (!isOpen) return null;
48
+
49
+ return (
50
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40" onClick={onClose}>
51
+ <div
52
+ className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl border border-primary/10"
53
+ onClick={(e) => e.stopPropagation()}
54
+ >
55
+ <h3 className="text-lg font-bold text-primary mb-4">
56
+ {initialData?.id ? "Edit image" : "Add gallery image"}
57
+ </h3>
58
+
59
+ {error && (
60
+ <div className="mb-4 p-3 text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg">{error}</div>
61
+ )}
62
+
63
+ <form
64
+ className="space-y-4"
65
+ onSubmit={(e) => {
66
+ e.preventDefault();
67
+ onSubmit(form);
68
+ }}
69
+ >
70
+ <input
71
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
72
+ placeholder="Title*"
73
+ value={form.title}
74
+ onChange={(e) => setForm({ ...form, title: e.target.value })}
75
+ required
76
+ />
77
+ <input
78
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
79
+ placeholder="Category"
80
+ value={form.category}
81
+ onChange={(e) => setForm({ ...form, category: e.target.value })}
82
+ />
83
+ <input
84
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
85
+ placeholder="Image URL* (/assets/... or https://)"
86
+ value={form.imageUrl}
87
+ onChange={(e) => setForm({ ...form, imageUrl: e.target.value })}
88
+ required
89
+ />
90
+ <textarea
91
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm resize-none"
92
+ placeholder="Description (optional)"
93
+ rows={2}
94
+ value={form.description ?? ""}
95
+ onChange={(e) => setForm({ ...form, description: e.target.value })}
96
+ />
97
+ <input
98
+ type="number"
99
+ className="w-full border border-primary/20 rounded-lg px-3 py-2 text-sm"
100
+ placeholder="Sort order"
101
+ value={form.sortOrder}
102
+ onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) || 0 })}
103
+ />
104
+ <label className="flex items-center gap-2 text-sm">
105
+ <input
106
+ type="checkbox"
107
+ checked={form.published}
108
+ onChange={(e) => setForm({ ...form, published: e.target.checked })}
109
+ />
110
+ Published on site
111
+ </label>
112
+ <div className="flex gap-3 pt-2">
113
+ <button
114
+ type="button"
115
+ onClick={onClose}
116
+ className="flex-1 py-2.5 rounded-lg border border-primary/20 text-sm font-bold cursor-pointer"
117
+ >
118
+ Cancel
119
+ </button>
120
+ <button
121
+ type="submit"
122
+ disabled={isSubmitting}
123
+ className="flex-1 py-2.5 rounded-lg bg-primary text-white text-sm font-bold disabled:opacity-50 cursor-pointer"
124
+ >
125
+ {isSubmitting ? "Saving..." : "Save"}
126
+ </button>
127
+ </div>
128
+ </form>
129
+ </div>
130
+ </div>
131
+ );
132
+ }