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"; "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;

View File

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

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

View File

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

View File

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

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-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",

View File

@ -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",

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