@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,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
|
+
© {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
|
+
}
|