@onexapis/cli 1.1.38 → 1.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +384 -380
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +381 -376
- package/dist/cli.mjs.map +1 -1
- package/dist/index.js +258 -293
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +255 -289
- package/dist/index.mjs.map +1 -1
- package/dist/preview/preview-app.tsx +13 -5
- package/package.json +1 -3
- package/templates/default/AUTH_AND_PROFILE.md +167 -0
- package/templates/default/CLAUDE.md +334 -1
- package/templates/default/LAYOUT.md +195 -0
- package/templates/default/bundle-entry.ts +5 -0
- package/templates/default/esbuild.config.js +20 -0
- package/templates/default/hooks/index.ts +26 -0
- package/templates/default/hooks/use-forgot-password-form.ts +90 -0
- package/templates/default/hooks/use-login-form.ts +102 -0
- package/templates/default/hooks/use-profile-form.ts +255 -0
- package/templates/default/hooks/use-register-form.ts +154 -0
- package/templates/default/hooks/use-verify-code-form.ts +224 -0
- package/templates/default/index.ts +21 -1
- package/templates/default/pages/about.ts +2 -2
- package/templates/default/pages/forgot-password.ts +39 -0
- package/templates/default/pages/home.ts +4 -4
- package/templates/default/pages/login.ts +39 -0
- package/templates/default/pages/profile.ts +39 -0
- package/templates/default/pages/register.ts +39 -0
- package/templates/default/pages/showcase.ts +7 -7
- package/templates/default/pages/verify-code.ts +39 -0
- package/templates/default/sections/about/about.schema.ts +1 -1
- package/templates/default/sections/auth-forgot-password/auth-forgot-password-default.tsx +192 -0
- package/templates/default/sections/auth-forgot-password/auth-forgot-password.schema.ts +150 -0
- package/templates/default/sections/auth-forgot-password/index.ts +14 -0
- package/templates/default/sections/auth-login/auth-login-default.tsx +238 -0
- package/templates/default/sections/auth-login/auth-login.schema.ts +171 -0
- package/templates/default/sections/auth-login/index.ts +14 -0
- package/templates/default/sections/auth-register/auth-register-default.tsx +327 -0
- package/templates/default/sections/auth-register/auth-register.schema.ts +188 -0
- package/templates/default/sections/auth-register/index.ts +14 -0
- package/templates/default/sections/auth-verify-code/auth-verify-code-default.tsx +209 -0
- package/templates/default/sections/auth-verify-code/auth-verify-code.schema.ts +150 -0
- package/templates/default/sections/auth-verify-code/index.ts +14 -0
- package/templates/default/sections/cta/cta.schema.ts +1 -1
- package/templates/default/sections/features/features.schema.ts +1 -1
- package/templates/default/sections/footer/footer-default.tsx +214 -0
- package/templates/default/sections/footer/footer.schema.ts +170 -0
- package/templates/default/sections/footer/index.ts +14 -0
- package/templates/default/sections/gallery/gallery.schema.ts +1 -1
- package/templates/default/sections/header/header-default.tsx +322 -0
- package/templates/default/sections/header/header.schema.ts +168 -0
- package/templates/default/sections/header/index.ts +14 -0
- package/templates/default/sections/hero/hero.schema.ts +1 -1
- package/templates/default/sections/profile/index.ts +14 -0
- package/templates/default/sections/profile/profile-default.tsx +522 -0
- package/templates/default/sections/profile/profile.schema.ts +228 -0
- package/templates/default/sections/stats/stats.schema.ts +1 -1
- package/templates/default/sections/testimonials/testimonials.schema.ts +1 -1
- package/templates/default/sections-registry.ts +28 -0
- package/templates/default/theme.layout.ts +71 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Header & Footer Layout — Default Theme
|
|
2
|
+
|
|
3
|
+
This document covers the built-in header and footer sections that render on every page via `theme.layout.ts`.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
Header and footer are **layout-level sections** — they're defined once in `theme.layout.ts` and rendered on every page automatically. Individual pages can opt out using `hideHeader` / `hideFooter`.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
theme.layout.ts
|
|
11
|
+
├── headerSections[] ──→ rendered as <header> on every page
|
|
12
|
+
└── footerSections[] ──→ rendered as <footer> on every page
|
|
13
|
+
|
|
14
|
+
Page config
|
|
15
|
+
├── hideHeader: true ──→ skips header for this page
|
|
16
|
+
└── hideFooter: true ──→ skips footer for this page
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Header Section
|
|
20
|
+
|
|
21
|
+
### Smart Scroll Behavior (default: on)
|
|
22
|
+
|
|
23
|
+
The header auto-hides when scrolling down and reappears when scrolling up. This is fully customizable via schema settings:
|
|
24
|
+
|
|
25
|
+
| Setting | Type | Default | Description |
|
|
26
|
+
| -------------------- | -------- | ----------- | --------------------------------------------- |
|
|
27
|
+
| `sticky` | checkbox | `true` | Pin header to top of viewport |
|
|
28
|
+
| `autoHide` | checkbox | `true` | Hide on scroll down, show on scroll up |
|
|
29
|
+
| `scrollThreshold` | number | `10` | Pixels of scroll needed to trigger hide/show |
|
|
30
|
+
| `showShadowOnScroll` | checkbox | `true` | Add drop shadow when page is scrolled |
|
|
31
|
+
| `transparentOnTop` | checkbox | `false` | Transparent background when at top of page |
|
|
32
|
+
| `headerHeight` | select | `16` (64px) | Compact (48px) / Default (64px) / Tall (80px) |
|
|
33
|
+
|
|
34
|
+
### Behavior Presets
|
|
35
|
+
|
|
36
|
+
**Default (sticky + auto-hide):**
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// theme.layout.ts
|
|
40
|
+
settings: {
|
|
41
|
+
sticky: true,
|
|
42
|
+
autoHide: true,
|
|
43
|
+
showShadowOnScroll: true,
|
|
44
|
+
transparentOnTop: false,
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Always visible (no auto-hide):**
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
settings: {
|
|
52
|
+
sticky: true,
|
|
53
|
+
autoHide: false, // ← stays visible at all times
|
|
54
|
+
showShadowOnScroll: true,
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Transparent hero overlay:**
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
settings: {
|
|
62
|
+
sticky: true,
|
|
63
|
+
autoHide: true,
|
|
64
|
+
transparentOnTop: true, // ← transparent at top, solid on scroll
|
|
65
|
+
showShadowOnScroll: true,
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Static (not sticky):**
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
settings: {
|
|
73
|
+
sticky: false, // ← scrolls away with the page
|
|
74
|
+
autoHide: false,
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Other Header Settings
|
|
79
|
+
|
|
80
|
+
| Setting | Type | Default | Description |
|
|
81
|
+
| ----------------- | ----- | --------------------- | ----------------------------------------- |
|
|
82
|
+
| `logoText` | text | `"My Site"` | Logo text (fallback if no logo component) |
|
|
83
|
+
| `navigationItems` | array | Home, About, Showcase | Navigation links `[{ label, href }]` |
|
|
84
|
+
| `ctaText` | text | `"Contact"` | CTA button text |
|
|
85
|
+
| `ctaLink` | url | `"#contact"` | CTA button link |
|
|
86
|
+
| `backgroundColor` | color | `#FFFFFF` | Header background |
|
|
87
|
+
| `textColor` | color | `#111827` | Navigation text color |
|
|
88
|
+
| `primaryColor` | color | `#2563EB` | CTA button color |
|
|
89
|
+
|
|
90
|
+
### How Auto-Hide Works
|
|
91
|
+
|
|
92
|
+
The header uses `requestAnimationFrame` for smooth 60fps scroll detection:
|
|
93
|
+
|
|
94
|
+
1. **Scroll down > threshold** (and past 80px from top) → header slides up (`translateY(-100%)`)
|
|
95
|
+
2. **Scroll up > threshold** → header slides back down (`translateY(0)`)
|
|
96
|
+
3. **At very top** (scrollY = 0) → always visible
|
|
97
|
+
4. **In editor** (`isEditing = true`) → auto-hide disabled, always visible
|
|
98
|
+
5. CSS transition: `duration-300 ease-in-out` for smooth animation
|
|
99
|
+
|
|
100
|
+
## Footer Section
|
|
101
|
+
|
|
102
|
+
### Settings
|
|
103
|
+
|
|
104
|
+
| Setting | Type | Default | Description |
|
|
105
|
+
| ------------------ | -------- | -------------------------- | ------------------------------- |
|
|
106
|
+
| `companyName` | text | `"My Site"` | Company name |
|
|
107
|
+
| `description` | textarea | `"A clean and minimal..."` | Company description |
|
|
108
|
+
| `showAboutColumn` | checkbox | `true` | Show/hide about links column |
|
|
109
|
+
| `aboutColumnTitle` | text | `"About"` | About column heading |
|
|
110
|
+
| `aboutLinks` | array | About Us, Contact | About links `[{ label, href }]` |
|
|
111
|
+
| `showLinksColumn` | checkbox | `true` | Show/hide quick links column |
|
|
112
|
+
| `linksColumnTitle` | text | `"Quick Links"` | Links column heading |
|
|
113
|
+
| `quickLinks` | array | Home, Showcase | Quick links `[{ label, href }]` |
|
|
114
|
+
| `copyrightText` | text | `"My Site. All rights..."` | Copyright text |
|
|
115
|
+
| `backgroundColor` | color | `#111827` | Footer background |
|
|
116
|
+
| `textColor` | color | `#9CA3AF` | Body text color |
|
|
117
|
+
| `primaryColor` | color | `#FFFFFF` | Heading color |
|
|
118
|
+
|
|
119
|
+
## Per-Page Visibility
|
|
120
|
+
|
|
121
|
+
Auth pages (login, register, forgot-password, verify-code) have `hideHeader: true` and `hideFooter: true` by default since they're full-screen.
|
|
122
|
+
|
|
123
|
+
### Hide header/footer on a page
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// pages/my-page.ts
|
|
127
|
+
export const myPageConfig = {
|
|
128
|
+
title: "My Page",
|
|
129
|
+
handle: "my-page",
|
|
130
|
+
path: "/my-page",
|
|
131
|
+
hideHeader: true, // ← no header on this page
|
|
132
|
+
hideFooter: true, // ← no footer on this page
|
|
133
|
+
// ...
|
|
134
|
+
};
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Pages with header/footer (default)
|
|
138
|
+
|
|
139
|
+
Pages without `hideHeader`/`hideFooter` get both automatically:
|
|
140
|
+
|
|
141
|
+
| Page | Header | Footer |
|
|
142
|
+
| ------------------------------------ | ------ | ------ |
|
|
143
|
+
| Home (`/`) | Yes | Yes |
|
|
144
|
+
| About (`/about`) | Yes | Yes |
|
|
145
|
+
| Showcase (`/showcase`) | Yes | Yes |
|
|
146
|
+
| Profile (`/profile`) | Yes | Yes |
|
|
147
|
+
| Login (`/login`) | No | No |
|
|
148
|
+
| Register (`/register`) | No | No |
|
|
149
|
+
| Forgot Password (`/forgot-password`) | No | No |
|
|
150
|
+
| Verify Code (`/verify-code`) | No | No |
|
|
151
|
+
|
|
152
|
+
## Customizing in the Editor
|
|
153
|
+
|
|
154
|
+
In the visual editor:
|
|
155
|
+
|
|
156
|
+
1. Go to **Theme Settings** (gear icon)
|
|
157
|
+
2. Open **Header Sections** or **Footer Sections** accordion
|
|
158
|
+
3. Click on the section to edit its settings
|
|
159
|
+
4. Changes apply to all pages that show the header/footer
|
|
160
|
+
|
|
161
|
+
## File Structure
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
sections/
|
|
165
|
+
header/
|
|
166
|
+
header-default.tsx # Component with smart scroll behavior
|
|
167
|
+
header.schema.ts # All settings including scroll behavior
|
|
168
|
+
index.ts
|
|
169
|
+
footer/
|
|
170
|
+
footer-default.tsx # 3-column footer
|
|
171
|
+
footer.schema.ts # Company info, links, copyright settings
|
|
172
|
+
index.ts
|
|
173
|
+
theme.layout.ts # References header/footer with default settings
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Extending
|
|
177
|
+
|
|
178
|
+
### Adding a second header template
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// sections/header/header-centered.tsx
|
|
182
|
+
export function HeaderCentered({ section, schema, isEditing }) { ... }
|
|
183
|
+
|
|
184
|
+
// sections/header/index.ts
|
|
185
|
+
export const headerComponents = {
|
|
186
|
+
default: HeaderDefault,
|
|
187
|
+
centered: HeaderCentered, // ← new template
|
|
188
|
+
};
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Then in `theme.layout.ts`, change `template: "centered"`.
|
|
192
|
+
|
|
193
|
+
### Custom scroll behavior
|
|
194
|
+
|
|
195
|
+
Override the `useHeaderScroll` hook logic in `header-default.tsx`, or create a new template with different behavior. All scroll settings are passed via schema — no code changes needed for basic customization.
|
|
@@ -16,3 +16,8 @@ export { default as layoutConfig } from "./theme.layout";
|
|
|
16
16
|
export { default as homePageConfig } from "./pages/home";
|
|
17
17
|
export { default as aboutPageConfig } from "./pages/about";
|
|
18
18
|
export { default as showcasePageConfig } from "./pages/showcase";
|
|
19
|
+
export { default as loginPageConfig } from "./pages/login";
|
|
20
|
+
export { default as registerPageConfig } from "./pages/register";
|
|
21
|
+
export { default as forgotPasswordPageConfig } from "./pages/forgot-password";
|
|
22
|
+
export { default as verifyCodePageConfig } from "./pages/verify-code";
|
|
23
|
+
export { default as profilePageConfig } from "./pages/profile";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const { build } = require("esbuild");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
build({
|
|
5
|
+
entryPoints: [path.resolve(__dirname, "bundle-entry.ts")],
|
|
6
|
+
bundle: true,
|
|
7
|
+
outfile: path.resolve(__dirname, "dist/bundle.mjs"),
|
|
8
|
+
format: "esm",
|
|
9
|
+
target: "es2020",
|
|
10
|
+
platform: "browser",
|
|
11
|
+
jsx: "automatic",
|
|
12
|
+
external: ["react", "react-dom", "@onexapis/core", "@onexapis/core/*"],
|
|
13
|
+
sourcemap: true,
|
|
14
|
+
minify: process.env.NODE_ENV === "production",
|
|
15
|
+
define: {
|
|
16
|
+
"process.env.NODE_ENV": JSON.stringify(
|
|
17
|
+
process.env.NODE_ENV || "development"
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
}).catch(() => process.exit(1));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Hooks
|
|
3
|
+
* Re-exports all authentication form hooks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { useLoginForm } from "./use-login-form";
|
|
7
|
+
export type { LoginFormData, UseLoginFormReturn } from "./use-login-form";
|
|
8
|
+
|
|
9
|
+
export { useRegisterForm } from "./use-register-form";
|
|
10
|
+
export type {
|
|
11
|
+
RegisterFormData,
|
|
12
|
+
UseRegisterFormReturn,
|
|
13
|
+
} from "./use-register-form";
|
|
14
|
+
|
|
15
|
+
export { useForgotPasswordForm } from "./use-forgot-password-form";
|
|
16
|
+
export type { UseForgotPasswordFormReturn } from "./use-forgot-password-form";
|
|
17
|
+
|
|
18
|
+
export { useVerifyCodeForm } from "./use-verify-code-form";
|
|
19
|
+
export type { UseVerifyCodeFormReturn } from "./use-verify-code-form";
|
|
20
|
+
|
|
21
|
+
export { useProfileForm } from "./use-profile-form";
|
|
22
|
+
export type {
|
|
23
|
+
ProfileFormData,
|
|
24
|
+
ChangePasswordData,
|
|
25
|
+
UseProfileFormReturn,
|
|
26
|
+
} from "./use-profile-form";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgot Password Form Hook
|
|
3
|
+
* Manages form state, validation, and submission via useAuth
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use client";
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback } from "react";
|
|
9
|
+
import { useAuth } from "@onexapis/core/hooks";
|
|
10
|
+
|
|
11
|
+
export interface UseForgotPasswordFormReturn {
|
|
12
|
+
username: string;
|
|
13
|
+
error: string | null;
|
|
14
|
+
isPending: boolean;
|
|
15
|
+
setUsername: (value: string) => void;
|
|
16
|
+
handleUsernameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
17
|
+
handleSubmit: (e: React.FormEvent) => void;
|
|
18
|
+
clearError: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useForgotPasswordForm(): UseForgotPasswordFormReturn {
|
|
22
|
+
const [username, setUsername] = useState("");
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const [isPending, setIsPending] = useState(false);
|
|
25
|
+
|
|
26
|
+
const { forgotPassword } = useAuth();
|
|
27
|
+
|
|
28
|
+
const handleUsernameChange = useCallback(
|
|
29
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
30
|
+
setUsername(e.target.value);
|
|
31
|
+
setError(null);
|
|
32
|
+
},
|
|
33
|
+
[]
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const handleSubmit = useCallback(
|
|
37
|
+
async (e: React.FormEvent) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
if (!username) {
|
|
40
|
+
setError("Please enter your email or username");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
username.includes("@") &&
|
|
45
|
+
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(username)
|
|
46
|
+
) {
|
|
47
|
+
setError("Invalid email format");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setError(null);
|
|
52
|
+
setIsPending(true);
|
|
53
|
+
try {
|
|
54
|
+
await forgotPassword({ username });
|
|
55
|
+
// Store countdown and redirect to verify-code
|
|
56
|
+
if (typeof window !== "undefined") {
|
|
57
|
+
const expiryTimestamp = Date.now() + 60 * 1000;
|
|
58
|
+
localStorage.setItem(
|
|
59
|
+
`resend_countdown_${username}`,
|
|
60
|
+
expiryTimestamp.toString()
|
|
61
|
+
);
|
|
62
|
+
window.location.href = `/verify-code?mode=reset&username=${encodeURIComponent(username)}`;
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const message =
|
|
66
|
+
err instanceof Error
|
|
67
|
+
? err.message
|
|
68
|
+
: "Failed to send verification code";
|
|
69
|
+
setError(message);
|
|
70
|
+
} finally {
|
|
71
|
+
setIsPending(false);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[username, forgotPassword]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const clearError = useCallback(() => {
|
|
78
|
+
setError(null);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
username,
|
|
83
|
+
error,
|
|
84
|
+
isPending,
|
|
85
|
+
setUsername,
|
|
86
|
+
handleUsernameChange,
|
|
87
|
+
handleSubmit,
|
|
88
|
+
clearError,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login Form Hook
|
|
3
|
+
* Manages form state, validation, and submission via useAuth
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use client";
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback } from "react";
|
|
9
|
+
import { useAuth } from "@onexapis/core/hooks";
|
|
10
|
+
|
|
11
|
+
export interface LoginFormData {
|
|
12
|
+
username: string;
|
|
13
|
+
password: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseLoginFormReturn {
|
|
17
|
+
formData: LoginFormData;
|
|
18
|
+
errors: Record<string, string>;
|
|
19
|
+
showPassword: boolean;
|
|
20
|
+
isPending: boolean;
|
|
21
|
+
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
22
|
+
handleSubmit: (e: React.FormEvent) => void;
|
|
23
|
+
toggleShowPassword: () => void;
|
|
24
|
+
clearError: (field: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useLoginForm(): UseLoginFormReturn {
|
|
28
|
+
const [formData, setFormData] = useState<LoginFormData>({
|
|
29
|
+
username: "",
|
|
30
|
+
password: "",
|
|
31
|
+
});
|
|
32
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
33
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
34
|
+
const [isPending, setIsPending] = useState(false);
|
|
35
|
+
|
|
36
|
+
const { login } = useAuth();
|
|
37
|
+
|
|
38
|
+
const handleInputChange = useCallback(
|
|
39
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
40
|
+
const { name, value } = e.target;
|
|
41
|
+
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
42
|
+
if (errors[name]) {
|
|
43
|
+
setErrors((prev) => ({ ...prev, [name]: "" }));
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[errors]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const validateForm = useCallback((): boolean => {
|
|
50
|
+
const newErrors: Record<string, string> = {};
|
|
51
|
+
|
|
52
|
+
if (!formData.username) {
|
|
53
|
+
newErrors.username = "Please enter your username or email";
|
|
54
|
+
}
|
|
55
|
+
if (!formData.password) {
|
|
56
|
+
newErrors.password = "Please enter your password";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setErrors(newErrors);
|
|
60
|
+
return Object.keys(newErrors).length === 0;
|
|
61
|
+
}, [formData]);
|
|
62
|
+
|
|
63
|
+
const handleSubmit = useCallback(
|
|
64
|
+
async (e: React.FormEvent) => {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
if (!validateForm()) return;
|
|
67
|
+
|
|
68
|
+
setIsPending(true);
|
|
69
|
+
try {
|
|
70
|
+
await login({
|
|
71
|
+
username: formData.username,
|
|
72
|
+
password: formData.password,
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : "Login failed";
|
|
76
|
+
setErrors({ form: message });
|
|
77
|
+
} finally {
|
|
78
|
+
setIsPending(false);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[validateForm, login, formData]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const toggleShowPassword = useCallback(() => {
|
|
85
|
+
setShowPassword((prev) => !prev);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const clearError = useCallback((field: string) => {
|
|
89
|
+
setErrors((prev) => ({ ...prev, [field]: "" }));
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
formData,
|
|
94
|
+
errors,
|
|
95
|
+
showPassword,
|
|
96
|
+
isPending,
|
|
97
|
+
handleInputChange,
|
|
98
|
+
handleSubmit,
|
|
99
|
+
toggleShowPassword,
|
|
100
|
+
clearError,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile Form Hook
|
|
3
|
+
* Manages profile form state, dirty tracking, and submission via useAuth
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use client";
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
9
|
+
import { useAuth } from "@onexapis/core/hooks";
|
|
10
|
+
|
|
11
|
+
export interface ProfileFormData {
|
|
12
|
+
name: string;
|
|
13
|
+
email: string;
|
|
14
|
+
phone: string;
|
|
15
|
+
address: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChangePasswordData {
|
|
19
|
+
currentPassword: string;
|
|
20
|
+
newPassword: string;
|
|
21
|
+
confirmPassword: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseProfileFormReturn {
|
|
25
|
+
// User state
|
|
26
|
+
isAuthenticated: boolean;
|
|
27
|
+
isLoading: boolean;
|
|
28
|
+
|
|
29
|
+
// Form state
|
|
30
|
+
formData: ProfileFormData;
|
|
31
|
+
handleFieldChange: (field: keyof ProfileFormData, value: string) => void;
|
|
32
|
+
isDirty: boolean;
|
|
33
|
+
isSubmitting: boolean;
|
|
34
|
+
submitError: string | null;
|
|
35
|
+
handleSubmit: (e?: React.FormEvent) => Promise<void>;
|
|
36
|
+
|
|
37
|
+
// Change password
|
|
38
|
+
passwordData: ChangePasswordData;
|
|
39
|
+
handlePasswordFieldChange: (
|
|
40
|
+
field: keyof ChangePasswordData,
|
|
41
|
+
value: string
|
|
42
|
+
) => void;
|
|
43
|
+
showCurrentPassword: boolean;
|
|
44
|
+
showNewPassword: boolean;
|
|
45
|
+
showConfirmPassword: boolean;
|
|
46
|
+
toggleCurrentPassword: () => void;
|
|
47
|
+
toggleNewPassword: () => void;
|
|
48
|
+
toggleConfirmPassword: () => void;
|
|
49
|
+
passwordErrors: Record<string, string>;
|
|
50
|
+
isChangingPassword: boolean;
|
|
51
|
+
showPasswordForm: boolean;
|
|
52
|
+
setShowPasswordForm: (show: boolean) => void;
|
|
53
|
+
handlePasswordSubmit: (e?: React.FormEvent) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
// Logout
|
|
56
|
+
handleLogout: () => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useProfileForm(): UseProfileFormReturn {
|
|
60
|
+
const {
|
|
61
|
+
user,
|
|
62
|
+
isAuthenticated,
|
|
63
|
+
isLoading,
|
|
64
|
+
updateProfile,
|
|
65
|
+
changePassword,
|
|
66
|
+
logout,
|
|
67
|
+
initialize,
|
|
68
|
+
} = useAuth();
|
|
69
|
+
|
|
70
|
+
const [formData, setFormData] = useState<ProfileFormData>({
|
|
71
|
+
name: "",
|
|
72
|
+
email: "",
|
|
73
|
+
phone: "",
|
|
74
|
+
address: "",
|
|
75
|
+
});
|
|
76
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
77
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
78
|
+
const initialDataRef = useRef<ProfileFormData | null>(null);
|
|
79
|
+
|
|
80
|
+
// Password form
|
|
81
|
+
const [passwordData, setPasswordData] = useState<ChangePasswordData>({
|
|
82
|
+
currentPassword: "",
|
|
83
|
+
newPassword: "",
|
|
84
|
+
confirmPassword: "",
|
|
85
|
+
});
|
|
86
|
+
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
|
87
|
+
const [showNewPassword, setShowNewPassword] = useState(false);
|
|
88
|
+
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
89
|
+
const [passwordErrors, setPasswordErrors] = useState<Record<string, string>>(
|
|
90
|
+
{}
|
|
91
|
+
);
|
|
92
|
+
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
|
93
|
+
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
|
94
|
+
|
|
95
|
+
// Initialize auth on mount
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
initialize();
|
|
98
|
+
}, [initialize]);
|
|
99
|
+
|
|
100
|
+
// Populate form from user data
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (user) {
|
|
103
|
+
const data: ProfileFormData = {
|
|
104
|
+
name: user.name || "",
|
|
105
|
+
email: user.email || "",
|
|
106
|
+
phone: user.phone_number || "",
|
|
107
|
+
address: user.address || "",
|
|
108
|
+
};
|
|
109
|
+
setFormData(data);
|
|
110
|
+
initialDataRef.current = data;
|
|
111
|
+
}
|
|
112
|
+
}, [user]);
|
|
113
|
+
|
|
114
|
+
const handleFieldChange = useCallback(
|
|
115
|
+
(field: keyof ProfileFormData, value: string) => {
|
|
116
|
+
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
117
|
+
setSubmitError(null);
|
|
118
|
+
},
|
|
119
|
+
[]
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const isDirty =
|
|
123
|
+
initialDataRef.current !== null &&
|
|
124
|
+
(formData.name !== initialDataRef.current.name ||
|
|
125
|
+
formData.phone !== initialDataRef.current.phone ||
|
|
126
|
+
formData.address !== initialDataRef.current.address);
|
|
127
|
+
|
|
128
|
+
const handleSubmit = useCallback(
|
|
129
|
+
async (e?: React.FormEvent) => {
|
|
130
|
+
e?.preventDefault();
|
|
131
|
+
if (!isDirty) return;
|
|
132
|
+
|
|
133
|
+
setIsSubmitting(true);
|
|
134
|
+
setSubmitError(null);
|
|
135
|
+
try {
|
|
136
|
+
await updateProfile({
|
|
137
|
+
name: formData.name,
|
|
138
|
+
phone_number: formData.phone,
|
|
139
|
+
address: formData.address,
|
|
140
|
+
});
|
|
141
|
+
// Update initial data ref to new values
|
|
142
|
+
initialDataRef.current = { ...formData };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const message =
|
|
145
|
+
error instanceof Error ? error.message : "Failed to update profile";
|
|
146
|
+
setSubmitError(message);
|
|
147
|
+
} finally {
|
|
148
|
+
setIsSubmitting(false);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
[isDirty, formData, updateProfile]
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Password handlers
|
|
155
|
+
const handlePasswordFieldChange = useCallback(
|
|
156
|
+
(field: keyof ChangePasswordData, value: string) => {
|
|
157
|
+
setPasswordData((prev) => ({ ...prev, [field]: value }));
|
|
158
|
+
if (passwordErrors[field]) {
|
|
159
|
+
setPasswordErrors((prev) => ({ ...prev, [field]: "" }));
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
[passwordErrors]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const validatePassword = useCallback((): boolean => {
|
|
166
|
+
const errors: Record<string, string> = {};
|
|
167
|
+
|
|
168
|
+
if (!passwordData.currentPassword) {
|
|
169
|
+
errors.currentPassword = "Please enter your current password";
|
|
170
|
+
}
|
|
171
|
+
if (!passwordData.newPassword) {
|
|
172
|
+
errors.newPassword = "Please enter a new password";
|
|
173
|
+
} else if (passwordData.newPassword.length < 8) {
|
|
174
|
+
errors.newPassword = "Password must be at least 8 characters";
|
|
175
|
+
} else if (
|
|
176
|
+
!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(passwordData.newPassword)
|
|
177
|
+
) {
|
|
178
|
+
errors.newPassword =
|
|
179
|
+
"Password must contain uppercase, lowercase, and a number";
|
|
180
|
+
} else if (passwordData.newPassword === passwordData.currentPassword) {
|
|
181
|
+
errors.newPassword =
|
|
182
|
+
"New password must be different from current password";
|
|
183
|
+
}
|
|
184
|
+
if (!passwordData.confirmPassword) {
|
|
185
|
+
errors.confirmPassword = "Please confirm your new password";
|
|
186
|
+
} else if (passwordData.newPassword !== passwordData.confirmPassword) {
|
|
187
|
+
errors.confirmPassword = "Passwords do not match";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
setPasswordErrors(errors);
|
|
191
|
+
return Object.keys(errors).length === 0;
|
|
192
|
+
}, [passwordData]);
|
|
193
|
+
|
|
194
|
+
const handlePasswordSubmit = useCallback(
|
|
195
|
+
async (e?: React.FormEvent) => {
|
|
196
|
+
e?.preventDefault();
|
|
197
|
+
if (!validatePassword()) return;
|
|
198
|
+
|
|
199
|
+
setIsChangingPassword(true);
|
|
200
|
+
try {
|
|
201
|
+
await changePassword({
|
|
202
|
+
oldPassword: passwordData.currentPassword,
|
|
203
|
+
newPassword: passwordData.newPassword,
|
|
204
|
+
});
|
|
205
|
+
// Reset form on success
|
|
206
|
+
setPasswordData({
|
|
207
|
+
currentPassword: "",
|
|
208
|
+
newPassword: "",
|
|
209
|
+
confirmPassword: "",
|
|
210
|
+
});
|
|
211
|
+
setShowPasswordForm(false);
|
|
212
|
+
setPasswordErrors({});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const message =
|
|
215
|
+
error instanceof Error ? error.message : "Failed to change password";
|
|
216
|
+
setPasswordErrors({ form: message });
|
|
217
|
+
} finally {
|
|
218
|
+
setIsChangingPassword(false);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
[validatePassword, changePassword, passwordData]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const handleLogout = useCallback(async () => {
|
|
225
|
+
await logout();
|
|
226
|
+
if (typeof window !== "undefined") {
|
|
227
|
+
window.location.href = "/login";
|
|
228
|
+
}
|
|
229
|
+
}, [logout]);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
isAuthenticated,
|
|
233
|
+
isLoading,
|
|
234
|
+
formData,
|
|
235
|
+
handleFieldChange,
|
|
236
|
+
isDirty,
|
|
237
|
+
isSubmitting,
|
|
238
|
+
submitError,
|
|
239
|
+
handleSubmit,
|
|
240
|
+
passwordData,
|
|
241
|
+
handlePasswordFieldChange,
|
|
242
|
+
showCurrentPassword,
|
|
243
|
+
showNewPassword,
|
|
244
|
+
showConfirmPassword,
|
|
245
|
+
toggleCurrentPassword: () => setShowCurrentPassword((p) => !p),
|
|
246
|
+
toggleNewPassword: () => setShowNewPassword((p) => !p),
|
|
247
|
+
toggleConfirmPassword: () => setShowConfirmPassword((p) => !p),
|
|
248
|
+
passwordErrors,
|
|
249
|
+
isChangingPassword,
|
|
250
|
+
showPasswordForm,
|
|
251
|
+
setShowPasswordForm,
|
|
252
|
+
handlePasswordSubmit,
|
|
253
|
+
handleLogout,
|
|
254
|
+
};
|
|
255
|
+
}
|