-
Notifications
You must be signed in to change notification settings - Fork 241
Expand file tree
/
Copy pathaccount-section.tsx
More file actions
240 lines (219 loc) · 7.89 KB
/
Copy pathaccount-section.tsx
File metadata and controls
240 lines (219 loc) · 7.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
'use client';
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { DestructiveActionDialog } from "./destructive-action-dialog";
import { OAuthCallbackError } from "@/lib/api/auth";
interface DeletionFlowState {
reason: string | null;
dataExported: boolean;
isDeleting: boolean;
countdown: number;
}
const DEFAULT_DELETION_FLOW: DeletionFlowState = {
reason: null,
dataExported: false,
isDeleting: false,
countdown: 14
};
/** Number of profile fields that have a non-empty value. */
export function countCompletedProfileFields(profile: ProfileState): number {
return (Object.values(profile) as string[]).filter(
(value) => value.trim().length > 0,
).length;
}
/** Total number of profile fields tracked. */
export function totalProfileFields(profile: ProfileState): number {
return Object.keys(profile).length;
}
/** A profile is "complete" once every tracked field is filled in. */
export function isProfileComplete(profile: ProfileState): boolean {
return countCompletedProfileFields(profile) === totalProfileFields(profile);
}
interface StatusState {
message: string;
type: "success" | "error" | null;
}
const sectionMap = [
{
label: "Account",
description: "Profile, identity, and region defaults.",
badge: "Core",
},
{
label: "Notifications",
description: "Transaction alerts and delivery channels.",
badge: "Alerts",
},
{
label: "Security",
description: "Password, verification, and sessions.",
badge: "Protected",
},
{
label: "Wallets",
description: "Connected wallets and transfer safeguards.",
badge: "2 linked",
},
];
/**
* AccountSection component.
* Renders user profile information, identity details, and regional settings.
* Uses placeholder demo data pending full backend API integration.
*/
interface AccountSectionProps {
/**
* Controlled profile state. When provided the component renders this value
* and reports edits through `onProfileChange`. When omitted the section
* manages its own internal state (standalone use).
*/
profile?: ProfileState;
onProfileChange?: (next: ProfileState) => void;
/**
* Called with the final saved profile once a save succeeds, so a parent
* tracking a dirty/unsaved-changes flag can clear it. Not called on
* validation failure or a simulated save error.
*/
onSaved?: (saved: ProfileState) => void;
}
export default function AccountSection({
profile: controlledProfile,
onProfileChange,
onSaved,
}: AccountSectionProps = {}) {
const [internalProfile, setInternalProfile] =
useState<ProfileState>(DEFAULT_PROFILE);
const profile = controlledProfile ?? internalProfile;
const [status, setStatus] = useState<StatusState>({
message: "",
type: null,
});
const [isSaving, setIsSaving] = useState(false);
const [isEmailTouched, setIsEmailTouched] = useState(false);
const statusTimeoutRef = useRef<number | null>(null);
const isMountedRef = useRef(true);
// Trim before validating so incidental whitespace can neither defeat
// isValidEmail() nor end up persisted in a form the user never typed.
const normalizedEmail = profile.email.trim();
const isEmailValid = isValidEmail(normalizedEmail);
const showEmailError = isEmailTouched && !isEmailValid;
useEffect(() => {
const savedPreferences = localStorage.getItem('stellopay_cookie_preferences');
if (savedPreferences) {
try {
const parsed = JSON.parse(savedPreferences);
setAnalytics(!!parsed.analytics);
setMarketing(!!parsed.marketing);
} catch (e) {
console.error('Failed to parse cookie preferences', e);
}
}
}, []);
const handleSaveProfile = async () => {
setIsSaving(true);
setStatus({ message: "", type: null });
clearQueuedStatusReset();
try {
// Simulate async API call
await new Promise((resolve, reject) =>
setTimeout(() => {
// Simulate occasional failure for testing
if (Math.random() > 0.8) {
reject(new Error("Failed to save"));
} else {
resolve(null);
}
}, 1500),
);
if (isMountedRef.current) {
setStatus({
message:
"Account profile changes are staged and ready for backend save.",
type: "success",
});
onSaved?.({ ...profile, email: normalizedEmail });
}
} catch {
if (isMountedRef.current) {
setStatus({
message: "Failed to save changes. Please try again.",
type: "error",
});
}
} finally {
if (isMountedRef.current) {
setIsSaving(false);
queueStatusReset();
}
}
};
return (
<section className="p-6 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-lg shadow-sm">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Cookie Preferences</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-4">
Manage your granular cookie categories and tracking choices.
</p>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800/50 rounded-md">
<div>
<span className="font-medium text-gray-900 dark:text-gray-100 text-sm">Essential Cookies</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Required for the website to function properly.</p>
</div>
<input type="checkbox" checked disabled className="cursor-not-allowed opacity-75" aria-label="Essential cookies locked on" />
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800/50 rounded-md">
<div>
<span className="font-medium text-gray-900 dark:text-gray-100 text-sm">Analytics Cookies</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Help us improve our website by collecting usage data.</p>
</div>
<input
type="checkbox"
checked={analytics}
onChange={(e) => setAnalytics(e.target.checked)}
aria-label="Analytics cookies toggle"
className="cursor-pointer"
/>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800/50 rounded-md">
<div>
<span className="font-medium text-gray-900 dark:text-gray-100 text-sm">Marketing Cookies</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Used to deliver relevant advertisements and tracking.</p>
</div>
<input
type="checkbox"
checked={marketing}
onChange={(e) => setMarketing(e.target.checked)}
aria-label="Marketing cookies toggle"
className="cursor-pointer"
/>
</div>
</div>
<button
onClick={handleSave}
className="mt-5 px-4 py-2 bg-black dark:bg-white text-white dark:text-black text-sm font-medium rounded-md hover:opacity-95 transition"
>
Save Preferences
</button>
</section>
);
};
interface AccountSectionProps {
profile: any;
onProfileChange: (profile: any) => void;
}
interface DeletionDialogProps {
title: string;
description: string;
impactItems: string[];
confirmationToken: string;
confirmationLabel: string;
confirmLabel: string;
onConfirm: () => void;
}
const getConfirmationError = (value: string, token: string): string | null => {
if (value === token) return null;
if (value.trim() === token) return `Remove extra spaces — type exactly "${token}"`;
if (value.toLowerCase() === token.toLowerCase()) return `Check capitalization — type exactly "${token}"`;
return `The text doesn't match — type exactly "${token}"`;
};