This commit is contained in:
Anang Yusman 2025-09-16 23:19:10 +08:00
parent ffb9dadeb4
commit 5d0a2ebf78
11 changed files with 1320 additions and 600 deletions

View File

@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useRef } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import React, { useEffect, useRef } from "react";
import { Editor } from "@tinymce/tinymce-react";
interface OptimizedEditorProps {
initialData?: string;
@ -13,10 +13,10 @@ interface OptimizedEditorProps {
}
const OptimizedEditor: React.FC<OptimizedEditorProps> = ({
initialData = '',
initialData = "",
onChange,
height = 400,
placeholder = 'Start typing...',
placeholder = "Start typing...",
disabled = false,
readOnly = false,
}) => {
@ -42,14 +42,30 @@ const OptimizedEditor: React.FC<OptimizedEditorProps> = ({
height,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
"advlist",
"autolink",
"lists",
"link",
"image",
"charmap",
"preview",
"anchor",
"searchreplace",
"visualblocks",
"code",
"fullscreen",
"insertdatetime",
"media",
"table",
"code",
"help",
"wordcount",
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
toolbar:
"undo redo | blocks | " +
"bold italic forecolor | alignleft aligncenter " +
"alignright alignjustify | bullist numlist outdent indent | " +
"removeformat | table | code | help",
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@ -63,27 +79,27 @@ const OptimizedEditor: React.FC<OptimizedEditorProps> = ({
}
`,
placeholder,
readonly: readOnly,
// readonly: readOnly,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Performance optimizations
cache_suffix: '?v=1.0',
cache_suffix: "?v=1.0",
browser_spellcheck: false,
gecko_spellcheck: false,
// Auto-save feature
auto_save: true,
auto_save_interval: '30s',
auto_save_interval: "30s",
// Better mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
theme: "silver",
plugins: ["lists", "autolink", "link", "image", "table"],
toolbar: "bold italic | bullist numlist | link image",
},
}}
/>
);
};
export default OptimizedEditor;
export default OptimizedEditor;

View File

@ -1,7 +1,7 @@
"use client";
import React, { useRef, useState, useEffect } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import React, { useRef, useState, useEffect } from "react";
import { Editor } from "@tinymce/tinymce-react";
interface TinyMCEEditorProps {
initialData?: string;
@ -11,7 +11,7 @@ interface TinyMCEEditorProps {
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
features?: 'basic' | 'standard' | 'full';
features?: "basic" | "standard" | "full";
toolbar?: string;
language?: string;
uploadUrl?: string;
@ -22,21 +22,21 @@ interface TinyMCEEditorProps {
}
const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
initialData = '',
initialData = "",
onChange,
onReady,
height = 400,
placeholder = 'Start typing...',
placeholder = "Start typing...",
disabled = false,
readOnly = false,
features = 'standard',
features = "standard",
toolbar,
language = 'en',
language = "en",
uploadUrl,
uploadHeaders,
className = '',
className = "",
autoSave = true,
autoSaveInterval = 30000
autoSaveInterval = 30000,
}) => {
const editorRef = useRef<any>(null);
const [isEditorLoaded, setIsEditorLoaded] = useState(false);
@ -47,35 +47,74 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
const getFeatureConfig = (featureLevel: string) => {
const configs = {
basic: {
plugins: ['lists', 'link', 'autolink', 'wordcount'],
toolbar: 'bold italic | bullist numlist | link',
menubar: false
plugins: ["lists", "link", "autolink", "wordcount"],
toolbar: "bold italic | bullist numlist | link",
menubar: false,
},
standard: {
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount'
"advlist",
"autolink",
"lists",
"link",
"image",
"charmap",
"preview",
"anchor",
"searchreplace",
"visualblocks",
"code",
"fullscreen",
"insertdatetime",
"media",
"table",
"help",
"wordcount",
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
menubar: false
toolbar:
"undo redo | blocks | " +
"bold italic forecolor | alignleft aligncenter " +
"alignright alignjustify | bullist numlist outdent indent | " +
"removeformat | table | code | help",
menubar: false,
},
full: {
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount', 'emoticons',
'paste', 'textcolor', 'colorpicker', 'hr', 'pagebreak', 'nonbreaking',
'toc', 'imagetools', 'textpattern', 'codesample'
"advlist",
"autolink",
"lists",
"link",
"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 | ' +
'alignleft aligncenter alignright alignjustify | ' +
'bullist numlist outdent indent | removeformat | help',
menubar: 'file edit view insert format tools table help'
}
toolbar:
"undo redo | formatselect | bold italic backcolor | " +
"alignleft aligncenter alignright alignjustify | " +
"bullist numlist outdent indent | removeformat | help",
menubar: "file edit view insert format tools table help",
},
};
return configs[featureLevel as keyof typeof configs] || configs.standard;
};
@ -89,13 +128,13 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
const handleEditorInit = (evt: any, editor: any) => {
editorRef.current = editor;
setIsEditorLoaded(true);
if (onReady) {
onReady(editor);
}
// Set up word count tracking
editor.on('keyup', () => {
editor.on("keyup", () => {
const count = editor.plugins.wordcount.body.getCharacterCount();
setWordCount(count);
});
@ -104,24 +143,24 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
if (autoSave && !readOnly) {
setInterval(() => {
const content = editor.getContent();
localStorage.setItem('tinymce-autosave', content);
localStorage.setItem("tinymce-autosave", content);
setLastSaved(new Date());
}, autoSaveInterval);
}
// Fix cursor jumping issues
editor.on('keyup', (e: any) => {
editor.on("keyup", (e: any) => {
// Prevent cursor jumping on content changes
e.stopPropagation();
});
editor.on('input', (e: any) => {
editor.on("input", (e: any) => {
// Prevent unnecessary re-renders
e.stopPropagation();
});
// Handle paste events properly
editor.on('paste', (e: any) => {
editor.on("paste", (e: any) => {
// Allow default paste behavior
return true;
});
@ -130,23 +169,23 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
const handleImageUpload = (blobInfo: any, progress: any) => {
return new Promise((resolve, reject) => {
if (!uploadUrl) {
reject('No upload URL configured');
reject("No upload URL configured");
return;
}
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
formData.append("file", blobInfo.blob(), blobInfo.filename());
fetch(uploadUrl, {
method: 'POST',
method: "POST",
headers: uploadHeaders || {},
body: formData
body: formData,
})
.then(response => response.json())
.then(result => {
.then((response) => response.json())
.then((result) => {
resolve(result.url);
})
.catch(error => {
.catch((error) => {
reject(error);
});
});
@ -165,7 +204,7 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
resize: false,
statusbar: !readOnly,
// Performance optimizations
cache_suffix: '?v=1.0',
cache_suffix: "?v=1.0",
browser_spellcheck: false,
gecko_spellcheck: false,
// Content styling
@ -188,40 +227,41 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
// Image upload configuration
images_upload_handler: uploadUrl ? handleImageUpload : undefined,
automatic_uploads: !!uploadUrl,
file_picker_types: 'image',
file_picker_types: "image",
// Better mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
theme: "silver",
plugins: ["lists", "autolink", "link", "image", "table"],
toolbar: "bold italic | bullist numlist | link image",
},
// Paste configuration
paste_as_text: false,
paste_enable_default_filters: true,
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_word_valid_elements: "b,strong,i,em,h1,h2,h3,h4,h5,h6",
paste_retain_style_properties:
"color background-color font-size font-weight",
// Table configuration
table_default_styles: {
width: '100%'
width: "100%",
},
table_default_attributes: {
border: '1'
border: "1",
},
// Code configuration
codesample_languages: [
{ text: 'HTML/XML', value: 'markup' },
{ text: 'JavaScript', value: 'javascript' },
{ text: 'CSS', value: 'css' },
{ text: 'PHP', value: 'php' },
{ text: 'Python', value: 'python' },
{ text: 'Java', value: 'java' },
{ text: 'C', value: 'c' },
{ text: 'C++', value: 'cpp' }
{ text: "HTML/XML", value: "markup" },
{ text: "JavaScript", value: "javascript" },
{ text: "CSS", value: "css" },
{ text: "PHP", value: "php" },
{ text: "Python", value: "python" },
{ text: "Java", value: "java" },
{ text: "C", value: "c" },
{ text: "C++", value: "cpp" },
],
// ...feature config
...featureConfig,
// Custom toolbar if provided
...(toolbar && { toolbar })
...(toolbar && { toolbar }),
};
return (
@ -232,15 +272,15 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
onEditorChange={handleEditorChange}
disabled={disabled}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={editorConfig}
// init={editorConfig}
/>
{/* Status bar */}
{isEditorLoaded && (
<div className="text-xs text-gray-500 mt-2 flex items-center justify-between">
<div className="flex items-center space-x-4">
<span>
{autoSave && !readOnly ? 'Auto-save enabled' : 'Read-only mode'}
{autoSave && !readOnly ? "Auto-save enabled" : "Read-only mode"}
</span>
{lastSaved && autoSave && !readOnly && (
<span> Last saved: {lastSaved.toLocaleTimeString()}</span>
@ -255,10 +295,15 @@ const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
{/* Performance indicator */}
<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>
);
};
export default TinyMCEEditor;
export default TinyMCEEditor;

5
components/navigation.ts Normal file
View File

@ -0,0 +1,5 @@
// import {createSharedPathnamesNavigation} from 'next-intl/navigation';
// import {locales} from '@/config';
// export const {Link, redirect, usePathname, useRouter} =
// createSharedPathnamesNavigation({locales,});

View File

@ -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 {
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"
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"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,
}}
className={cn("p-3", className)}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
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: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"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
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"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"
? "[&: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"
: "[&:has([aria-selected])]:rounded-md"
),
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",
defaultClassNames.day
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100"
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...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
components={
{
// IconLeft: ({ className, ...props }) => (
// <ChevronLeft className={cn("size-4", className)} {...props} />
// ),
// IconRight: ({ className, ...props }) => (
// <ChevronRight className={cn("size-4", className)} {...props} />
// ),
}
}
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}
/>
)
);
}
export { Calendar, CalendarDayButton }
export { Calendar };

View File

@ -1,10 +1,10 @@
import { useEffect, useState, useCallback } from 'react';
import {
FacebookLoginResponse,
FacebookLoginError,
import { useEffect, useState, useCallback } from "react";
import {
FacebookLoginResponse,
FacebookLoginError,
FacebookUser,
FacebookSDKInitOptions
} from '@/types/facebook-login';
FacebookSDKInitOptions,
} from "@/types/facebook-login";
export interface UseFacebookLoginOptions extends FacebookSDKInitOptions {}
@ -13,20 +13,26 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
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
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
// Load Facebook SDK if not already loaded
if (!window.FB) {
const script = document.createElement('script');
const script = document.createElement("script");
script.src = `https://connect.facebook.net/en_US/sdk.js`;
script.async = true;
script.defer = true;
script.crossOrigin = 'anonymous';
script.crossOrigin = "anonymous";
window.fbAsyncInit = () => {
window.FB.init({
appId,
@ -36,10 +42,10 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
autoLogAppEvents,
});
setIsLoaded(true);
// Check login status
window.FB.getLoginStatus((response: any) => {
if (response.status === 'connected') {
if (response.status === "connected") {
setIsLoggedIn(true);
getUserInfo(response.authResponse.accessToken);
}
@ -57,38 +63,51 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
}, [appId, cookie, xfbml, version, autoLogAppEvents]);
const getUserInfo = useCallback((accessToken: string) => {
window.FB.api('/me', { fields: 'name,email,picture' }, (response: FacebookUser) => {
if (response && !response.error) {
setUser(response);
window.FB.api(
"/me",
{ fields: "name,email,picture" },
(response: FacebookUser) => {
if (response && !response.error) {
setUser(response);
}
}
});
);
}, []);
const login = useCallback((permissions: string[] = ['public_profile', 'email']) => {
return new Promise<FacebookLoginResponse>((resolve, reject) => {
if (!window.FB) {
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' });
const login = useCallback(
(permissions: string[] = ["public_profile", "email"]) => {
return new Promise<FacebookLoginResponse>((resolve, reject) => {
if (!window.FB) {
reject(new Error("Facebook SDK not loaded"));
return;
}
}, { scope: permissions.join(',') });
});
}, [getUserInfo]);
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(",") }
);
});
},
[getUserInfo]
);
const logout = useCallback(() => {
return new Promise<void>((resolve, reject) => {
if (!window.FB) {
reject(new Error('Facebook SDK not loaded'));
reject(new Error("Facebook SDK not loaded"));
return;
}
@ -103,7 +122,7 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
const getLoginStatus = useCallback(() => {
return new Promise<any>((resolve, reject) => {
if (!window.FB) {
reject(new Error('Facebook SDK not loaded'));
reject(new Error("Facebook SDK not loaded"));
return;
}
@ -121,4 +140,4 @@ export const useFacebookLogin = (options: UseFacebookLoginOptions) => {
logout,
getLoginStatus,
};
};
};

View File

@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "@/components/navigation";
import { toast } from "sonner";
import {
RegistrationFormData,
@ -53,6 +53,7 @@ import {
RegistrationRateLimiter,
REGISTRATION_CONSTANTS,
} from "@/lib/registration-utils";
import { useRouter } from "next/navigation";
// Global rate limiter instance
const registrationRateLimiter = new RegistrationRateLimiter();
@ -62,13 +63,13 @@ export const useOTP = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timer, setTimer] = useState<TimerState>(createTimer());
const startTimer = useCallback(() => {
setTimer(createTimer());
}, []);
const stopTimer = useCallback(() => {
setTimer(prev => ({
setTimer((prev) => ({
...prev,
isActive: false,
isExpired: true,
@ -78,7 +79,7 @@ export const useOTP = () => {
// Timer effect
useEffect(() => {
if (!timer.isActive || timer.countdown <= 0) {
setTimer(prev => ({
setTimer((prev) => ({
...prev,
isActive: false,
isExpired: true,
@ -87,7 +88,7 @@ export const useOTP = () => {
}
const interval = setInterval(() => {
setTimer(prev => ({
setTimer((prev) => ({
...prev,
countdown: Math.max(0, prev.countdown - 1000),
}));
@ -96,103 +97,115 @@ export const useOTP = () => {
return () => clearInterval(interval);
}, [timer.isActive, timer.countdown]);
const requestOTPCode = useCallback(async (
email: string,
category: UserCategory,
memberIdentity?: string
): Promise<boolean> => {
try {
setLoading(true);
setError(null);
const requestOTPCode = useCallback(
async (
email: string,
category: UserCategory,
memberIdentity?: string
): Promise<boolean> => {
try {
setLoading(true);
setError(null);
// Check rate limiting
const identifier = `${email}-${category}`;
if (!registrationRateLimiter.canAttempt(identifier)) {
const remainingAttempts = registrationRateLimiter.getRemainingAttempts(identifier);
throw new Error(`Too many OTP requests. Please try again later. Remaining attempts: ${remainingAttempts}`);
// Check rate limiting
const identifier = `${email}-${category}`;
if (!registrationRateLimiter.canAttempt(identifier)) {
const 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 = {
memberIdentity: memberIdentity || null,
email,
category: getCategoryRoleId(category),
};
const verifyOTPCode = useCallback(
async (
email: string,
otp: string,
category: UserCategory,
memberIdentity?: string
): Promise<any> => {
try {
setLoading(true);
setError(null);
// Debug logging
console.log("OTP Request Data:", data);
console.log("Category before conversion:", category);
console.log("Category after conversion:", getCategoryRoleId(category));
if (otp.length !== 6) {
throw new Error("OTP must be exactly 6 digits");
}
const response = await requestOTP(data);
const data = {
memberIdentity: memberIdentity || null,
email,
otp,
category: getCategoryRoleId(category),
};
if (response?.error) {
registrationRateLimiter.recordAttempt(identifier);
throw new Error(response.message || "Failed to send OTP");
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]
);
// 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 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]);
const resendOTP = useCallback(
async (
email: string,
category: UserCategory,
memberIdentity?: string
): Promise<boolean> => {
return await requestOTPCode(email, category, memberIdentity);
},
[requestOTPCode]
);
return {
requestOTP: requestOTPCode,
@ -220,7 +233,7 @@ export const useLocationData = () => {
setError(null);
const response = await listProvince();
if (!response || response.error) {
throw new Error(response?.message || "Failed to fetch provinces");
}
@ -241,7 +254,7 @@ export const useLocationData = () => {
setError(null);
const response = await listCity(provinceId);
if (response?.error) {
throw new Error(response.message || "Failed to fetch cities");
}
@ -263,7 +276,7 @@ export const useLocationData = () => {
setError(null);
const response = await listDistricts(cityId);
if (response?.error) {
throw new Error(response.message || "Failed to fetch districts");
}
@ -301,54 +314,60 @@ export const useInstituteData = (category?: number) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInstitutes = useCallback(async (categoryId?: number) => {
try {
setLoading(true);
setError(null);
const fetchInstitutes = useCallback(
async (categoryId?: number) => {
try {
setLoading(true);
setError(null);
const response = await listInstitusi(categoryId || category);
if (response?.error) {
throw new Error(response.message || "Failed to fetch institutes");
const response = await listInstitusi(categoryId || category);
if (response?.error) {
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 || []);
} catch (error: any) {
const errorMessage = error?.message || "Failed to fetch institutes";
setError(errorMessage);
showRegistrationError(error, "Failed to fetch institutes");
} finally {
setLoading(false);
}
}, [category]);
const saveInstitute = useCallback(
async (instituteData: InstituteData): Promise<number> => {
try {
setLoading(true);
setError(null);
const saveInstitute = useCallback(async (instituteData: InstituteData): Promise<number> => {
try {
setLoading(true);
setError(null);
const sanitizedData = sanitizeInstituteData(instituteData);
const sanitizedData = sanitizeInstituteData(instituteData);
const response = await saveInstitutes({
name: sanitizedData.name,
address: sanitizedData.address,
categoryRoleId: category || 6, // Use provided category or default to Journalist category
});
const response = await saveInstitutes({
name: sanitizedData.name,
address: sanitizedData.address,
categoryRoleId: category || 6, // Use provided category or default to Journalist category
});
if (response?.error) {
throw new Error(response.message || "Failed to save institute");
if (response?.error) {
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;
} 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]);
},
[category]
);
// Load institutes on mount if category is provided
useEffect(() => {
@ -371,49 +390,59 @@ export const useUserDataValidation = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const validateJournalistData = useCallback(async (certificateNumber: string): Promise<any> => {
try {
setLoading(true);
setError(null);
const validateJournalistData = useCallback(
async (certificateNumber: string): Promise<any> => {
try {
setLoading(true);
setError(null);
const response = await getDataJournalist(certificateNumber);
const response = await getDataJournalist(certificateNumber);
if (response?.error) {
throw new Error(response.message || "Invalid journalist certificate number");
if (response?.error) {
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;
} 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);
}
}, []);
const validatePersonnelData = useCallback(
async (policeNumber: string): Promise<any> => {
try {
setLoading(true);
setError(null);
const validatePersonnelData = useCallback(async (policeNumber: string): Promise<any> => {
try {
setLoading(true);
setError(null);
const response = await getDataPersonil(policeNumber);
const response = await getDataPersonil(policeNumber);
if (response?.error) {
throw new Error(response.message || "Invalid police number");
}
if (response?.error) {
throw new Error(response.message || "Invalid police number");
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 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 {
validateJournalistData,
@ -429,57 +458,70 @@ export const useRegistration = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submitRegistration = useCallback(async (
data: RegistrationFormData,
category: UserCategory,
userData: any,
instituteId?: number
): Promise<boolean> => {
try {
setLoading(true);
setError(null);
const submitRegistration = useCallback(
async (
data: RegistrationFormData,
category: UserCategory,
userData: any,
instituteId?: number
): Promise<boolean> => {
try {
setLoading(true);
setError(null);
// Sanitize and validate data
const sanitizedData = sanitizeRegistrationData(data);
// Validate password
const passwordValidation = validatePassword(sanitizedData.password, sanitizedData.passwordConf);
if (!passwordValidation.isValid) {
throw new Error(passwordValidation.errors[0]);
// Sanitize and validate data
const sanitizedData = sanitizeRegistrationData(data);
// Validate password
const passwordValidation = validatePassword(
sanitizedData.password,
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
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]);
},
[router]
);
return {
submitRegistration,
@ -490,81 +532,93 @@ export const useRegistration = () => {
// Hook for form validation
export const useFormValidation = () => {
const validateIdentityForm = useCallback((
data: JournalistRegistrationData | PersonnelRegistrationData | GeneralRegistrationData,
category: UserCategory
): { isValid: boolean; errors: string[] } => {
return validateIdentityData(data, category);
}, []);
const validateIdentityForm = useCallback(
(
data:
| JournalistRegistrationData
| PersonnelRegistrationData
| GeneralRegistrationData,
category: UserCategory
): { isValid: boolean; errors: string[] } => {
return validateIdentityData(data, category);
},
[]
);
const validateProfileForm = useCallback((data: RegistrationFormData): { isValid: boolean; errors: string[] } => {
const errors: string[] = [];
const validateProfileForm = useCallback(
(data: RegistrationFormData): { isValid: boolean; errors: string[] } => {
const errors: string[] = [];
// Validate required fields
if (!data.firstName?.trim()) {
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!);
// Validate required fields
if (!data.firstName?.trim()) {
errors.push("Full name is required");
}
}
if (!data.email?.trim()) {
errors.push("Email is required");
} else {
const emailValidation = validateEmail(data.email);
if (!emailValidation.isValid) {
errors.push(emailValidation.error!);
if (!data.username?.trim()) {
errors.push("Username is required");
} else {
const usernameValidation = validateUsername(data.username);
if (!usernameValidation.isValid) {
errors.push(usernameValidation.error!);
}
}
}
if (!data.phoneNumber?.trim()) {
errors.push("Phone number is required");
} else {
const phoneValidation = validatePhoneNumber(data.phoneNumber);
if (!phoneValidation.isValid) {
errors.push(phoneValidation.error!);
if (!data.email?.trim()) {
errors.push("Email is required");
} else {
const emailValidation = validateEmail(data.email);
if (!emailValidation.isValid) {
errors.push(emailValidation.error!);
}
}
}
if (!data.address?.trim()) {
errors.push("Address is required");
}
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]);
if (!data.phoneNumber?.trim()) {
errors.push("Phone number is required");
} else {
const phoneValidation = validatePhoneNumber(data.phoneNumber);
if (!phoneValidation.isValid) {
errors.push(phoneValidation.error!);
}
}
}
return {
isValid: errors.length === 0,
errors,
};
}, []);
if (!data.address?.trim()) {
errors.push("Address is required");
}
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 {
validateIdentityForm,
validateProfileForm,
};
};
};

374
lib/registration-utils.ts Normal file
View File

@ -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();

48
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tanstack/react-table": "^8.21.3",
"@tinymce/tinymce-react": "^6.3.0",
"@types/crypto-js": "^4.2.0",
"@types/js-cookie": "^3.0.6",
"@types/next": "^9.0.0",
@ -33,6 +34,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.3",
"input-otp": "^1.4.2",
"jotai": "^2.14.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.525.0",
"next": "15.3.5",
@ -2800,6 +2802,24 @@
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.4.tgz",
@ -3952,6 +3972,34 @@
"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": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",

View File

@ -22,6 +22,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tanstack/react-table": "^8.21.3",
"@tinymce/tinymce-react": "^6.3.0",
"@types/crypto-js": "^4.2.0",
"@types/js-cookie": "^3.0.6",
"@types/next": "^9.0.0",
@ -34,6 +35,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.3",
"input-otp": "^1.4.2",
"jotai": "^2.14.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.525.0",
"next": "15.3.5",

48
types/facebook-login.ts Normal file
View File

@ -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;
}
}

247
types/registration.ts Normal file
View File

@ -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';
}