@stevederico/skateboard-ui 1.1.1 → 1.1.2
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/App.jsx +17 -11
- package/Context.jsx +78 -18
- package/ErrorBoundary.jsx +81 -0
- package/Layout.jsx +20 -4
- package/SignInView.jsx +22 -4
- package/Utilities.js +115 -37
- package/package.json +5 -1
package/App.jsx
CHANGED
|
@@ -18,7 +18,8 @@ import PaymentView from './PaymentView.jsx';
|
|
|
18
18
|
import SettingsView from './SettingsView.jsx';
|
|
19
19
|
import NotFound from './NotFound.jsx';
|
|
20
20
|
import ProtectedRoute from './ProtectedRoute.jsx';
|
|
21
|
-
import
|
|
21
|
+
import ErrorBoundary from './ErrorBoundary.jsx';
|
|
22
|
+
import { useAppSetup, initializeUtilities, validateConstants } from './Utilities.js';
|
|
22
23
|
import { ContextProvider, getState } from './Context.jsx';
|
|
23
24
|
|
|
24
25
|
function App({ constants, appRoutes, defaultRoute }) {
|
|
@@ -59,6 +60,9 @@ function App({ constants, appRoutes, defaultRoute }) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export function createSkateboardApp({ constants, appRoutes, defaultRoute = appRoutes[0]?.path || 'home', wrapper: Wrapper }) {
|
|
63
|
+
// Validate constants before initialization
|
|
64
|
+
validateConstants(constants);
|
|
65
|
+
|
|
62
66
|
// Initialize utilities with constants
|
|
63
67
|
initializeUtilities(constants);
|
|
64
68
|
|
|
@@ -66,18 +70,20 @@ export function createSkateboardApp({ constants, appRoutes, defaultRoute = appRo
|
|
|
66
70
|
const root = createRoot(container);
|
|
67
71
|
|
|
68
72
|
root.render(
|
|
69
|
-
<
|
|
70
|
-
{
|
|
71
|
-
|
|
73
|
+
<ErrorBoundary>
|
|
74
|
+
<ContextProvider constants={constants}>
|
|
75
|
+
{Wrapper ? (
|
|
76
|
+
<Wrapper>
|
|
77
|
+
<Router>
|
|
78
|
+
<App constants={constants} appRoutes={appRoutes} defaultRoute={defaultRoute} />
|
|
79
|
+
</Router>
|
|
80
|
+
</Wrapper>
|
|
81
|
+
) : (
|
|
72
82
|
<Router>
|
|
73
83
|
<App constants={constants} appRoutes={appRoutes} defaultRoute={defaultRoute} />
|
|
74
84
|
</Router>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<App constants={constants} appRoutes={appRoutes} defaultRoute={defaultRoute} />
|
|
79
|
-
</Router>
|
|
80
|
-
)}
|
|
81
|
-
</ContextProvider>
|
|
85
|
+
)}
|
|
86
|
+
</ContextProvider>
|
|
87
|
+
</ErrorBoundary>
|
|
82
88
|
);
|
|
83
89
|
}
|
package/Context.jsx
CHANGED
|
@@ -2,19 +2,74 @@ import React, { createContext, useContext, useReducer } from 'react';
|
|
|
2
2
|
|
|
3
3
|
const context = createContext();
|
|
4
4
|
|
|
5
|
+
// Check if localStorage is available
|
|
6
|
+
function isLocalStorageAvailable() {
|
|
7
|
+
try {
|
|
8
|
+
const test = '__storage_test__';
|
|
9
|
+
localStorage.setItem(test, test);
|
|
10
|
+
localStorage.removeItem(test);
|
|
11
|
+
return true;
|
|
12
|
+
} catch (e) {
|
|
13
|
+
console.warn('localStorage not available:', e.message);
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Safe localStorage operations for Context
|
|
19
|
+
function safeLSSetItem(key, value) {
|
|
20
|
+
if (!isLocalStorageAvailable()) {
|
|
21
|
+
console.warn(`Could not save to localStorage: ${key}`);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
localStorage.setItem(key, value);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error('localStorage setItem error:', error.message);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeLSGetItem(key) {
|
|
34
|
+
if (!isLocalStorageAvailable()) return null;
|
|
35
|
+
try {
|
|
36
|
+
return localStorage.getItem(key);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('localStorage getItem error:', error.message);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeLSRemoveItem(key) {
|
|
44
|
+
if (!isLocalStorageAvailable()) return false;
|
|
45
|
+
try {
|
|
46
|
+
localStorage.removeItem(key);
|
|
47
|
+
return true;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('localStorage removeItem error:', error.message);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
5
54
|
export function ContextProvider({ children, constants }) {
|
|
6
55
|
const getStorageKey = () => {
|
|
7
56
|
const appName = constants.appName || 'skateboard';
|
|
8
57
|
return `${appName.toLowerCase().replace(/\s+/g, '-')}_user`;
|
|
9
58
|
};
|
|
10
59
|
|
|
60
|
+
const getCSRFKey = () => {
|
|
61
|
+
const appName = constants.appName || 'skateboard';
|
|
62
|
+
return `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
|
|
63
|
+
};
|
|
64
|
+
|
|
11
65
|
const getInitialUser = () => {
|
|
12
66
|
try {
|
|
13
67
|
const storageKey = getStorageKey();
|
|
14
|
-
const storedUser =
|
|
68
|
+
const storedUser = safeLSGetItem(storageKey);
|
|
15
69
|
if (!storedUser || storedUser === "undefined") return null;
|
|
16
70
|
return JSON.parse(storedUser);
|
|
17
71
|
} catch (e) {
|
|
72
|
+
console.error('Error parsing user data:', e.message);
|
|
18
73
|
return null;
|
|
19
74
|
}
|
|
20
75
|
};
|
|
@@ -22,24 +77,29 @@ export function ContextProvider({ children, constants }) {
|
|
|
22
77
|
const initialState = { user: getInitialUser() };
|
|
23
78
|
|
|
24
79
|
function reducer(state, action) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return state;
|
|
80
|
+
const storageKey = getStorageKey();
|
|
81
|
+
const csrfKey = getCSRFKey();
|
|
82
|
+
|
|
83
|
+
switch (action.type) {
|
|
84
|
+
case 'SET_USER': {
|
|
85
|
+
try {
|
|
86
|
+
const success = safeLSSetItem(storageKey, JSON.stringify(action.payload));
|
|
87
|
+
if (!success) {
|
|
88
|
+
console.error('Failed to persist user data to localStorage');
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error('Error setting user:', error.message);
|
|
92
|
+
}
|
|
93
|
+
return { ...state, user: action.payload };
|
|
40
94
|
}
|
|
41
|
-
|
|
42
|
-
|
|
95
|
+
case 'CLEAR_USER': {
|
|
96
|
+
// Clean up both user and CSRF token
|
|
97
|
+
safeLSRemoveItem(storageKey);
|
|
98
|
+
safeLSRemoveItem(csrfKey);
|
|
99
|
+
return { ...state, user: null };
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return state;
|
|
43
103
|
}
|
|
44
104
|
}
|
|
45
105
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
class ErrorBoundary extends React.Component {
|
|
4
|
+
constructor(props) {
|
|
5
|
+
super(props);
|
|
6
|
+
this.state = { hasError: false, error: null };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static getDerivedStateFromError(error) {
|
|
10
|
+
return { hasError: true, error };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
componentDidCatch(error, errorInfo) {
|
|
14
|
+
console.error('Error caught by boundary:', error, errorInfo);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
componentDidMount() {
|
|
18
|
+
// Handle unhandled promise rejections (async errors)
|
|
19
|
+
const handleUnhandledRejection = (event) => {
|
|
20
|
+
console.error('Unhandled promise rejection:', event.reason);
|
|
21
|
+
this.setState({
|
|
22
|
+
hasError: true,
|
|
23
|
+
error: new Error(`Async Error: ${event.reason?.message || String(event.reason)}`)
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Handle errors in event handlers and other non-React contexts
|
|
28
|
+
const handleError = (event) => {
|
|
29
|
+
if (event.error && !(this.state.hasError)) {
|
|
30
|
+
console.error('Global error:', event.error);
|
|
31
|
+
this.setState({ hasError: true, error: event.error });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
|
36
|
+
window.addEventListener('error', handleError);
|
|
37
|
+
|
|
38
|
+
this.unsubscribeRejection = () => {
|
|
39
|
+
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
|
40
|
+
};
|
|
41
|
+
this.unsubscribeError = () => {
|
|
42
|
+
window.removeEventListener('error', handleError);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
componentWillUnmount() {
|
|
47
|
+
if (this.unsubscribeRejection) this.unsubscribeRejection();
|
|
48
|
+
if (this.unsubscribeError) this.unsubscribeError();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
render() {
|
|
52
|
+
if (this.state.hasError) {
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex items-center justify-center w-full h-screen bg-background">
|
|
55
|
+
<div className="text-center max-w-md">
|
|
56
|
+
<h1 className="text-2xl font-bold mb-4">Something went wrong</h1>
|
|
57
|
+
<p className="text-muted-foreground mb-4 break-words">{this.state.error?.message || 'Unknown error'}</p>
|
|
58
|
+
<div className="flex gap-2 justify-center">
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => this.setState({ hasError: false, error: null })}
|
|
61
|
+
className="px-4 py-2 bg-accent text-accent-foreground rounded-md hover:bg-accent/90"
|
|
62
|
+
>
|
|
63
|
+
Try Again
|
|
64
|
+
</button>
|
|
65
|
+
<button
|
|
66
|
+
onClick={() => window.location.reload()}
|
|
67
|
+
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
|
68
|
+
>
|
|
69
|
+
Reload Page
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return this.props.children;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default ErrorBoundary;
|
package/Layout.jsx
CHANGED
|
@@ -9,19 +9,35 @@ export default function Layout({ children }) {
|
|
|
9
9
|
|
|
10
10
|
useEffect(() => {
|
|
11
11
|
const root = document.documentElement;
|
|
12
|
-
let theme
|
|
12
|
+
let theme;
|
|
13
|
+
|
|
14
|
+
// Safely get theme from localStorage
|
|
15
|
+
try {
|
|
16
|
+
theme = localStorage.getItem('theme');
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.warn('Could not read theme from localStorage:', error.message);
|
|
19
|
+
theme = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
14
|
-
|
|
23
|
+
|
|
15
24
|
if (!theme) {
|
|
16
25
|
theme = systemPrefersDark ? 'dark' : 'light';
|
|
17
26
|
}
|
|
18
|
-
|
|
27
|
+
|
|
19
28
|
if (theme === 'dark') {
|
|
20
29
|
root.classList.add('dark');
|
|
21
30
|
} else {
|
|
22
31
|
root.classList.remove('dark');
|
|
23
32
|
}
|
|
24
|
-
|
|
33
|
+
|
|
34
|
+
// Safely save theme to localStorage
|
|
35
|
+
try {
|
|
36
|
+
localStorage.setItem('theme', theme);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.warn('Could not save theme to localStorage:', error.message);
|
|
39
|
+
// Theme will still be applied visually even if not persisted
|
|
40
|
+
}
|
|
25
41
|
}, []);
|
|
26
42
|
|
|
27
43
|
return (
|
package/SignInView.jsx
CHANGED
|
@@ -24,7 +24,7 @@ import { useState, useEffect, useRef } from 'react';
|
|
|
24
24
|
import { useNavigate } from 'react-router-dom';
|
|
25
25
|
import { getState } from './Context.jsx';
|
|
26
26
|
import constants from "@/constants.json";
|
|
27
|
-
import { getBackendURL } from './Utilities'
|
|
27
|
+
import { getBackendURL, getAppKey } from './Utilities'
|
|
28
28
|
|
|
29
29
|
export default function LoginForm({
|
|
30
30
|
className,
|
|
@@ -64,16 +64,34 @@ export default function LoginForm({
|
|
|
64
64
|
const data = await response.json();
|
|
65
65
|
// Store CSRF token in localStorage with app-specific key
|
|
66
66
|
if (data.csrfToken) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const csrfKey = getAppKey('csrf');
|
|
68
|
+
try {
|
|
69
|
+
localStorage.setItem(csrfKey, data.csrfToken);
|
|
70
|
+
} catch (storageError) {
|
|
71
|
+
console.error('Failed to store CSRF token:', storageError.message);
|
|
72
|
+
// Continue even if storage fails
|
|
73
|
+
}
|
|
70
74
|
}
|
|
71
75
|
dispatch({ type: 'SET_USER', payload: data });
|
|
72
76
|
navigate('/app');
|
|
73
77
|
} else {
|
|
78
|
+
// Clean up stale CSRF token on failed sign-in
|
|
79
|
+
try {
|
|
80
|
+
const csrfKey = getAppKey('csrf');
|
|
81
|
+
localStorage.removeItem(csrfKey);
|
|
82
|
+
} catch (cleanupError) {
|
|
83
|
+
console.warn('Could not clean up CSRF token:', cleanupError.message);
|
|
84
|
+
}
|
|
74
85
|
setErrorMessage('Invalid Credentials');
|
|
75
86
|
}
|
|
76
87
|
} catch (error) {
|
|
88
|
+
// Clean up stale CSRF token on error
|
|
89
|
+
try {
|
|
90
|
+
const csrfKey = getAppKey('csrf');
|
|
91
|
+
localStorage.removeItem(csrfKey);
|
|
92
|
+
} catch (cleanupError) {
|
|
93
|
+
console.warn('Could not clean up CSRF token:', cleanupError.message);
|
|
94
|
+
}
|
|
77
95
|
setErrorMessage('Server Error');
|
|
78
96
|
} finally {
|
|
79
97
|
setIsSubmitting(false);
|
package/Utilities.js
CHANGED
|
@@ -3,6 +3,63 @@ import { useEffect, useState } from 'react';
|
|
|
3
3
|
// Constants will be initialized by the app shell
|
|
4
4
|
let _constants = null;
|
|
5
5
|
|
|
6
|
+
// Check if localStorage is available (respects private mode, etc.)
|
|
7
|
+
let _localStorageAvailable = null;
|
|
8
|
+
function isLocalStorageAvailable() {
|
|
9
|
+
if (_localStorageAvailable !== null) return _localStorageAvailable;
|
|
10
|
+
try {
|
|
11
|
+
const test = '__storage_test__';
|
|
12
|
+
localStorage.setItem(test, test);
|
|
13
|
+
localStorage.removeItem(test);
|
|
14
|
+
_localStorageAvailable = true;
|
|
15
|
+
return true;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
console.warn('localStorage not available (private mode or quota exceeded):', e.message);
|
|
18
|
+
_localStorageAvailable = false;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Safe localStorage wrapper with error handling
|
|
24
|
+
function safeSetItem(key, value) {
|
|
25
|
+
if (!isLocalStorageAvailable()) {
|
|
26
|
+
console.warn(`Could not save to localStorage: ${key} (storage unavailable)`);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
localStorage.setItem(key, value);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.name === 'QuotaExceededError') {
|
|
34
|
+
console.error('localStorage quota exceeded - data not persisted');
|
|
35
|
+
} else {
|
|
36
|
+
console.error('localStorage setItem error:', error.message);
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeGetItem(key) {
|
|
43
|
+
if (!isLocalStorageAvailable()) return null;
|
|
44
|
+
try {
|
|
45
|
+
return localStorage.getItem(key);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('localStorage getItem error:', error.message);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeRemoveItem(key) {
|
|
53
|
+
if (!isLocalStorageAvailable()) return false;
|
|
54
|
+
try {
|
|
55
|
+
localStorage.removeItem(key);
|
|
56
|
+
return true;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('localStorage removeItem error:', error.message);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
6
63
|
export function initializeUtilities(constants) {
|
|
7
64
|
_constants = constants;
|
|
8
65
|
}
|
|
@@ -31,7 +88,7 @@ export function getCookie(name) {
|
|
|
31
88
|
export function getCSRFToken() {
|
|
32
89
|
const appName = getConstants().appName || 'skateboard';
|
|
33
90
|
const csrfKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
|
|
34
|
-
return
|
|
91
|
+
return safeGetItem(csrfKey);
|
|
35
92
|
}
|
|
36
93
|
|
|
37
94
|
export function getAppKey(suffix) {
|
|
@@ -44,7 +101,7 @@ export function isAuthenticated() {
|
|
|
44
101
|
return true;
|
|
45
102
|
}
|
|
46
103
|
const csrfKey = getAppKey('csrf');
|
|
47
|
-
return Boolean(
|
|
104
|
+
return Boolean(safeGetItem(csrfKey));
|
|
48
105
|
}
|
|
49
106
|
|
|
50
107
|
export function getBackendURL() {
|
|
@@ -143,9 +200,7 @@ export async function showManage(stripeID) {
|
|
|
143
200
|
const data = await response.json();
|
|
144
201
|
console.log("/portal response: ", data);
|
|
145
202
|
if (data.url) {
|
|
146
|
-
|
|
147
|
-
localStorage.setItem(getAppKey("beforeManageURL"), window.location.href);
|
|
148
|
-
|
|
203
|
+
safeSetItem(getAppKey("beforeManageURL"), window.location.href);
|
|
149
204
|
window.location.href = data.url; // Redirect to Stripe billing portal
|
|
150
205
|
} else {
|
|
151
206
|
console.error("No URL returned from server");
|
|
@@ -182,7 +237,7 @@ export async function showCheckout(email, productIndex = 0) {
|
|
|
182
237
|
const data = await response.json();
|
|
183
238
|
if (data.url) {
|
|
184
239
|
// Save the current URL in localStorage before redirecting
|
|
185
|
-
|
|
240
|
+
safeSetItem(getAppKey("beforeCheckoutURL"), window.location.href);
|
|
186
241
|
// Redirect to payment checkout
|
|
187
242
|
window.location.href = data.url;
|
|
188
243
|
return true;
|
|
@@ -274,13 +329,18 @@ export async function showUpgradeSheet(upgradeSheetRef) {
|
|
|
274
329
|
// Check subscription from user data in localStorage instead of API call
|
|
275
330
|
const appName = getConstants().appName || 'skateboard';
|
|
276
331
|
const storageKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_user`;
|
|
277
|
-
const storedUser =
|
|
332
|
+
const storedUser = safeGetItem(storageKey);
|
|
278
333
|
|
|
279
334
|
let subscriber = false;
|
|
280
335
|
if (storedUser && storedUser !== "undefined") {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
336
|
+
try {
|
|
337
|
+
const user = JSON.parse(storedUser);
|
|
338
|
+
subscriber = user?.subscription?.status === 'active' &&
|
|
339
|
+
(!user?.subscription?.expires || user?.subscription?.expires > Math.floor(Date.now() / 1000));
|
|
340
|
+
} catch (error) {
|
|
341
|
+
console.error('Error parsing user data from storage:', error.message);
|
|
342
|
+
subscriber = false;
|
|
343
|
+
}
|
|
284
344
|
}
|
|
285
345
|
|
|
286
346
|
if (subscriber) {
|
|
@@ -421,15 +481,20 @@ export async function apiRequest(endpoint, options = {}) {
|
|
|
421
481
|
(options.method || 'GET').toUpperCase()
|
|
422
482
|
);
|
|
423
483
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
484
|
+
let response;
|
|
485
|
+
try {
|
|
486
|
+
response = await fetch(`${getBackendURL()}${endpoint}`, {
|
|
487
|
+
...options,
|
|
488
|
+
credentials: 'include',
|
|
489
|
+
headers: {
|
|
490
|
+
'Content-Type': 'application/json',
|
|
491
|
+
...(needsCSRF && csrfToken && { 'X-CSRF-Token': csrfToken }),
|
|
492
|
+
...options.headers
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
} catch (error) {
|
|
496
|
+
throw new Error(`Network error: ${error.message}`);
|
|
497
|
+
}
|
|
433
498
|
|
|
434
499
|
// Handle 401 (redirect to signout)
|
|
435
500
|
if (response.status === 401) {
|
|
@@ -442,7 +507,12 @@ export async function apiRequest(endpoint, options = {}) {
|
|
|
442
507
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
|
443
508
|
}
|
|
444
509
|
|
|
445
|
-
|
|
510
|
+
// Parse JSON with error handling
|
|
511
|
+
try {
|
|
512
|
+
return await response.json();
|
|
513
|
+
} catch (error) {
|
|
514
|
+
throw new Error(`Invalid JSON response from server: ${error.message}`);
|
|
515
|
+
}
|
|
446
516
|
}
|
|
447
517
|
|
|
448
518
|
/**
|
|
@@ -462,33 +532,41 @@ export async function apiRequestWithParams(endpoint, params = {}, options = {})
|
|
|
462
532
|
// ===== CONSTANTS VALIDATION =====
|
|
463
533
|
|
|
464
534
|
/**
|
|
465
|
-
* Validate constants.json has all required fields
|
|
535
|
+
* Validate constants.json has all required fields with proper values
|
|
466
536
|
* @param {object} constants - Constants object to validate
|
|
467
537
|
* @returns {object} - Same constants if valid
|
|
468
|
-
* @throws {Error} - If required fields are missing
|
|
538
|
+
* @throws {Error} - If required fields are missing or invalid
|
|
469
539
|
*/
|
|
470
540
|
export function validateConstants(constants) {
|
|
471
541
|
const required = [
|
|
472
|
-
'appName',
|
|
473
|
-
'appIcon',
|
|
474
|
-
'tagline',
|
|
475
|
-
'cta',
|
|
476
|
-
'backendURL',
|
|
477
|
-
'devBackendURL',
|
|
478
|
-
'features.title',
|
|
479
|
-
'features.items',
|
|
480
|
-
'companyName',
|
|
481
|
-
'companyWebsite',
|
|
482
|
-
'companyEmail',
|
|
542
|
+
{ key: 'appName', type: 'string' },
|
|
543
|
+
{ key: 'appIcon', type: 'string' },
|
|
544
|
+
{ key: 'tagline', type: 'string' },
|
|
545
|
+
{ key: 'cta', type: 'string' },
|
|
546
|
+
{ key: 'backendURL', type: 'string' },
|
|
547
|
+
{ key: 'devBackendURL', type: 'string' },
|
|
548
|
+
{ key: 'features.title', type: 'string' },
|
|
549
|
+
{ key: 'features.items', type: 'array' },
|
|
550
|
+
{ key: 'companyName', type: 'string' },
|
|
551
|
+
{ key: 'companyWebsite', type: 'string' },
|
|
552
|
+
{ key: 'companyEmail', type: 'string' },
|
|
483
553
|
];
|
|
484
554
|
|
|
485
|
-
const
|
|
555
|
+
const errors = required.filter(({ key, type }) => {
|
|
486
556
|
const value = key.split('.').reduce((obj, k) => obj?.[k], constants);
|
|
557
|
+
|
|
558
|
+
if (type === 'string') {
|
|
559
|
+
// Must be non-empty string
|
|
560
|
+
return !value || typeof value !== 'string' || value.trim().length === 0;
|
|
561
|
+
} else if (type === 'array') {
|
|
562
|
+
// Must be non-empty array
|
|
563
|
+
return !Array.isArray(value) || value.length === 0;
|
|
564
|
+
}
|
|
487
565
|
return !value;
|
|
488
|
-
});
|
|
566
|
+
}).map(({ key }) => key);
|
|
489
567
|
|
|
490
|
-
if (
|
|
491
|
-
throw new Error(`
|
|
568
|
+
if (errors.length > 0) {
|
|
569
|
+
throw new Error(`Invalid constants configuration: ${errors.join(', ')} (must be non-empty strings/arrays)`);
|
|
492
570
|
}
|
|
493
571
|
|
|
494
572
|
return constants;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevederico/skateboard-ui",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./AppSidebar": {
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"import": "./DynamicIcon.jsx",
|
|
13
13
|
"default": "./DynamicIcon.jsx"
|
|
14
14
|
},
|
|
15
|
+
"./ErrorBoundary": {
|
|
16
|
+
"import": "./ErrorBoundary.jsx",
|
|
17
|
+
"default": "./ErrorBoundary.jsx"
|
|
18
|
+
},
|
|
15
19
|
"./Header": {
|
|
16
20
|
"import": "./Header.jsx",
|
|
17
21
|
"default": "./Header.jsx"
|