fix
This commit is contained in:
parent
ffb9dadeb4
commit
5d0a2ebf78
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from "react";
|
||||||
import { Editor } from '@tinymce/tinymce-react';
|
import { Editor } from "@tinymce/tinymce-react";
|
||||||
|
|
||||||
interface OptimizedEditorProps {
|
interface OptimizedEditorProps {
|
||||||
initialData?: string;
|
initialData?: string;
|
||||||
|
|
@ -13,10 +13,10 @@ interface OptimizedEditorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const OptimizedEditor: React.FC<OptimizedEditorProps> = ({
|
const OptimizedEditor: React.FC<OptimizedEditorProps> = ({
|
||||||
initialData = '',
|
initialData = "",
|
||||||
onChange,
|
onChange,
|
||||||
height = 400,
|
height = 400,
|
||||||
placeholder = 'Start typing...',
|
placeholder = "Start typing...",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -42,14 +42,30 @@ const OptimizedEditor: React.FC<OptimizedEditorProps> = ({
|
||||||
height,
|
height,
|
||||||
menubar: false,
|
menubar: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
"advlist",
|
||||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
"autolink",
|
||||||
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
|
"lists",
|
||||||
|
"link",
|
||||||
|
"image",
|
||||||
|
"charmap",
|
||||||
|
"preview",
|
||||||
|
"anchor",
|
||||||
|
"searchreplace",
|
||||||
|
"visualblocks",
|
||||||
|
"code",
|
||||||
|
"fullscreen",
|
||||||
|
"insertdatetime",
|
||||||
|
"media",
|
||||||
|
"table",
|
||||||
|
"code",
|
||||||
|
"help",
|
||||||
|
"wordcount",
|
||||||
],
|
],
|
||||||
toolbar: 'undo redo | blocks | ' +
|
toolbar:
|
||||||
'bold italic forecolor | alignleft aligncenter ' +
|
"undo redo | blocks | " +
|
||||||
'alignright alignjustify | bullist numlist outdent indent | ' +
|
"bold italic forecolor | alignleft aligncenter " +
|
||||||
'removeformat | table | code | help',
|
"alignright alignjustify | bullist numlist outdent indent | " +
|
||||||
|
"removeformat | table | code | help",
|
||||||
content_style: `
|
content_style: `
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
@ -63,27 +79,27 @@ const OptimizedEditor: React.FC<OptimizedEditorProps> = ({
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
placeholder,
|
placeholder,
|
||||||
readonly: readOnly,
|
// readonly: readOnly,
|
||||||
branding: false,
|
branding: false,
|
||||||
elementpath: false,
|
elementpath: false,
|
||||||
resize: false,
|
resize: false,
|
||||||
statusbar: false,
|
statusbar: false,
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
cache_suffix: '?v=1.0',
|
cache_suffix: "?v=1.0",
|
||||||
browser_spellcheck: false,
|
browser_spellcheck: false,
|
||||||
gecko_spellcheck: false,
|
gecko_spellcheck: false,
|
||||||
// Auto-save feature
|
// Auto-save feature
|
||||||
auto_save: true,
|
auto_save: true,
|
||||||
auto_save_interval: '30s',
|
auto_save_interval: "30s",
|
||||||
// Better mobile support
|
// Better mobile support
|
||||||
mobile: {
|
mobile: {
|
||||||
theme: 'silver',
|
theme: "silver",
|
||||||
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
|
plugins: ["lists", "autolink", "link", "image", "table"],
|
||||||
toolbar: 'bold italic | bullist numlist | link image'
|
toolbar: "bold italic | bullist numlist | link image",
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OptimizedEditor;
|
export default OptimizedEditor;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect } from "react";
|
||||||
import { Editor } from '@tinymce/tinymce-react';
|
import { Editor } from "@tinymce/tinymce-react";
|
||||||
|
|
||||||
interface TinyMCEEditorProps {
|
interface TinyMCEEditorProps {
|
||||||
initialData?: string;
|
initialData?: string;
|
||||||
|
|
@ -11,7 +11,7 @@ interface TinyMCEEditorProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
features?: 'basic' | 'standard' | 'full';
|
features?: "basic" | "standard" | "full";
|
||||||
toolbar?: string;
|
toolbar?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
uploadUrl?: string;
|
uploadUrl?: string;
|
||||||
|
|
@ -22,21 +22,21 @@ interface TinyMCEEditorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
initialData = '',
|
initialData = "",
|
||||||
onChange,
|
onChange,
|
||||||
onReady,
|
onReady,
|
||||||
height = 400,
|
height = 400,
|
||||||
placeholder = 'Start typing...',
|
placeholder = "Start typing...",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
features = 'standard',
|
features = "standard",
|
||||||
toolbar,
|
toolbar,
|
||||||
language = 'en',
|
language = "en",
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
uploadHeaders,
|
uploadHeaders,
|
||||||
className = '',
|
className = "",
|
||||||
autoSave = true,
|
autoSave = true,
|
||||||
autoSaveInterval = 30000
|
autoSaveInterval = 30000,
|
||||||
}) => {
|
}) => {
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
const [isEditorLoaded, setIsEditorLoaded] = useState(false);
|
const [isEditorLoaded, setIsEditorLoaded] = useState(false);
|
||||||
|
|
@ -47,35 +47,74 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
const getFeatureConfig = (featureLevel: string) => {
|
const getFeatureConfig = (featureLevel: string) => {
|
||||||
const configs = {
|
const configs = {
|
||||||
basic: {
|
basic: {
|
||||||
plugins: ['lists', 'link', 'autolink', 'wordcount'],
|
plugins: ["lists", "link", "autolink", "wordcount"],
|
||||||
toolbar: 'bold italic | bullist numlist | link',
|
toolbar: "bold italic | bullist numlist | link",
|
||||||
menubar: false
|
menubar: false,
|
||||||
},
|
},
|
||||||
standard: {
|
standard: {
|
||||||
plugins: [
|
plugins: [
|
||||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
"advlist",
|
||||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
"autolink",
|
||||||
'insertdatetime', 'media', 'table', 'help', 'wordcount'
|
"lists",
|
||||||
|
"link",
|
||||||
|
"image",
|
||||||
|
"charmap",
|
||||||
|
"preview",
|
||||||
|
"anchor",
|
||||||
|
"searchreplace",
|
||||||
|
"visualblocks",
|
||||||
|
"code",
|
||||||
|
"fullscreen",
|
||||||
|
"insertdatetime",
|
||||||
|
"media",
|
||||||
|
"table",
|
||||||
|
"help",
|
||||||
|
"wordcount",
|
||||||
],
|
],
|
||||||
toolbar: 'undo redo | blocks | ' +
|
toolbar:
|
||||||
'bold italic forecolor | alignleft aligncenter ' +
|
"undo redo | blocks | " +
|
||||||
'alignright alignjustify | bullist numlist outdent indent | ' +
|
"bold italic forecolor | alignleft aligncenter " +
|
||||||
'removeformat | table | code | help',
|
"alignright alignjustify | bullist numlist outdent indent | " +
|
||||||
menubar: false
|
"removeformat | table | code | help",
|
||||||
|
menubar: false,
|
||||||
},
|
},
|
||||||
full: {
|
full: {
|
||||||
plugins: [
|
plugins: [
|
||||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
"advlist",
|
||||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
"autolink",
|
||||||
'insertdatetime', 'media', 'table', 'help', 'wordcount', 'emoticons',
|
"lists",
|
||||||
'paste', 'textcolor', 'colorpicker', 'hr', 'pagebreak', 'nonbreaking',
|
"link",
|
||||||
'toc', 'imagetools', 'textpattern', 'codesample'
|
"image",
|
||||||
|
"charmap",
|
||||||
|
"preview",
|
||||||
|
"anchor",
|
||||||
|
"searchreplace",
|
||||||
|
"visualblocks",
|
||||||
|
"code",
|
||||||
|
"fullscreen",
|
||||||
|
"insertdatetime",
|
||||||
|
"media",
|
||||||
|
"table",
|
||||||
|
"help",
|
||||||
|
"wordcount",
|
||||||
|
"emoticons",
|
||||||
|
"paste",
|
||||||
|
"textcolor",
|
||||||
|
"colorpicker",
|
||||||
|
"hr",
|
||||||
|
"pagebreak",
|
||||||
|
"nonbreaking",
|
||||||
|
"toc",
|
||||||
|
"imagetools",
|
||||||
|
"textpattern",
|
||||||
|
"codesample",
|
||||||
],
|
],
|
||||||
toolbar: 'undo redo | formatselect | bold italic backcolor | ' +
|
toolbar:
|
||||||
'alignleft aligncenter alignright alignjustify | ' +
|
"undo redo | formatselect | bold italic backcolor | " +
|
||||||
'bullist numlist outdent indent | removeformat | help',
|
"alignleft aligncenter alignright alignjustify | " +
|
||||||
menubar: 'file edit view insert format tools table help'
|
"bullist numlist outdent indent | removeformat | help",
|
||||||
}
|
menubar: "file edit view insert format tools table help",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return configs[featureLevel as keyof typeof configs] || configs.standard;
|
return configs[featureLevel as keyof typeof configs] || configs.standard;
|
||||||
};
|
};
|
||||||
|
|
@ -89,13 +128,13 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
const handleEditorInit = (evt: any, editor: any) => {
|
const handleEditorInit = (evt: any, editor: any) => {
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
setIsEditorLoaded(true);
|
setIsEditorLoaded(true);
|
||||||
|
|
||||||
if (onReady) {
|
if (onReady) {
|
||||||
onReady(editor);
|
onReady(editor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up word count tracking
|
// Set up word count tracking
|
||||||
editor.on('keyup', () => {
|
editor.on("keyup", () => {
|
||||||
const count = editor.plugins.wordcount.body.getCharacterCount();
|
const count = editor.plugins.wordcount.body.getCharacterCount();
|
||||||
setWordCount(count);
|
setWordCount(count);
|
||||||
});
|
});
|
||||||
|
|
@ -104,24 +143,24 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
if (autoSave && !readOnly) {
|
if (autoSave && !readOnly) {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const content = editor.getContent();
|
const content = editor.getContent();
|
||||||
localStorage.setItem('tinymce-autosave', content);
|
localStorage.setItem("tinymce-autosave", content);
|
||||||
setLastSaved(new Date());
|
setLastSaved(new Date());
|
||||||
}, autoSaveInterval);
|
}, autoSaveInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix cursor jumping issues
|
// Fix cursor jumping issues
|
||||||
editor.on('keyup', (e: any) => {
|
editor.on("keyup", (e: any) => {
|
||||||
// Prevent cursor jumping on content changes
|
// Prevent cursor jumping on content changes
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.on('input', (e: any) => {
|
editor.on("input", (e: any) => {
|
||||||
// Prevent unnecessary re-renders
|
// Prevent unnecessary re-renders
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle paste events properly
|
// Handle paste events properly
|
||||||
editor.on('paste', (e: any) => {
|
editor.on("paste", (e: any) => {
|
||||||
// Allow default paste behavior
|
// Allow default paste behavior
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -130,23 +169,23 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
const handleImageUpload = (blobInfo: any, progress: any) => {
|
const handleImageUpload = (blobInfo: any, progress: any) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!uploadUrl) {
|
if (!uploadUrl) {
|
||||||
reject('No upload URL configured');
|
reject("No upload URL configured");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', blobInfo.blob(), blobInfo.filename());
|
formData.append("file", blobInfo.blob(), blobInfo.filename());
|
||||||
|
|
||||||
fetch(uploadUrl, {
|
fetch(uploadUrl, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: uploadHeaders || {},
|
headers: uploadHeaders || {},
|
||||||
body: formData
|
body: formData,
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then((response) => response.json())
|
||||||
.then(result => {
|
.then((result) => {
|
||||||
resolve(result.url);
|
resolve(result.url);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -165,7 +204,7 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
resize: false,
|
resize: false,
|
||||||
statusbar: !readOnly,
|
statusbar: !readOnly,
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
cache_suffix: '?v=1.0',
|
cache_suffix: "?v=1.0",
|
||||||
browser_spellcheck: false,
|
browser_spellcheck: false,
|
||||||
gecko_spellcheck: false,
|
gecko_spellcheck: false,
|
||||||
// Content styling
|
// Content styling
|
||||||
|
|
@ -188,40 +227,41 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
// Image upload configuration
|
// Image upload configuration
|
||||||
images_upload_handler: uploadUrl ? handleImageUpload : undefined,
|
images_upload_handler: uploadUrl ? handleImageUpload : undefined,
|
||||||
automatic_uploads: !!uploadUrl,
|
automatic_uploads: !!uploadUrl,
|
||||||
file_picker_types: 'image',
|
file_picker_types: "image",
|
||||||
// Better mobile support
|
// Better mobile support
|
||||||
mobile: {
|
mobile: {
|
||||||
theme: 'silver',
|
theme: "silver",
|
||||||
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
|
plugins: ["lists", "autolink", "link", "image", "table"],
|
||||||
toolbar: 'bold italic | bullist numlist | link image'
|
toolbar: "bold italic | bullist numlist | link image",
|
||||||
},
|
},
|
||||||
// Paste configuration
|
// Paste configuration
|
||||||
paste_as_text: false,
|
paste_as_text: false,
|
||||||
paste_enable_default_filters: true,
|
paste_enable_default_filters: true,
|
||||||
paste_word_valid_elements: 'b,strong,i,em,h1,h2,h3,h4,h5,h6',
|
paste_word_valid_elements: "b,strong,i,em,h1,h2,h3,h4,h5,h6",
|
||||||
paste_retain_style_properties: 'color background-color font-size font-weight',
|
paste_retain_style_properties:
|
||||||
|
"color background-color font-size font-weight",
|
||||||
// Table configuration
|
// Table configuration
|
||||||
table_default_styles: {
|
table_default_styles: {
|
||||||
width: '100%'
|
width: "100%",
|
||||||
},
|
},
|
||||||
table_default_attributes: {
|
table_default_attributes: {
|
||||||
border: '1'
|
border: "1",
|
||||||
},
|
},
|
||||||
// Code configuration
|
// Code configuration
|
||||||
codesample_languages: [
|
codesample_languages: [
|
||||||
{ text: 'HTML/XML', value: 'markup' },
|
{ text: "HTML/XML", value: "markup" },
|
||||||
{ text: 'JavaScript', value: 'javascript' },
|
{ text: "JavaScript", value: "javascript" },
|
||||||
{ text: 'CSS', value: 'css' },
|
{ text: "CSS", value: "css" },
|
||||||
{ text: 'PHP', value: 'php' },
|
{ text: "PHP", value: "php" },
|
||||||
{ text: 'Python', value: 'python' },
|
{ text: "Python", value: "python" },
|
||||||
{ text: 'Java', value: 'java' },
|
{ text: "Java", value: "java" },
|
||||||
{ text: 'C', value: 'c' },
|
{ text: "C", value: "c" },
|
||||||
{ text: 'C++', value: 'cpp' }
|
{ text: "C++", value: "cpp" },
|
||||||
],
|
],
|
||||||
// ...feature config
|
// ...feature config
|
||||||
...featureConfig,
|
...featureConfig,
|
||||||
// Custom toolbar if provided
|
// Custom toolbar if provided
|
||||||
...(toolbar && { toolbar })
|
...(toolbar && { toolbar }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -232,15 +272,15 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
onEditorChange={handleEditorChange}
|
onEditorChange={handleEditorChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
|
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
|
||||||
init={editorConfig}
|
// init={editorConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
{isEditorLoaded && (
|
{isEditorLoaded && (
|
||||||
<div className="text-xs text-gray-500 mt-2 flex items-center justify-between">
|
<div className="text-xs text-gray-500 mt-2 flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span>
|
<span>
|
||||||
{autoSave && !readOnly ? 'Auto-save enabled' : 'Read-only mode'}
|
{autoSave && !readOnly ? "Auto-save enabled" : "Read-only mode"}
|
||||||
</span>
|
</span>
|
||||||
{lastSaved && autoSave && !readOnly && (
|
{lastSaved && autoSave && !readOnly && (
|
||||||
<span>• Last saved: {lastSaved.toLocaleTimeString()}</span>
|
<span>• Last saved: {lastSaved.toLocaleTimeString()}</span>
|
||||||
|
|
@ -255,10 +295,15 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
|
||||||
|
|
||||||
{/* Performance indicator */}
|
{/* Performance indicator */}
|
||||||
<div className="text-xs text-gray-400 mt-1">
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
Bundle size: {features === 'basic' ? '~150KB' : features === 'standard' ? '~200KB' : '~300KB'}
|
Bundle size:{" "}
|
||||||
|
{features === "basic"
|
||||||
|
? "~150KB"
|
||||||
|
: features === "standard"
|
||||||
|
? "~200KB"
|
||||||
|
: "~300KB"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TinyMCEEditor;
|
export default TinyMCEEditor;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
// import {createSharedPathnamesNavigation} from 'next-intl/navigation';
|
||||||
|
// import {locales} from '@/config';
|
||||||
|
|
||||||
|
// export const {Link, redirect, usePathname, useRouter} =
|
||||||
|
// createSharedPathnamesNavigation({locales,});
|
||||||
|
|
@ -1,213 +1,75 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
|
||||||
import * as React from "react"
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
classNames,
|
classNames,
|
||||||
showOutsideDays = true,
|
showOutsideDays = true,
|
||||||
captionLayout = "label",
|
|
||||||
buttonVariant = "ghost",
|
|
||||||
formatters,
|
|
||||||
components,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DayPicker> & {
|
}: React.ComponentProps<typeof DayPicker>) {
|
||||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
|
||||||
}) {
|
|
||||||
const defaultClassNames = getDefaultClassNames()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
className={cn(
|
className={cn("p-3", className)}
|
||||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
|
||||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
|
||||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
captionLayout={captionLayout}
|
|
||||||
formatters={{
|
|
||||||
formatMonthDropdown: (date) =>
|
|
||||||
date.toLocaleString("default", { month: "short" }),
|
|
||||||
...formatters,
|
|
||||||
}}
|
|
||||||
classNames={{
|
classNames={{
|
||||||
root: cn("w-fit", defaultClassNames.root),
|
months: "flex flex-col sm:flex-row gap-2",
|
||||||
months: cn(
|
month: "flex flex-col gap-4",
|
||||||
"flex gap-4 flex-col md:flex-row relative",
|
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||||
defaultClassNames.months
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "flex items-center gap-1",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
),
|
),
|
||||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
nav_button_previous: "absolute left-1",
|
||||||
nav: cn(
|
nav_button_next: "absolute right-1",
|
||||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
table: "w-full border-collapse space-x-1",
|
||||||
defaultClassNames.nav
|
head_row: "flex",
|
||||||
),
|
head_cell:
|
||||||
button_previous: cn(
|
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||||
buttonVariants({ variant: buttonVariant }),
|
row: "flex w-full mt-2",
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
cell: cn(
|
||||||
defaultClassNames.button_previous
|
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||||
),
|
props.mode === "range"
|
||||||
button_next: cn(
|
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||||
buttonVariants({ variant: buttonVariant }),
|
: "[&:has([aria-selected])]:rounded-md"
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
|
||||||
defaultClassNames.button_next
|
|
||||||
),
|
|
||||||
month_caption: cn(
|
|
||||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
|
||||||
defaultClassNames.month_caption
|
|
||||||
),
|
|
||||||
dropdowns: cn(
|
|
||||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
|
||||||
defaultClassNames.dropdowns
|
|
||||||
),
|
|
||||||
dropdown_root: cn(
|
|
||||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
|
||||||
defaultClassNames.dropdown_root
|
|
||||||
),
|
|
||||||
dropdown: cn(
|
|
||||||
"absolute bg-popover inset-0 opacity-0",
|
|
||||||
defaultClassNames.dropdown
|
|
||||||
),
|
|
||||||
caption_label: cn(
|
|
||||||
"select-none font-medium",
|
|
||||||
captionLayout === "label"
|
|
||||||
? "text-sm"
|
|
||||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
|
||||||
defaultClassNames.caption_label
|
|
||||||
),
|
|
||||||
table: "w-full border-collapse",
|
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
|
||||||
weekday: cn(
|
|
||||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
|
||||||
defaultClassNames.weekday
|
|
||||||
),
|
|
||||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
|
||||||
week_number_header: cn(
|
|
||||||
"select-none w-(--cell-size)",
|
|
||||||
defaultClassNames.week_number_header
|
|
||||||
),
|
|
||||||
week_number: cn(
|
|
||||||
"text-[0.8rem] select-none text-muted-foreground",
|
|
||||||
defaultClassNames.week_number
|
|
||||||
),
|
),
|
||||||
day: cn(
|
day: cn(
|
||||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
buttonVariants({ variant: "ghost" }),
|
||||||
defaultClassNames.day
|
"size-8 p-0 font-normal aria-selected:opacity-100"
|
||||||
),
|
),
|
||||||
range_start: cn(
|
day_range_start:
|
||||||
"rounded-l-md bg-accent",
|
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
defaultClassNames.range_start
|
day_range_end:
|
||||||
),
|
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
day_selected:
|
||||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
today: cn(
|
day_today: "bg-accent text-accent-foreground",
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
day_outside:
|
||||||
defaultClassNames.today
|
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
),
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
outside: cn(
|
day_range_middle:
|
||||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
defaultClassNames.outside
|
day_hidden: "invisible",
|
||||||
),
|
|
||||||
disabled: cn(
|
|
||||||
"text-muted-foreground opacity-50",
|
|
||||||
defaultClassNames.disabled
|
|
||||||
),
|
|
||||||
hidden: cn("invisible", defaultClassNames.hidden),
|
|
||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={
|
||||||
Root: ({ className, rootRef, ...props }) => {
|
{
|
||||||
return (
|
// IconLeft: ({ className, ...props }) => (
|
||||||
<div
|
// <ChevronLeft className={cn("size-4", className)} {...props} />
|
||||||
data-slot="calendar"
|
// ),
|
||||||
ref={rootRef}
|
// IconRight: ({ className, ...props }) => (
|
||||||
className={cn(className)}
|
// <ChevronRight className={cn("size-4", className)} {...props} />
|
||||||
{...props}
|
// ),
|
||||||
/>
|
}
|
||||||
)
|
|
||||||
},
|
|
||||||
Chevron: ({ className, orientation, ...props }) => {
|
|
||||||
if (orientation === "left") {
|
|
||||||
return (
|
|
||||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orientation === "right") {
|
|
||||||
return (
|
|
||||||
<ChevronRightIcon
|
|
||||||
className={cn("size-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
|
||||||
)
|
|
||||||
},
|
|
||||||
DayButton: CalendarDayButton,
|
|
||||||
WeekNumber: ({ children, ...props }) => {
|
|
||||||
return (
|
|
||||||
<td {...props}>
|
|
||||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
...components,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarDayButton({
|
|
||||||
className,
|
|
||||||
day,
|
|
||||||
modifiers,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DayButton>) {
|
|
||||||
const defaultClassNames = getDefaultClassNames()
|
|
||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (modifiers.focused) ref.current?.focus()
|
|
||||||
}, [modifiers.focused])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
data-day={day.date.toLocaleDateString()}
|
|
||||||
data-selected-single={
|
|
||||||
modifiers.selected &&
|
|
||||||
!modifiers.range_start &&
|
|
||||||
!modifiers.range_end &&
|
|
||||||
!modifiers.range_middle
|
|
||||||
}
|
}
|
||||||
data-range-start={modifiers.range_start}
|
|
||||||
data-range-end={modifiers.range_end}
|
|
||||||
data-range-middle={modifiers.range_middle}
|
|
||||||
className={cn(
|
|
||||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
|
||||||
defaultClassNames.day,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Calendar, CalendarDayButton }
|
export { Calendar };
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
FacebookLoginResponse,
|
FacebookLoginResponse,
|
||||||
FacebookLoginError,
|
FacebookLoginError,
|
||||||
FacebookUser,
|
FacebookUser,
|
||||||
FacebookSDKInitOptions
|
FacebookSDKInitOptions,
|
||||||
} from '@/types/facebook-login';
|
} from "@/types/facebook-login";
|
||||||
|
|
||||||
export interface UseFacebookLoginOptions extends FacebookSDKInitOptions {}
|
export interface UseFacebookLoginOptions extends FacebookSDKInitOptions {}
|
||||||
|
|
||||||
|
|
@ -13,20 +13,26 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [user, setUser] = useState<FacebookUser | null>(null);
|
const [user, setUser] = useState<FacebookUser | null>(null);
|
||||||
|
|
||||||
const { appId, version = 'v18.0', cookie = true, xfbml = true, autoLogAppEvents = true } = options;
|
const {
|
||||||
|
appId,
|
||||||
|
version = "v18.0",
|
||||||
|
cookie = true,
|
||||||
|
xfbml = true,
|
||||||
|
autoLogAppEvents = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
// Initialize Facebook SDK
|
// Initialize Facebook SDK
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// Load Facebook SDK if not already loaded
|
// Load Facebook SDK if not already loaded
|
||||||
if (!window.FB) {
|
if (!window.FB) {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement("script");
|
||||||
script.src = `https://connect.facebook.net/en_US/sdk.js`;
|
script.src = `https://connect.facebook.net/en_US/sdk.js`;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.defer = true;
|
script.defer = true;
|
||||||
script.crossOrigin = 'anonymous';
|
script.crossOrigin = "anonymous";
|
||||||
|
|
||||||
window.fbAsyncInit = () => {
|
window.fbAsyncInit = () => {
|
||||||
window.FB.init({
|
window.FB.init({
|
||||||
appId,
|
appId,
|
||||||
|
|
@ -36,10 +42,10 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
|
||||||
autoLogAppEvents,
|
autoLogAppEvents,
|
||||||
});
|
});
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
|
|
||||||
// Check login status
|
// Check login status
|
||||||
window.FB.getLoginStatus((response: any) => {
|
window.FB.getLoginStatus((response: any) => {
|
||||||
if (response.status === 'connected') {
|
if (response.status === "connected") {
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
getUserInfo(response.authResponse.accessToken);
|
getUserInfo(response.authResponse.accessToken);
|
||||||
}
|
}
|
||||||
|
|
@ -57,38 +63,51 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
|
||||||
}, [appId, cookie, xfbml, version, autoLogAppEvents]);
|
}, [appId, cookie, xfbml, version, autoLogAppEvents]);
|
||||||
|
|
||||||
const getUserInfo = useCallback((accessToken: string) => {
|
const getUserInfo = useCallback((accessToken: string) => {
|
||||||
window.FB.api('/me', { fields: 'name,email,picture' }, (response: FacebookUser) => {
|
window.FB.api(
|
||||||
if (response && !response.error) {
|
"/me",
|
||||||
setUser(response);
|
{ fields: "name,email,picture" },
|
||||||
|
(response: FacebookUser) => {
|
||||||
|
if (response && !response.error) {
|
||||||
|
setUser(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = useCallback((permissions: string[] = ['public_profile', 'email']) => {
|
const login = useCallback(
|
||||||
return new Promise<FacebookLoginResponse>((resolve, reject) => {
|
(permissions: string[] = ["public_profile", "email"]) => {
|
||||||
if (!window.FB) {
|
return new Promise<FacebookLoginResponse>((resolve, reject) => {
|
||||||
reject(new Error('Facebook SDK not loaded'));
|
if (!window.FB) {
|
||||||
return;
|
reject(new Error("Facebook SDK not loaded"));
|
||||||
}
|
return;
|
||||||
|
|
||||||
window.FB.login((response: any) => {
|
|
||||||
if (response.status === 'connected') {
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
getUserInfo(response.authResponse.accessToken);
|
|
||||||
resolve(response.authResponse);
|
|
||||||
} else if (response.status === 'not_authorized') {
|
|
||||||
reject({ error: 'not_authorized', errorDescription: 'User denied permissions' });
|
|
||||||
} else {
|
|
||||||
reject({ error: 'unknown', errorDescription: 'Login failed' });
|
|
||||||
}
|
}
|
||||||
}, { scope: permissions.join(',') });
|
|
||||||
});
|
window.FB.login(
|
||||||
}, [getUserInfo]);
|
(response: any) => {
|
||||||
|
if (response.status === "connected") {
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
getUserInfo(response.authResponse.accessToken);
|
||||||
|
resolve(response.authResponse);
|
||||||
|
} else if (response.status === "not_authorized") {
|
||||||
|
reject({
|
||||||
|
error: "not_authorized",
|
||||||
|
errorDescription: "User denied permissions",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject({ error: "unknown", errorDescription: "Login failed" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: permissions.join(",") }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getUserInfo]
|
||||||
|
);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
if (!window.FB) {
|
if (!window.FB) {
|
||||||
reject(new Error('Facebook SDK not loaded'));
|
reject(new Error("Facebook SDK not loaded"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,7 +122,7 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
|
||||||
const getLoginStatus = useCallback(() => {
|
const getLoginStatus = useCallback(() => {
|
||||||
return new Promise<any>((resolve, reject) => {
|
return new Promise<any>((resolve, reject) => {
|
||||||
if (!window.FB) {
|
if (!window.FB) {
|
||||||
reject(new Error('Facebook SDK not loaded'));
|
reject(new Error("Facebook SDK not loaded"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,4 +140,4 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
|
||||||
logout,
|
logout,
|
||||||
getLoginStatus,
|
getLoginStatus,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { useRouter } from "@/components/navigation";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
RegistrationFormData,
|
RegistrationFormData,
|
||||||
|
|
@ -53,6 +53,7 @@ import {
|
||||||
RegistrationRateLimiter,
|
RegistrationRateLimiter,
|
||||||
REGISTRATION_CONSTANTS,
|
REGISTRATION_CONSTANTS,
|
||||||
} from "@/lib/registration-utils";
|
} from "@/lib/registration-utils";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
// Global rate limiter instance
|
// Global rate limiter instance
|
||||||
const registrationRateLimiter = new RegistrationRateLimiter();
|
const registrationRateLimiter = new RegistrationRateLimiter();
|
||||||
|
|
@ -62,13 +63,13 @@ export const useOTP = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [timer, setTimer] = useState<TimerState>(createTimer());
|
const [timer, setTimer] = useState<TimerState>(createTimer());
|
||||||
|
|
||||||
const startTimer = useCallback(() => {
|
const startTimer = useCallback(() => {
|
||||||
setTimer(createTimer());
|
setTimer(createTimer());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stopTimer = useCallback(() => {
|
const stopTimer = useCallback(() => {
|
||||||
setTimer(prev => ({
|
setTimer((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
isExpired: true,
|
isExpired: true,
|
||||||
|
|
@ -78,7 +79,7 @@ export const useOTP = () => {
|
||||||
// Timer effect
|
// Timer effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!timer.isActive || timer.countdown <= 0) {
|
if (!timer.isActive || timer.countdown <= 0) {
|
||||||
setTimer(prev => ({
|
setTimer((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
isExpired: true,
|
isExpired: true,
|
||||||
|
|
@ -87,7 +88,7 @@ export const useOTP = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setTimer(prev => ({
|
setTimer((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
countdown: Math.max(0, prev.countdown - 1000),
|
countdown: Math.max(0, prev.countdown - 1000),
|
||||||
}));
|
}));
|
||||||
|
|
@ -96,103 +97,115 @@ export const useOTP = () => {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [timer.isActive, timer.countdown]);
|
}, [timer.isActive, timer.countdown]);
|
||||||
|
|
||||||
const requestOTPCode = useCallback(async (
|
const requestOTPCode = useCallback(
|
||||||
email: string,
|
async (
|
||||||
category: UserCategory,
|
email: string,
|
||||||
memberIdentity?: string
|
category: UserCategory,
|
||||||
): Promise<boolean> => {
|
memberIdentity?: string
|
||||||
try {
|
): Promise<boolean> => {
|
||||||
setLoading(true);
|
try {
|
||||||
setError(null);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
// Check rate limiting
|
// Check rate limiting
|
||||||
const identifier = `${email}-${category}`;
|
const identifier = `${email}-${category}`;
|
||||||
if (!registrationRateLimiter.canAttempt(identifier)) {
|
if (!registrationRateLimiter.canAttempt(identifier)) {
|
||||||
const remainingAttempts = registrationRateLimiter.getRemainingAttempts(identifier);
|
const remainingAttempts =
|
||||||
throw new Error(`Too many OTP requests. Please try again later. Remaining attempts: ${remainingAttempts}`);
|
registrationRateLimiter.getRemainingAttempts(identifier);
|
||||||
|
throw new Error(
|
||||||
|
`Too many OTP requests. Please try again later. Remaining attempts: ${remainingAttempts}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
memberIdentity: memberIdentity || null,
|
||||||
|
email,
|
||||||
|
category: getCategoryRoleId(category),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log("OTP Request Data:", data);
|
||||||
|
console.log("Category before conversion:", category);
|
||||||
|
console.log("Category after conversion:", getCategoryRoleId(category));
|
||||||
|
|
||||||
|
const response = await requestOTP(data);
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
registrationRateLimiter.recordAttempt(identifier);
|
||||||
|
throw new Error(response.message || "Failed to send OTP");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start timer on successful OTP request
|
||||||
|
startTimer();
|
||||||
|
showRegistrationInfo("OTP sent successfully. Please check your email.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error?.message || "Failed to send OTP";
|
||||||
|
setError(errorMessage);
|
||||||
|
showRegistrationError(error, "Failed to send OTP");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[startTimer]
|
||||||
|
);
|
||||||
|
|
||||||
const data = {
|
const verifyOTPCode = useCallback(
|
||||||
memberIdentity: memberIdentity || null,
|
async (
|
||||||
email,
|
email: string,
|
||||||
category: getCategoryRoleId(category),
|
otp: string,
|
||||||
};
|
category: UserCategory,
|
||||||
|
memberIdentity?: string
|
||||||
|
): Promise<any> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
// Debug logging
|
if (otp.length !== 6) {
|
||||||
console.log("OTP Request Data:", data);
|
throw new Error("OTP must be exactly 6 digits");
|
||||||
console.log("Category before conversion:", category);
|
}
|
||||||
console.log("Category after conversion:", getCategoryRoleId(category));
|
|
||||||
|
|
||||||
const response = await requestOTP(data);
|
const data = {
|
||||||
|
memberIdentity: memberIdentity || null,
|
||||||
|
email,
|
||||||
|
otp,
|
||||||
|
category: getCategoryRoleId(category),
|
||||||
|
};
|
||||||
|
|
||||||
if (response?.error) {
|
const response = await verifyOTP(data.email, data.otp);
|
||||||
registrationRateLimiter.recordAttempt(identifier);
|
|
||||||
throw new Error(response.message || "Failed to send OTP");
|
if (response?.error) {
|
||||||
|
throw new Error(response.message || "OTP verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTimer();
|
||||||
|
showRegistrationSuccess("OTP verified successfully");
|
||||||
|
|
||||||
|
return response?.data?.userData;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error?.message || "OTP verification failed";
|
||||||
|
setError(errorMessage);
|
||||||
|
showRegistrationError(error, "OTP verification failed");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[stopTimer]
|
||||||
|
);
|
||||||
|
|
||||||
// Start timer on successful OTP request
|
const resendOTP = useCallback(
|
||||||
startTimer();
|
async (
|
||||||
showRegistrationInfo("OTP sent successfully. Please check your email.");
|
email: string,
|
||||||
|
category: UserCategory,
|
||||||
return true;
|
memberIdentity?: string
|
||||||
} catch (error: any) {
|
): Promise<boolean> => {
|
||||||
const errorMessage = error?.message || "Failed to send OTP";
|
return await requestOTPCode(email, category, memberIdentity);
|
||||||
setError(errorMessage);
|
},
|
||||||
showRegistrationError(error, "Failed to send OTP");
|
[requestOTPCode]
|
||||||
return false;
|
);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [startTimer]);
|
|
||||||
|
|
||||||
const verifyOTPCode = useCallback(async (
|
|
||||||
email: string,
|
|
||||||
otp: string,
|
|
||||||
category: UserCategory,
|
|
||||||
memberIdentity?: string
|
|
||||||
): Promise<any> => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (otp.length !== 6) {
|
|
||||||
throw new Error("OTP must be exactly 6 digits");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
memberIdentity: memberIdentity || null,
|
|
||||||
email,
|
|
||||||
otp,
|
|
||||||
category: getCategoryRoleId(category),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await verifyOTP(data.email, data.otp);
|
|
||||||
|
|
||||||
if (response?.error) {
|
|
||||||
throw new Error(response.message || "OTP verification failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
stopTimer();
|
|
||||||
showRegistrationSuccess("OTP verified successfully");
|
|
||||||
|
|
||||||
return response?.data?.userData;
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorMessage = error?.message || "OTP verification failed";
|
|
||||||
setError(errorMessage);
|
|
||||||
showRegistrationError(error, "OTP verification failed");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [stopTimer]);
|
|
||||||
|
|
||||||
const resendOTP = useCallback(async (
|
|
||||||
email: string,
|
|
||||||
category: UserCategory,
|
|
||||||
memberIdentity?: string
|
|
||||||
): Promise<boolean> => {
|
|
||||||
return await requestOTPCode(email, category, memberIdentity);
|
|
||||||
}, [requestOTPCode]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestOTP: requestOTPCode,
|
requestOTP: requestOTPCode,
|
||||||
|
|
@ -220,7 +233,7 @@ export const useLocationData = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await listProvince();
|
const response = await listProvince();
|
||||||
|
|
||||||
if (!response || response.error) {
|
if (!response || response.error) {
|
||||||
throw new Error(response?.message || "Failed to fetch provinces");
|
throw new Error(response?.message || "Failed to fetch provinces");
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +254,7 @@ export const useLocationData = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await listCity(provinceId);
|
const response = await listCity(provinceId);
|
||||||
|
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
throw new Error(response.message || "Failed to fetch cities");
|
throw new Error(response.message || "Failed to fetch cities");
|
||||||
}
|
}
|
||||||
|
|
@ -263,7 +276,7 @@ export const useLocationData = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await listDistricts(cityId);
|
const response = await listDistricts(cityId);
|
||||||
|
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
throw new Error(response.message || "Failed to fetch districts");
|
throw new Error(response.message || "Failed to fetch districts");
|
||||||
}
|
}
|
||||||
|
|
@ -301,54 +314,60 @@ export const useInstituteData = (category?: number) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchInstitutes = useCallback(async (categoryId?: number) => {
|
const fetchInstitutes = useCallback(
|
||||||
try {
|
async (categoryId?: number) => {
|
||||||
setLoading(true);
|
try {
|
||||||
setError(null);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
const response = await listInstitusi(categoryId || category);
|
const response = await listInstitusi(categoryId || category);
|
||||||
|
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
throw new Error(response.message || "Failed to fetch institutes");
|
throw new Error(response.message || "Failed to fetch institutes");
|
||||||
|
}
|
||||||
|
|
||||||
|
setInstitutes(response?.data?.data || []);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error?.message || "Failed to fetch institutes";
|
||||||
|
setError(errorMessage);
|
||||||
|
showRegistrationError(error, "Failed to fetch institutes");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[category]
|
||||||
|
);
|
||||||
|
|
||||||
setInstitutes(response?.data?.data || []);
|
const saveInstitute = useCallback(
|
||||||
} catch (error: any) {
|
async (instituteData: InstituteData): Promise<number> => {
|
||||||
const errorMessage = error?.message || "Failed to fetch institutes";
|
try {
|
||||||
setError(errorMessage);
|
setLoading(true);
|
||||||
showRegistrationError(error, "Failed to fetch institutes");
|
setError(null);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [category]);
|
|
||||||
|
|
||||||
const saveInstitute = useCallback(async (instituteData: InstituteData): Promise<number> => {
|
const sanitizedData = sanitizeInstituteData(instituteData);
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const sanitizedData = sanitizeInstituteData(instituteData);
|
const response = await saveInstitutes({
|
||||||
|
name: sanitizedData.name,
|
||||||
const response = await saveInstitutes({
|
address: sanitizedData.address,
|
||||||
name: sanitizedData.name,
|
categoryRoleId: category || 6, // Use provided category or default to Journalist category
|
||||||
address: sanitizedData.address,
|
});
|
||||||
categoryRoleId: category || 6, // Use provided category or default to Journalist category
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
throw new Error(response.message || "Failed to save institute");
|
throw new Error(response.message || "Failed to save institute");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response?.data?.data?.id || 1;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error?.message || "Failed to save institute";
|
||||||
|
setError(errorMessage);
|
||||||
|
showRegistrationError(error, "Failed to save institute");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
return response?.data?.data?.id || 1;
|
[category]
|
||||||
} catch (error: any) {
|
);
|
||||||
const errorMessage = error?.message || "Failed to save institute";
|
|
||||||
setError(errorMessage);
|
|
||||||
showRegistrationError(error, "Failed to save institute");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [category]);
|
|
||||||
|
|
||||||
// Load institutes on mount if category is provided
|
// Load institutes on mount if category is provided
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -371,49 +390,59 @@ export const useUserDataValidation = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const validateJournalistData = useCallback(async (certificateNumber: string): Promise<any> => {
|
const validateJournalistData = useCallback(
|
||||||
try {
|
async (certificateNumber: string): Promise<any> => {
|
||||||
setLoading(true);
|
try {
|
||||||
setError(null);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
const response = await getDataJournalist(certificateNumber);
|
const response = await getDataJournalist(certificateNumber);
|
||||||
|
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
throw new Error(response.message || "Invalid journalist certificate number");
|
throw new Error(
|
||||||
|
response.message || "Invalid journalist certificate number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response?.data?.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage =
|
||||||
|
error?.message || "Failed to validate journalist data";
|
||||||
|
setError(errorMessage);
|
||||||
|
showRegistrationError(error, "Failed to validate journalist data");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return response?.data?.data;
|
const validatePersonnelData = useCallback(
|
||||||
} catch (error: any) {
|
async (policeNumber: string): Promise<any> => {
|
||||||
const errorMessage = error?.message || "Failed to validate journalist data";
|
try {
|
||||||
setError(errorMessage);
|
setLoading(true);
|
||||||
showRegistrationError(error, "Failed to validate journalist data");
|
setError(null);
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validatePersonnelData = useCallback(async (policeNumber: string): Promise<any> => {
|
const response = await getDataPersonil(policeNumber);
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await getDataPersonil(policeNumber);
|
if (response?.error) {
|
||||||
|
throw new Error(response.message || "Invalid police number");
|
||||||
|
}
|
||||||
|
|
||||||
if (response?.error) {
|
return response?.data?.data;
|
||||||
throw new Error(response.message || "Invalid police number");
|
} catch (error: any) {
|
||||||
|
const errorMessage =
|
||||||
|
error?.message || "Failed to validate personnel data";
|
||||||
|
setError(errorMessage);
|
||||||
|
showRegistrationError(error, "Failed to validate personnel data");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
return response?.data?.data;
|
[]
|
||||||
} catch (error: any) {
|
);
|
||||||
const errorMessage = error?.message || "Failed to validate personnel data";
|
|
||||||
setError(errorMessage);
|
|
||||||
showRegistrationError(error, "Failed to validate personnel data");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validateJournalistData,
|
validateJournalistData,
|
||||||
|
|
@ -429,57 +458,70 @@ export const useRegistration = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const submitRegistration = useCallback(async (
|
const submitRegistration = useCallback(
|
||||||
data: RegistrationFormData,
|
async (
|
||||||
category: UserCategory,
|
data: RegistrationFormData,
|
||||||
userData: any,
|
category: UserCategory,
|
||||||
instituteId?: number
|
userData: any,
|
||||||
): Promise<boolean> => {
|
instituteId?: number
|
||||||
try {
|
): Promise<boolean> => {
|
||||||
setLoading(true);
|
try {
|
||||||
setError(null);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
// Sanitize and validate data
|
// Sanitize and validate data
|
||||||
const sanitizedData = sanitizeRegistrationData(data);
|
const sanitizedData = sanitizeRegistrationData(data);
|
||||||
|
|
||||||
// Validate password
|
// Validate password
|
||||||
const passwordValidation = validatePassword(sanitizedData.password, sanitizedData.passwordConf);
|
const passwordValidation = validatePassword(
|
||||||
if (!passwordValidation.isValid) {
|
sanitizedData.password,
|
||||||
throw new Error(passwordValidation.errors[0]);
|
sanitizedData.passwordConf
|
||||||
|
);
|
||||||
|
if (!passwordValidation.isValid) {
|
||||||
|
throw new Error(passwordValidation.errors[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username
|
||||||
|
const usernameValidation = validateUsername(sanitizedData.username);
|
||||||
|
if (!usernameValidation.isValid) {
|
||||||
|
throw new Error(usernameValidation.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform data for API
|
||||||
|
const transformedData = transformRegistrationData(
|
||||||
|
sanitizedData,
|
||||||
|
category,
|
||||||
|
userData,
|
||||||
|
instituteId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postRegistration(transformedData);
|
||||||
|
console.log("PPPP", transformedData);
|
||||||
|
if (response?.error) {
|
||||||
|
throw new Error(response.message || "Registration failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
showRegistrationSuccess(
|
||||||
|
"Registration successful! Please check your email for verification."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redirect to login page
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/auth");
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error?.message || "Registration failed";
|
||||||
|
setError(errorMessage);
|
||||||
|
showRegistrationError(error, "Registration failed");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// Validate username
|
[router]
|
||||||
const usernameValidation = validateUsername(sanitizedData.username);
|
);
|
||||||
if (!usernameValidation.isValid) {
|
|
||||||
throw new Error(usernameValidation.error!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform data for API
|
|
||||||
const transformedData = transformRegistrationData(sanitizedData, category, userData, instituteId);
|
|
||||||
|
|
||||||
const response = await postRegistration(transformedData);
|
|
||||||
console.log("PPPP", transformedData)
|
|
||||||
if (response?.error) {
|
|
||||||
throw new Error(response.message || "Registration failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
showRegistrationSuccess("Registration successful! Please check your email for verification.");
|
|
||||||
|
|
||||||
// Redirect to login page
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push("/auth");
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorMessage = error?.message || "Registration failed";
|
|
||||||
setError(errorMessage);
|
|
||||||
showRegistrationError(error, "Registration failed");
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
submitRegistration,
|
submitRegistration,
|
||||||
|
|
@ -490,81 +532,93 @@ export const useRegistration = () => {
|
||||||
|
|
||||||
// Hook for form validation
|
// Hook for form validation
|
||||||
export const useFormValidation = () => {
|
export const useFormValidation = () => {
|
||||||
const validateIdentityForm = useCallback((
|
const validateIdentityForm = useCallback(
|
||||||
data: JournalistRegistrationData | PersonnelRegistrationData | GeneralRegistrationData,
|
(
|
||||||
category: UserCategory
|
data:
|
||||||
): { isValid: boolean; errors: string[] } => {
|
| JournalistRegistrationData
|
||||||
return validateIdentityData(data, category);
|
| PersonnelRegistrationData
|
||||||
}, []);
|
| GeneralRegistrationData,
|
||||||
|
category: UserCategory
|
||||||
|
): { isValid: boolean; errors: string[] } => {
|
||||||
|
return validateIdentityData(data, category);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const validateProfileForm = useCallback((data: RegistrationFormData): { isValid: boolean; errors: string[] } => {
|
const validateProfileForm = useCallback(
|
||||||
const errors: string[] = [];
|
(data: RegistrationFormData): { isValid: boolean; errors: string[] } => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!data.firstName?.trim()) {
|
if (!data.firstName?.trim()) {
|
||||||
errors.push("Full name is required");
|
errors.push("Full name is required");
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.username?.trim()) {
|
|
||||||
errors.push("Username is required");
|
|
||||||
} else {
|
|
||||||
const usernameValidation = validateUsername(data.username);
|
|
||||||
if (!usernameValidation.isValid) {
|
|
||||||
errors.push(usernameValidation.error!);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.email?.trim()) {
|
if (!data.username?.trim()) {
|
||||||
errors.push("Email is required");
|
errors.push("Username is required");
|
||||||
} else {
|
} else {
|
||||||
const emailValidation = validateEmail(data.email);
|
const usernameValidation = validateUsername(data.username);
|
||||||
if (!emailValidation.isValid) {
|
if (!usernameValidation.isValid) {
|
||||||
errors.push(emailValidation.error!);
|
errors.push(usernameValidation.error!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.phoneNumber?.trim()) {
|
if (!data.email?.trim()) {
|
||||||
errors.push("Phone number is required");
|
errors.push("Email is required");
|
||||||
} else {
|
} else {
|
||||||
const phoneValidation = validatePhoneNumber(data.phoneNumber);
|
const emailValidation = validateEmail(data.email);
|
||||||
if (!phoneValidation.isValid) {
|
if (!emailValidation.isValid) {
|
||||||
errors.push(phoneValidation.error!);
|
errors.push(emailValidation.error!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.address?.trim()) {
|
if (!data.phoneNumber?.trim()) {
|
||||||
errors.push("Address is required");
|
errors.push("Phone number is required");
|
||||||
}
|
} else {
|
||||||
|
const phoneValidation = validatePhoneNumber(data.phoneNumber);
|
||||||
if (!data.provinsi) {
|
if (!phoneValidation.isValid) {
|
||||||
errors.push("Province is required");
|
errors.push(phoneValidation.error!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.kota) {
|
|
||||||
errors.push("City is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.kecamatan) {
|
|
||||||
errors.push("Subdistrict is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.password) {
|
|
||||||
errors.push("Password is required");
|
|
||||||
} else {
|
|
||||||
const passwordValidation = validatePassword(data.password, data.passwordConf);
|
|
||||||
if (!passwordValidation.isValid) {
|
|
||||||
errors.push(passwordValidation.errors[0]);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
if (!data.address?.trim()) {
|
||||||
isValid: errors.length === 0,
|
errors.push("Address is required");
|
||||||
errors,
|
}
|
||||||
};
|
|
||||||
}, []);
|
if (!data.provinsi) {
|
||||||
|
errors.push("Province is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.kota) {
|
||||||
|
errors.push("City is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.kecamatan) {
|
||||||
|
errors.push("Subdistrict is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.password) {
|
||||||
|
errors.push("Password is required");
|
||||||
|
} else {
|
||||||
|
const passwordValidation = validatePassword(
|
||||||
|
data.password,
|
||||||
|
data.passwordConf
|
||||||
|
);
|
||||||
|
if (!passwordValidation.isValid) {
|
||||||
|
errors.push(passwordValidation.errors[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validateIdentityForm,
|
validateIdentityForm,
|
||||||
validateProfileForm,
|
validateProfileForm,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
import {
|
||||||
|
RegistrationFormData,
|
||||||
|
JournalistRegistrationData,
|
||||||
|
PersonnelRegistrationData,
|
||||||
|
GeneralRegistrationData,
|
||||||
|
InstituteData,
|
||||||
|
UserCategory,
|
||||||
|
PasswordValidation,
|
||||||
|
TimerState,
|
||||||
|
Association
|
||||||
|
} from "@/types/registration";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export const REGISTRATION_CONSTANTS = {
|
||||||
|
OTP_TIMEOUT: 60000, // 1 minute in milliseconds
|
||||||
|
MAX_OTP_ATTEMPTS: 3,
|
||||||
|
PASSWORD_MIN_LENGTH: 8,
|
||||||
|
USERNAME_MIN_LENGTH: 3,
|
||||||
|
USERNAME_MAX_LENGTH: 50,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Association data
|
||||||
|
export const ASSOCIATIONS: Association[] = [
|
||||||
|
{ id: "1", name: "PWI (Persatuan Wartawan Indonesia)", value: "PWI" },
|
||||||
|
{ id: "2", name: "IJTI (Ikatan Jurnalis Televisi Indonesia)", value: "IJTI" },
|
||||||
|
{ id: "3", name: "PFI (Pewarta Foto Indonesia)", value: "PFI" },
|
||||||
|
{ id: "4", name: "AJI (Asosiasi Jurnalis Indonesia)", value: "AJI" },
|
||||||
|
{ id: "5", name: "Other Identity", value: "Wartawan" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Password validation utility
|
||||||
|
export const validatePassword = (password: string, confirmPassword?: string): PasswordValidation => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let strength: 'weak' | 'medium' | 'strong' = 'weak';
|
||||||
|
|
||||||
|
// Check minimum length
|
||||||
|
if (password.length < REGISTRATION_CONSTANTS.PASSWORD_MIN_LENGTH) {
|
||||||
|
errors.push(`Password must be at least ${REGISTRATION_CONSTANTS.PASSWORD_MIN_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for uppercase letter
|
||||||
|
if (!/[A-Z]/.test(password)) {
|
||||||
|
errors.push("Password must contain at least one uppercase letter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for lowercase letter
|
||||||
|
if (!/[a-z]/.test(password)) {
|
||||||
|
errors.push("Password must contain at least one lowercase letter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for number
|
||||||
|
if (!/\d/.test(password)) {
|
||||||
|
errors.push("Password must contain at least one number");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for special character
|
||||||
|
if (!/[@$!%*?&]/.test(password)) {
|
||||||
|
errors.push("Password must contain at least one special character (@$!%*?&)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password confirmation
|
||||||
|
if (confirmPassword && password !== confirmPassword) {
|
||||||
|
errors.push("Passwords don't match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine strength
|
||||||
|
if (password.length >= 12 && errors.length === 0) {
|
||||||
|
strength = 'strong';
|
||||||
|
} else if (password.length >= 8 && errors.length <= 1) {
|
||||||
|
strength = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
strength,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Username validation utility
|
||||||
|
export const validateUsername = (username: string): { isValid: boolean; error?: string } => {
|
||||||
|
if (username.length < REGISTRATION_CONSTANTS.USERNAME_MIN_LENGTH) {
|
||||||
|
return { isValid: false, error: `Username must be at least ${REGISTRATION_CONSTANTS.USERNAME_MIN_LENGTH} characters` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length > REGISTRATION_CONSTANTS.USERNAME_MAX_LENGTH) {
|
||||||
|
return { isValid: false, error: `Username must be less than ${REGISTRATION_CONSTANTS.USERNAME_MAX_LENGTH} characters` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
||||||
|
return { isValid: false, error: "Username can only contain letters, numbers, dots, underscores, and hyphens" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Email validation utility
|
||||||
|
export const validateEmail = (email: string): { isValid: boolean; error?: string } => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return { isValid: false, error: "Email is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return { isValid: false, error: "Please enter a valid email address" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phone number validation utility
|
||||||
|
export const validatePhoneNumber = (phoneNumber: string): { isValid: boolean; error?: string } => {
|
||||||
|
const phoneRegex = /^[0-9+\-\s()]+$/;
|
||||||
|
|
||||||
|
if (!phoneNumber) {
|
||||||
|
return { isValid: false, error: "Phone number is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phoneRegex.test(phoneNumber)) {
|
||||||
|
return { isValid: false, error: "Please enter a valid phone number" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timer utility
|
||||||
|
export const createTimer = (duration: number = REGISTRATION_CONSTANTS.OTP_TIMEOUT): TimerState => {
|
||||||
|
return {
|
||||||
|
countdown: duration,
|
||||||
|
isActive: true,
|
||||||
|
isExpired: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTime = (milliseconds: number): string => {
|
||||||
|
const minutes = Math.floor(milliseconds / 60000);
|
||||||
|
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Data sanitization utility
|
||||||
|
export const sanitizeRegistrationData = (data: RegistrationFormData): RegistrationFormData => {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
firstName: data.firstName.trim(),
|
||||||
|
username: data.username.trim().toLowerCase(),
|
||||||
|
email: data.email.trim().toLowerCase(),
|
||||||
|
phoneNumber: data.phoneNumber.trim(),
|
||||||
|
address: data.address.trim(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeInstituteData = (data: InstituteData): InstituteData => {
|
||||||
|
return {
|
||||||
|
id : data.id,
|
||||||
|
name: data.name.trim(),
|
||||||
|
address: data.address.trim(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category validation utility
|
||||||
|
export const isValidCategory = (category: string): category is UserCategory => {
|
||||||
|
return category === "6" || category === "7" || category === "general";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategoryLabel = (category: UserCategory): string => {
|
||||||
|
switch (category) {
|
||||||
|
case "6":
|
||||||
|
return "Journalist";
|
||||||
|
case "7":
|
||||||
|
return "Personnel";
|
||||||
|
case "general":
|
||||||
|
return "Public";
|
||||||
|
default:
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category to role ID conversion utility
|
||||||
|
export const getCategoryRoleId = (category: UserCategory): number => {
|
||||||
|
switch (category) {
|
||||||
|
case "6":
|
||||||
|
return 6; // Journalist
|
||||||
|
case "7":
|
||||||
|
return 7; // Personnel
|
||||||
|
case "general":
|
||||||
|
return 5; // Public (general)
|
||||||
|
default:
|
||||||
|
return 5; // Default to public
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Error handling utilities
|
||||||
|
export const showRegistrationError = (error: any, defaultMessage: string = "Registration failed") => {
|
||||||
|
const message = error?.message || error?.data?.message || defaultMessage;
|
||||||
|
toast.error(message);
|
||||||
|
console.error("Registration error:", error);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showRegistrationSuccess = (message: string = "Registration successful") => {
|
||||||
|
toast.success(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showRegistrationInfo = (message: string) => {
|
||||||
|
toast.info(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Data transformation utilities
|
||||||
|
export const transformRegistrationData = (
|
||||||
|
data: RegistrationFormData,
|
||||||
|
category: UserCategory,
|
||||||
|
userData: any,
|
||||||
|
instituteId?: number
|
||||||
|
): any => {
|
||||||
|
const baseData = {
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.firstName, // Using firstName as lastName for now
|
||||||
|
username: data.username,
|
||||||
|
phoneNumber: data.phoneNumber,
|
||||||
|
email: data.email,
|
||||||
|
address: data.address,
|
||||||
|
provinceId: Number(data.provinsi),
|
||||||
|
cityId: Number(data.kota),
|
||||||
|
districtId: Number(data.kecamatan),
|
||||||
|
password: data.password,
|
||||||
|
roleId: getCategoryRoleId(category),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add category-specific data
|
||||||
|
if (category === "6") {
|
||||||
|
return {
|
||||||
|
...baseData,
|
||||||
|
memberIdentity: userData?.journalistCertificate,
|
||||||
|
instituteId: instituteId || 1,
|
||||||
|
};
|
||||||
|
} else if (category === "7") {
|
||||||
|
return {
|
||||||
|
...baseData,
|
||||||
|
memberIdentity: userData?.policeNumber,
|
||||||
|
instituteId: 1,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...baseData,
|
||||||
|
memberIdentity: null,
|
||||||
|
instituteId: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Form data utilities
|
||||||
|
export const createInitialFormData = (category: UserCategory) => {
|
||||||
|
const baseData = {
|
||||||
|
firstName: "",
|
||||||
|
username: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
email: "",
|
||||||
|
address: "",
|
||||||
|
provinsi: "",
|
||||||
|
kota: "",
|
||||||
|
kecamatan: "",
|
||||||
|
password: "",
|
||||||
|
passwordConf: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (category === "6") {
|
||||||
|
return {
|
||||||
|
...baseData,
|
||||||
|
journalistCertificate: "",
|
||||||
|
association: "",
|
||||||
|
};
|
||||||
|
} else if (category === "7") {
|
||||||
|
return {
|
||||||
|
...baseData,
|
||||||
|
policeNumber: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validation utilities
|
||||||
|
export const validateIdentityData = (
|
||||||
|
data: JournalistRegistrationData | PersonnelRegistrationData | GeneralRegistrationData,
|
||||||
|
category: UserCategory
|
||||||
|
): { isValid: boolean; errors: string[] } => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
const emailValidation = validateEmail(data.email);
|
||||||
|
if (!emailValidation.isValid) {
|
||||||
|
errors.push(emailValidation.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category-specific validation
|
||||||
|
if (category === "6") {
|
||||||
|
const journalistData = data as JournalistRegistrationData;
|
||||||
|
if (!journalistData.journalistCertificate?.trim()) {
|
||||||
|
errors.push("Journalist certificate number is required");
|
||||||
|
}
|
||||||
|
if (!journalistData.association?.trim()) {
|
||||||
|
errors.push("Association is required");
|
||||||
|
}
|
||||||
|
} else if (category === "7") {
|
||||||
|
const personnelData = data as PersonnelRegistrationData;
|
||||||
|
if (!personnelData.policeNumber?.trim()) {
|
||||||
|
errors.push("Police number is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate limiting utility
|
||||||
|
export class RegistrationRateLimiter {
|
||||||
|
private attempts: Map<string, { count: number; lastAttempt: number }> = new Map();
|
||||||
|
private readonly maxAttempts: number;
|
||||||
|
private readonly windowMs: number;
|
||||||
|
|
||||||
|
constructor(maxAttempts: number = REGISTRATION_CONSTANTS.MAX_OTP_ATTEMPTS, windowMs: number = 300000) {
|
||||||
|
this.maxAttempts = maxAttempts;
|
||||||
|
this.windowMs = windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
canAttempt(identifier: string): boolean {
|
||||||
|
const attempt = this.attempts.get(identifier);
|
||||||
|
if (!attempt) return true;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - attempt.lastAttempt > this.windowMs) {
|
||||||
|
this.attempts.delete(identifier);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attempt.count < this.maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAttempt(identifier: string): void {
|
||||||
|
const attempt = this.attempts.get(identifier);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (attempt) {
|
||||||
|
attempt.count++;
|
||||||
|
attempt.lastAttempt = now;
|
||||||
|
} else {
|
||||||
|
this.attempts.set(identifier, { count: 1, lastAttempt: now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemainingAttempts(identifier: string): number {
|
||||||
|
const attempt = this.attempts.get(identifier);
|
||||||
|
if (!attempt) return this.maxAttempts;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - attempt.lastAttempt > this.windowMs) {
|
||||||
|
this.attempts.delete(identifier);
|
||||||
|
return this.maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, this.maxAttempts - attempt.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(identifier: string): void {
|
||||||
|
this.attempts.delete(identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export rate limiter instance
|
||||||
|
export const registrationRateLimiter = new RegistrationRateLimiter();
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
"@types/crypto-js": "^4.2.0",
|
"@types/crypto-js": "^4.2.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/next": "^9.0.0",
|
"@types/next": "^9.0.0",
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.3",
|
"framer-motion": "^12.23.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jotai": "^2.14.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "15.3.5",
|
"next": "15.3.5",
|
||||||
|
|
@ -2800,6 +2802,24 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tinymce/tinymce-react": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.6.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
|
||||||
|
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
|
||||||
|
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"tinymce": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/color-convert": {
|
"node_modules/@types/color-convert": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.4.tgz",
|
||||||
|
|
@ -3952,6 +3972,34 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jotai": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-JQkNkTnqjk1BlSUjHfXi+pGG/573bVN104gp6CymhrWDseZGDReTNniWrLhJ+zXbM6pH+82+UNJ2vwYQUkQMWQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": ">=7.0.0",
|
||||||
|
"@babel/template": ">=7.0.0",
|
||||||
|
"@types/react": ">=17.0.0",
|
||||||
|
"react": ">=17.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@babel/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@babel/template": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-base64": {
|
"node_modules/js-base64": {
|
||||||
"version": "3.7.8",
|
"version": "3.7.8",
|
||||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
"@types/crypto-js": "^4.2.0",
|
"@types/crypto-js": "^4.2.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/next": "^9.0.0",
|
"@types/next": "^9.0.0",
|
||||||
|
|
@ -34,6 +35,7 @@
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.3",
|
"framer-motion": "^12.23.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jotai": "^2.14.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "15.3.5",
|
"next": "15.3.5",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
export interface FacebookLoginResponse {
|
||||||
|
accessToken: string;
|
||||||
|
userID: string;
|
||||||
|
expiresIn: number;
|
||||||
|
signedRequest: string;
|
||||||
|
graphDomain: string;
|
||||||
|
data_access_expiration_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookLoginError {
|
||||||
|
error: string;
|
||||||
|
errorDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
picture?: {
|
||||||
|
data: {
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
error?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookSDKInitOptions {
|
||||||
|
appId: string;
|
||||||
|
version?: string;
|
||||||
|
cookie?: boolean;
|
||||||
|
xfbml?: boolean;
|
||||||
|
autoLogAppEvents?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
FB: {
|
||||||
|
init: (options: FacebookSDKInitOptions) => void;
|
||||||
|
login: (callback: (response: any) => void, options?: { scope: string }) => void;
|
||||||
|
logout: (callback: (response: any) => void) => void;
|
||||||
|
getLoginStatus: (callback: (response: any) => void) => void;
|
||||||
|
api: (path: string, params: any, callback: (response: any) => void) => void;
|
||||||
|
};
|
||||||
|
fbAsyncInit: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Base schemas for validation
|
||||||
|
export const registrationSchema = z.object({
|
||||||
|
firstName: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Full name is required" })
|
||||||
|
.min(2, { message: "Full name must be at least 2 characters" })
|
||||||
|
.max(100, { message: "Full name must be less than 100 characters" })
|
||||||
|
.regex(/^[a-zA-Z\s]+$/, { message: "Full name can only contain letters and spaces" }),
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Username is required" })
|
||||||
|
.min(3, { message: "Username must be at least 3 characters" })
|
||||||
|
.max(50, { message: "Username must be less than 50 characters" })
|
||||||
|
.regex(/^[a-zA-Z0-9._-]+$/, { message: "Username can only contain letters, numbers, dots, underscores, and hyphens" }),
|
||||||
|
phoneNumber: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Phone number is required" })
|
||||||
|
.regex(/^[0-9+\-\s()]+$/, { message: "Please enter a valid phone number" }),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Email is required" })
|
||||||
|
.email({ message: "Please enter a valid email address" }),
|
||||||
|
address: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Address is required" })
|
||||||
|
.min(10, { message: "Address must be at least 10 characters" })
|
||||||
|
.max(500, { message: "Address must be less than 500 characters" }),
|
||||||
|
provinsi: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Province is required" }),
|
||||||
|
kota: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "City is required" }),
|
||||||
|
kecamatan: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Subdistrict is required" }),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Password is required" })
|
||||||
|
.min(8, { message: "Password must be at least 8 characters" })
|
||||||
|
.max(100, { message: "Password must be less than 100 characters" })
|
||||||
|
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
|
||||||
|
message: "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character"
|
||||||
|
}),
|
||||||
|
passwordConf: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Password confirmation is required" }),
|
||||||
|
}).refine((data) => data.password === data.passwordConf, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ["passwordConf"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const journalistRegistrationSchema = z.object({
|
||||||
|
journalistCertificate: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Journalist certificate number is required" })
|
||||||
|
.min(5, { message: "Journalist certificate number must be at least 5 characters" }),
|
||||||
|
association: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Association is required" }),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Email is required" })
|
||||||
|
.email({ message: "Please enter a valid email address" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const personnelRegistrationSchema = z.object({
|
||||||
|
policeNumber: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Police number is required" })
|
||||||
|
.min(5, { message: "Police number must be at least 5 characters" }),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Email is required" })
|
||||||
|
.email({ message: "Please enter a valid email address" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generalRegistrationSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Email is required" })
|
||||||
|
.email({ message: "Please enter a valid email address" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const instituteSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Institute name is required" })
|
||||||
|
.min(2, { message: "Institute name must be at least 2 characters" }),
|
||||||
|
address: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Institute address is required" })
|
||||||
|
.min(10, { message: "Institute address must be at least 10 characters" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inferred types from schemas
|
||||||
|
export type RegistrationFormData = z.infer<typeof registrationSchema>;
|
||||||
|
export type JournalistRegistrationData = z.infer<typeof journalistRegistrationSchema>;
|
||||||
|
export type PersonnelRegistrationData = z.infer<typeof personnelRegistrationSchema>;
|
||||||
|
export type GeneralRegistrationData = z.infer<typeof generalRegistrationSchema>;
|
||||||
|
export type InstituteData = z.infer<typeof instituteSchema>;
|
||||||
|
|
||||||
|
// API response types
|
||||||
|
export interface RegistrationResponse {
|
||||||
|
data?: {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
error?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OTPRequestResponse {
|
||||||
|
data?: {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
error?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OTPVerificationResponse {
|
||||||
|
data?: {
|
||||||
|
message: string;
|
||||||
|
userData?: any;
|
||||||
|
};
|
||||||
|
error?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstituteResponse {
|
||||||
|
data?: {
|
||||||
|
data: InstituteData[];
|
||||||
|
};
|
||||||
|
error?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
provName?: string;
|
||||||
|
cityName?: string;
|
||||||
|
disName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationResponse {
|
||||||
|
data?: {
|
||||||
|
data: LocationData[];
|
||||||
|
};
|
||||||
|
error?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration step types
|
||||||
|
export type RegistrationStep = "identity" | "otp" | "profile";
|
||||||
|
|
||||||
|
// User category types
|
||||||
|
export type UserCategory = "6" | "7" | "general"; // 6=Journalist, 7=Personnel, general=Public
|
||||||
|
|
||||||
|
// Component props types
|
||||||
|
export interface RegistrationLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
currentStep: RegistrationStep;
|
||||||
|
totalSteps: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityFormProps {
|
||||||
|
category: UserCategory;
|
||||||
|
onSuccess: (data: JournalistRegistrationData | PersonnelRegistrationData | GeneralRegistrationData) => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationOTPFormProps {
|
||||||
|
email: string;
|
||||||
|
category: UserCategory;
|
||||||
|
memberIdentity?: string;
|
||||||
|
onSuccess: (userData: any) => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
onResend: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileFormProps {
|
||||||
|
userData: any;
|
||||||
|
category: UserCategory;
|
||||||
|
onSuccess: (data: RegistrationFormData) => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstituteFormProps {
|
||||||
|
onInstituteChange: (institute: InstituteData | null) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationSelectorProps {
|
||||||
|
onProvinceChange: (provinceId: string) => void;
|
||||||
|
onCityChange: (cityId: string) => void;
|
||||||
|
onDistrictChange: (districtId: string) => void;
|
||||||
|
selectedProvince?: string;
|
||||||
|
selectedCity?: string;
|
||||||
|
selectedDistrict?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration state types
|
||||||
|
export interface RegistrationState {
|
||||||
|
currentStep: RegistrationStep;
|
||||||
|
category: UserCategory;
|
||||||
|
identityData: JournalistRegistrationData | PersonnelRegistrationData | GeneralRegistrationData | null;
|
||||||
|
userData: any;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationContextType extends RegistrationState {
|
||||||
|
setStep: (step: RegistrationStep) => void;
|
||||||
|
setIdentityData: (data: JournalistRegistrationData | PersonnelRegistrationData | GeneralRegistrationData) => void;
|
||||||
|
setUserData: (data: any) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Association types
|
||||||
|
export interface Association {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer types
|
||||||
|
export interface TimerState {
|
||||||
|
countdown: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isExpired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password validation types
|
||||||
|
export interface PasswordValidation {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
strength: 'weak' | 'medium' | 'strong';
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue