@djangocfg/layouts 1.4.24 → 1.4.27
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.27",
|
|
4
4
|
"description": "Pre-built dashboard layouts, authentication pages, and admin templates for Next.js applications with Tailwind CSS",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -85,9 +85,9 @@
|
|
|
85
85
|
"check": "tsc --noEmit"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
|
-
"@djangocfg/api": "^1.4.
|
|
89
|
-
"@djangocfg/og-image": "^1.4.
|
|
90
|
-
"@djangocfg/ui": "^1.4.
|
|
88
|
+
"@djangocfg/api": "^1.4.27",
|
|
89
|
+
"@djangocfg/og-image": "^1.4.27",
|
|
90
|
+
"@djangocfg/ui": "^1.4.27",
|
|
91
91
|
"@hookform/resolvers": "^5.2.0",
|
|
92
92
|
"consola": "^3.4.2",
|
|
93
93
|
"lucide-react": "^0.468.0",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"vidstack": "0.6.15"
|
|
110
110
|
},
|
|
111
111
|
"devDependencies": {
|
|
112
|
-
"@djangocfg/typescript-config": "^1.4.
|
|
112
|
+
"@djangocfg/typescript-config": "^1.4.27",
|
|
113
113
|
"@types/node": "^24.7.2",
|
|
114
114
|
"@types/react": "19.2.2",
|
|
115
115
|
"@types/react-dom": "19.2.1",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment } from 'react';
|
|
2
2
|
import Head from 'next/head';
|
|
3
|
+
import { useRouter } from 'next/router';
|
|
3
4
|
import { generateOgImageUrl } from '@djangocfg/og-image/utils';
|
|
4
5
|
|
|
5
6
|
import { PageConfig } from '../../../types/pageConfig';
|
|
@@ -13,6 +14,8 @@ interface SeoProps {
|
|
|
13
14
|
logoVector?: string;
|
|
14
15
|
};
|
|
15
16
|
siteUrl?: string;
|
|
17
|
+
/** Override canonical URL (defaults to current page URL) */
|
|
18
|
+
canonicalUrl?: string;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -28,8 +31,7 @@ function isAbsoluteUrl(url: string): boolean {
|
|
|
28
31
|
* Priority:
|
|
29
32
|
* 1. Provided siteUrl (if absolute)
|
|
30
33
|
* 2. NEXT_PUBLIC_SITE_URL env var
|
|
31
|
-
* 3.
|
|
32
|
-
* 4. window.location.origin (client-side only)
|
|
34
|
+
* 3. window.location.origin (client-side only)
|
|
33
35
|
*/
|
|
34
36
|
function getAbsoluteSiteUrl(siteUrl?: string): string | null {
|
|
35
37
|
// 1. Check if provided siteUrl is already absolute
|
|
@@ -43,13 +45,7 @@ function getAbsoluteSiteUrl(siteUrl?: string): string | null {
|
|
|
43
45
|
return envSiteUrl.replace(/\/$/, '');
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
// 3.
|
|
47
|
-
const vercelUrl = process.env.NEXT_PUBLIC_VERCEL_URL;
|
|
48
|
-
if (vercelUrl) {
|
|
49
|
-
return `https://${vercelUrl}`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// 4. Client-side fallback (not available during SSR)
|
|
48
|
+
// 3. Client-side fallback (not available during SSR)
|
|
53
49
|
if (typeof window !== 'undefined') {
|
|
54
50
|
return window.location.origin;
|
|
55
51
|
}
|
|
@@ -57,7 +53,9 @@ function getAbsoluteSiteUrl(siteUrl?: string): string | null {
|
|
|
57
53
|
return null;
|
|
58
54
|
}
|
|
59
55
|
|
|
60
|
-
export default function Seo({ pageConfig, icons, siteUrl }: SeoProps) {
|
|
56
|
+
export default function Seo({ pageConfig, icons, siteUrl, canonicalUrl }: SeoProps) {
|
|
57
|
+
const router = useRouter();
|
|
58
|
+
|
|
61
59
|
const {
|
|
62
60
|
title,
|
|
63
61
|
description,
|
|
@@ -74,6 +72,11 @@ export default function Seo({ pageConfig, icons, siteUrl }: SeoProps) {
|
|
|
74
72
|
// Get absolute site URL with smart fallbacks
|
|
75
73
|
const absoluteSiteUrl = getAbsoluteSiteUrl(siteUrl);
|
|
76
74
|
|
|
75
|
+
// Build canonical URL: custom > siteUrl + current path
|
|
76
|
+
const currentPath = router.asPath.split('?')[0]; // Remove query params
|
|
77
|
+
const absoluteCanonicalUrl = canonicalUrl
|
|
78
|
+
|| (absoluteSiteUrl ? `${absoluteSiteUrl}${currentPath}` : null);
|
|
79
|
+
|
|
77
80
|
// Generate OG image URL using @djangocfg/og-image utilities
|
|
78
81
|
const ogImageUrl = ogImage
|
|
79
82
|
? generateOgImageUrl('/api/og', {
|
|
@@ -97,14 +100,19 @@ export default function Seo({ pageConfig, icons, siteUrl }: SeoProps) {
|
|
|
97
100
|
{/* Favicon */}
|
|
98
101
|
<link rel="icon" type="image/png" href={icons?.logo192 || '/favicon.png'} />
|
|
99
102
|
|
|
103
|
+
{/* Canonical URL - important for SEO */}
|
|
104
|
+
{absoluteCanonicalUrl && (
|
|
105
|
+
<link rel="canonical" href={absoluteCanonicalUrl} />
|
|
106
|
+
)}
|
|
107
|
+
|
|
100
108
|
{/* Open Graph */}
|
|
101
109
|
<meta property="og:title" content={openGraph?.title || ogTitle} />
|
|
102
110
|
<meta property="og:description" content={openGraph?.description || ogSubtitle} />
|
|
103
111
|
<meta property="og:type" content={openGraph?.type || 'website'} />
|
|
104
112
|
|
|
105
|
-
{/* Canonical URL */}
|
|
106
|
-
{
|
|
107
|
-
<meta property="og:url" content={
|
|
113
|
+
{/* OG Canonical URL */}
|
|
114
|
+
{absoluteCanonicalUrl && (
|
|
115
|
+
<meta property="og:url" content={absoluteCanonicalUrl} />
|
|
108
116
|
)}
|
|
109
117
|
|
|
110
118
|
{/* Site Name */}
|
|
@@ -58,17 +58,17 @@ export interface ContactFormProps {
|
|
|
58
58
|
// ============================================================================
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
61
|
+
* ContactFormBase - Contact form using Django-CFG Lead API
|
|
62
|
+
*
|
|
63
|
+
* NOTE: Use ContactForm from index.ts which is dynamically imported (ssr: false)
|
|
62
64
|
*
|
|
63
65
|
* @example
|
|
64
66
|
* ```tsx
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* onSuccess={(result) => console.log('Lead ID:', result.lead_id)}
|
|
68
|
-
* />
|
|
67
|
+
* import { ContactForm } from '@djangocfg/layouts';
|
|
68
|
+
* <ContactForm apiUrl="https://api.example.com" />
|
|
69
69
|
* ```
|
|
70
70
|
*/
|
|
71
|
-
export function
|
|
71
|
+
export function ContactFormBase({ apiUrl, ...props }: ContactFormProps) {
|
|
72
72
|
return (
|
|
73
73
|
<ContactFormProvider apiUrl={apiUrl}>
|
|
74
74
|
<ContactFormInner {...props} />
|
|
@@ -112,20 +112,42 @@ function ContactFormInner({
|
|
|
112
112
|
const t = { ...DEFAULT_FORM_TEXTS, ...texts };
|
|
113
113
|
const [draft, setDraft, clearDraft] = useLocalStorage<FormDraft>(STORAGE_KEY, emptyDraft);
|
|
114
114
|
const [isSuccess, setIsSuccess] = useState(false);
|
|
115
|
+
const [isHydrated, setIsHydrated] = useState(false);
|
|
115
116
|
|
|
116
117
|
const form = useForm<FormData>({
|
|
117
118
|
resolver: zodResolver(Schemas.LeadSubmissionRequestSchema),
|
|
119
|
+
// Start with empty values to match SSR
|
|
118
120
|
defaultValues: {
|
|
119
121
|
...emptyDraft,
|
|
120
|
-
|
|
121
|
-
site_url: typeof window !== 'undefined' ? window.location.href : '',
|
|
122
|
+
site_url: '',
|
|
122
123
|
},
|
|
123
124
|
});
|
|
124
125
|
|
|
126
|
+
// Hydrate form with localStorage draft and site_url after mount
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (isHydrated) return;
|
|
129
|
+
setIsHydrated(true);
|
|
130
|
+
|
|
131
|
+
// Apply draft from localStorage and set site_url
|
|
132
|
+
const currentValues = form.getValues();
|
|
133
|
+
const hasDraftData = draft.name || draft.email || draft.company || draft.subject || draft.message;
|
|
134
|
+
|
|
135
|
+
if (hasDraftData || !currentValues.site_url) {
|
|
136
|
+
form.reset({
|
|
137
|
+
...emptyDraft,
|
|
138
|
+
...draft,
|
|
139
|
+
site_url: window.location.href,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}, [draft, form, isHydrated]);
|
|
143
|
+
|
|
125
144
|
// Watch form values and save to localStorage
|
|
126
145
|
const watchedValues = useWatch({ control: form.control });
|
|
127
146
|
|
|
128
147
|
useEffect(() => {
|
|
148
|
+
// Only save to localStorage after hydration to avoid unnecessary writes
|
|
149
|
+
if (!isHydrated) return;
|
|
150
|
+
|
|
129
151
|
const { name, email, company, subject, message } = watchedValues;
|
|
130
152
|
if (name || email || company || subject || message) {
|
|
131
153
|
setDraft({
|
|
@@ -136,14 +158,30 @@ function ContactFormInner({
|
|
|
136
158
|
message: message || '',
|
|
137
159
|
});
|
|
138
160
|
}
|
|
139
|
-
}, [watchedValues, setDraft]);
|
|
161
|
+
}, [watchedValues, setDraft, isHydrated]);
|
|
140
162
|
|
|
141
163
|
const handleSubmit = async (data: FormData) => {
|
|
142
164
|
try {
|
|
143
165
|
const result = await submit(data);
|
|
144
166
|
if (resetOnSuccess) {
|
|
145
|
-
|
|
146
|
-
|
|
167
|
+
// Keep contact info (name, email, company), clear only message content
|
|
168
|
+
const currentValues = form.getValues();
|
|
169
|
+
form.reset({
|
|
170
|
+
name: currentValues.name,
|
|
171
|
+
email: currentValues.email,
|
|
172
|
+
company: currentValues.company,
|
|
173
|
+
subject: '',
|
|
174
|
+
message: '',
|
|
175
|
+
site_url: currentValues.site_url,
|
|
176
|
+
});
|
|
177
|
+
// Update draft to keep contact info
|
|
178
|
+
setDraft({
|
|
179
|
+
name: currentValues.name || '',
|
|
180
|
+
email: currentValues.email || '',
|
|
181
|
+
company: currentValues.company || '',
|
|
182
|
+
subject: '',
|
|
183
|
+
message: '',
|
|
184
|
+
});
|
|
147
185
|
}
|
|
148
186
|
setIsSuccess(true);
|
|
149
187
|
onSuccess?.(result);
|
|
@@ -156,7 +194,16 @@ function ContactFormInner({
|
|
|
156
194
|
|
|
157
195
|
const handleReset = () => {
|
|
158
196
|
setIsSuccess(false);
|
|
159
|
-
form
|
|
197
|
+
// Keep contact info when returning to form
|
|
198
|
+
const currentDraft = draft;
|
|
199
|
+
form.reset({
|
|
200
|
+
name: currentDraft.name,
|
|
201
|
+
email: currentDraft.email,
|
|
202
|
+
company: currentDraft.company,
|
|
203
|
+
subject: '',
|
|
204
|
+
message: '',
|
|
205
|
+
site_url: typeof window !== 'undefined' ? window.location.href : '',
|
|
206
|
+
});
|
|
160
207
|
};
|
|
161
208
|
|
|
162
209
|
// Success state
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { Mail, MapPin, Calendar } from 'lucide-react';
|
|
5
|
-
import { ContactForm } from './ContactForm';
|
|
5
|
+
import { ContactFormBase as ContactForm } from './ContactForm';
|
|
6
6
|
import { ContactInfo } from './ContactInfo';
|
|
7
7
|
import type { ContactDetail, LeadSubmissionResult } from './types';
|
|
8
8
|
|
|
@@ -48,29 +48,17 @@ export interface ContactPageProps {
|
|
|
48
48
|
// ============================================================================
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
51
|
+
* ContactPageBase - Pre-configured contact page component
|
|
52
52
|
*
|
|
53
|
-
*
|
|
53
|
+
* NOTE: Use ContactPage from index.ts which is dynamically imported (ssr: false)
|
|
54
54
|
*
|
|
55
55
|
* @example
|
|
56
56
|
* ```tsx
|
|
57
|
-
*
|
|
57
|
+
* import { ContactPage } from '@djangocfg/layouts';
|
|
58
58
|
* <ContactPage />
|
|
59
|
-
*
|
|
60
|
-
* // With custom title
|
|
61
|
-
* <ContactPage
|
|
62
|
-
* title={<>Contact <span className="text-primary">Us</span></>}
|
|
63
|
-
* subtitle="We'd love to hear from you"
|
|
64
|
-
* />
|
|
65
|
-
*
|
|
66
|
-
* // Override defaults
|
|
67
|
-
* <ContactPage
|
|
68
|
-
* apiUrl="https://api.myproject.com"
|
|
69
|
-
* email="hello@myproject.com"
|
|
70
|
-
* />
|
|
71
59
|
* ```
|
|
72
60
|
*/
|
|
73
|
-
export function
|
|
61
|
+
export function ContactPageBase({
|
|
74
62
|
apiUrl = DEFAULT_CONFIG.apiUrl,
|
|
75
63
|
email = DEFAULT_CONFIG.email,
|
|
76
64
|
calendlyUrl = DEFAULT_CONFIG.calendly,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic ContactForm components (ssr: false)
|
|
3
|
+
*
|
|
4
|
+
* Avoids hydration mismatch when using localStorage for form drafts.
|
|
5
|
+
* Components are loaded client-side only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import dynamic from 'next/dynamic';
|
|
11
|
+
import { Skeleton } from '@djangocfg/ui';
|
|
12
|
+
import type { ContactFormProps } from './ContactForm';
|
|
13
|
+
import type { ContactPageProps } from './ContactPage';
|
|
14
|
+
|
|
15
|
+
function ContactFormSkeleton() {
|
|
16
|
+
return (
|
|
17
|
+
<div className="space-y-4">
|
|
18
|
+
<Skeleton className="w-full h-10" />
|
|
19
|
+
<Skeleton className="w-full h-10" />
|
|
20
|
+
<Skeleton className="w-full h-10" />
|
|
21
|
+
<Skeleton className="w-full h-24" />
|
|
22
|
+
<Skeleton className="w-full h-10" />
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ContactPageSkeleton() {
|
|
28
|
+
return (
|
|
29
|
+
<div>
|
|
30
|
+
<div className="text-center mb-8 md:mb-12">
|
|
31
|
+
<Skeleton className="w-64 h-12 mx-auto mb-4" />
|
|
32
|
+
<Skeleton className="w-96 h-6 mx-auto" />
|
|
33
|
+
</div>
|
|
34
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
35
|
+
<div className="lg:col-span-2">
|
|
36
|
+
<ContactFormSkeleton />
|
|
37
|
+
</div>
|
|
38
|
+
<div className="space-y-4">
|
|
39
|
+
<Skeleton className="w-full h-32" />
|
|
40
|
+
<Skeleton className="w-full h-24" />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const ContactForm = dynamic<ContactFormProps>(
|
|
48
|
+
() => import('./ContactForm').then((mod) => mod.ContactFormBase),
|
|
49
|
+
{ ssr: false, loading: () => <ContactFormSkeleton /> }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export const ContactPage = dynamic<ContactPageProps>(
|
|
53
|
+
() => import('./ContactPage').then((mod) => mod.ContactPageBase),
|
|
54
|
+
{ ssr: false, loading: () => <ContactPageSkeleton /> }
|
|
55
|
+
);
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// ContactForm - Contact form using Django-CFG Lead API
|
|
3
3
|
// ============================================================================
|
|
4
4
|
|
|
5
|
-
// Components
|
|
6
|
-
export { ContactForm,
|
|
5
|
+
// Components (dynamic import, ssr: false - no hydration issues)
|
|
6
|
+
export { ContactForm, ContactPage } from './dynamic';
|
|
7
7
|
export { ContactInfo } from './ContactInfo';
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
// Types
|
|
10
|
+
export type { ContactFormProps } from './ContactForm';
|
|
11
|
+
export type { ContactPageProps } from './ContactPage';
|
|
9
12
|
|
|
10
13
|
// Provider & Hooks
|
|
11
14
|
export {
|