@stevederico/skateboard-ui 1.1.1 → 1.1.3

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 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 { useAppSetup, initializeUtilities } from './Utilities.js';
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
- <ContextProvider constants={constants}>
70
- {Wrapper ? (
71
- <Wrapper>
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
- </Wrapper>
76
- ) : (
77
- <Router>
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 = localStorage.getItem(storageKey);
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
- try {
26
- const storageKey = getStorageKey();
27
- const appName = constants.appName || 'skateboard';
28
- const csrfKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
29
-
30
- switch (action.type) {
31
- case 'SET_USER':
32
- localStorage.setItem(storageKey, JSON.stringify(action.payload));
33
- return { ...state, user: action.payload };
34
- case 'CLEAR_USER':
35
- localStorage.removeItem(storageKey);
36
- localStorage.removeItem(csrfKey);
37
- return { ...state, user: null };
38
- default:
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
- } catch (e) {
42
- return state;
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 = localStorage.getItem('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
- localStorage.setItem('theme', theme);
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 appName = constants.appName || 'skateboard';
68
- const csrfKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
69
- localStorage.setItem(csrfKey, data.csrfToken);
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,12 +3,77 @@ 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) {
64
+ if (!constants) {
65
+ console.error('[Utilities] initializeUtilities called with null/undefined constants!');
66
+ throw new Error('initializeUtilities called with null/undefined constants');
67
+ }
68
+ console.log('[Utilities] Initializing with app:', constants.appName);
7
69
  _constants = constants;
70
+ console.log('[Utilities] Initialization complete. _constants set:', !!_constants);
8
71
  }
9
72
 
10
73
  function getConstants() {
11
74
  if (!_constants) {
75
+ console.error('[Utilities] getConstants called but _constants is null!');
76
+ console.trace('[Utilities] Call stack:');
12
77
  throw new Error('Utilities not initialized. Call initializeUtilities(constants) first.');
13
78
  }
14
79
  return _constants;
@@ -31,7 +96,7 @@ export function getCookie(name) {
31
96
  export function getCSRFToken() {
32
97
  const appName = getConstants().appName || 'skateboard';
33
98
  const csrfKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
34
- return localStorage.getItem(csrfKey);
99
+ return safeGetItem(csrfKey);
35
100
  }
36
101
 
37
102
  export function getAppKey(suffix) {
@@ -44,7 +109,7 @@ export function isAuthenticated() {
44
109
  return true;
45
110
  }
46
111
  const csrfKey = getAppKey('csrf');
47
- return Boolean(localStorage.getItem(csrfKey));
112
+ return Boolean(safeGetItem(csrfKey));
48
113
  }
49
114
 
50
115
  export function getBackendURL() {
@@ -143,9 +208,7 @@ export async function showManage(stripeID) {
143
208
  const data = await response.json();
144
209
  console.log("/portal response: ", data);
145
210
  if (data.url) {
146
-
147
- localStorage.setItem(getAppKey("beforeManageURL"), window.location.href);
148
-
211
+ safeSetItem(getAppKey("beforeManageURL"), window.location.href);
149
212
  window.location.href = data.url; // Redirect to Stripe billing portal
150
213
  } else {
151
214
  console.error("No URL returned from server");
@@ -182,7 +245,7 @@ export async function showCheckout(email, productIndex = 0) {
182
245
  const data = await response.json();
183
246
  if (data.url) {
184
247
  // Save the current URL in localStorage before redirecting
185
- localStorage.setItem(getAppKey("beforeCheckoutURL"), window.location.href);
248
+ safeSetItem(getAppKey("beforeCheckoutURL"), window.location.href);
186
249
  // Redirect to payment checkout
187
250
  window.location.href = data.url;
188
251
  return true;
@@ -274,13 +337,18 @@ export async function showUpgradeSheet(upgradeSheetRef) {
274
337
  // Check subscription from user data in localStorage instead of API call
275
338
  const appName = getConstants().appName || 'skateboard';
276
339
  const storageKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_user`;
277
- const storedUser = localStorage.getItem(storageKey);
340
+ const storedUser = safeGetItem(storageKey);
278
341
 
279
342
  let subscriber = false;
280
343
  if (storedUser && storedUser !== "undefined") {
281
- const user = JSON.parse(storedUser);
282
- subscriber = user?.subscription?.status === 'active' &&
283
- (!user?.subscription?.expires || user?.subscription?.expires > Math.floor(Date.now() / 1000));
344
+ try {
345
+ const user = JSON.parse(storedUser);
346
+ subscriber = user?.subscription?.status === 'active' &&
347
+ (!user?.subscription?.expires || user?.subscription?.expires > Math.floor(Date.now() / 1000));
348
+ } catch (error) {
349
+ console.error('Error parsing user data from storage:', error.message);
350
+ subscriber = false;
351
+ }
284
352
  }
285
353
 
286
354
  if (subscriber) {
@@ -421,15 +489,20 @@ export async function apiRequest(endpoint, options = {}) {
421
489
  (options.method || 'GET').toUpperCase()
422
490
  );
423
491
 
424
- const response = await fetch(`${getBackendURL()}${endpoint}`, {
425
- ...options,
426
- credentials: 'include',
427
- headers: {
428
- 'Content-Type': 'application/json',
429
- ...(needsCSRF && csrfToken && { 'X-CSRF-Token': csrfToken }),
430
- ...options.headers
431
- }
432
- });
492
+ let response;
493
+ try {
494
+ response = await fetch(`${getBackendURL()}${endpoint}`, {
495
+ ...options,
496
+ credentials: 'include',
497
+ headers: {
498
+ 'Content-Type': 'application/json',
499
+ ...(needsCSRF && csrfToken && { 'X-CSRF-Token': csrfToken }),
500
+ ...options.headers
501
+ }
502
+ });
503
+ } catch (error) {
504
+ throw new Error(`Network error: ${error.message}`);
505
+ }
433
506
 
434
507
  // Handle 401 (redirect to signout)
435
508
  if (response.status === 401) {
@@ -442,7 +515,12 @@ export async function apiRequest(endpoint, options = {}) {
442
515
  throw new Error(`Request failed: ${response.status} ${response.statusText}`);
443
516
  }
444
517
 
445
- return response.json();
518
+ // Parse JSON with error handling
519
+ try {
520
+ return await response.json();
521
+ } catch (error) {
522
+ throw new Error(`Invalid JSON response from server: ${error.message}`);
523
+ }
446
524
  }
447
525
 
448
526
  /**
@@ -462,33 +540,41 @@ export async function apiRequestWithParams(endpoint, params = {}, options = {})
462
540
  // ===== CONSTANTS VALIDATION =====
463
541
 
464
542
  /**
465
- * Validate constants.json has all required fields
543
+ * Validate constants.json has all required fields with proper values
466
544
  * @param {object} constants - Constants object to validate
467
545
  * @returns {object} - Same constants if valid
468
- * @throws {Error} - If required fields are missing
546
+ * @throws {Error} - If required fields are missing or invalid
469
547
  */
470
548
  export function validateConstants(constants) {
471
549
  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',
550
+ { key: 'appName', type: 'string' },
551
+ { key: 'appIcon', type: 'string' },
552
+ { key: 'tagline', type: 'string' },
553
+ { key: 'cta', type: 'string' },
554
+ { key: 'backendURL', type: 'string' },
555
+ { key: 'devBackendURL', type: 'string' },
556
+ { key: 'features.title', type: 'string' },
557
+ { key: 'features.items', type: 'array' },
558
+ { key: 'companyName', type: 'string' },
559
+ { key: 'companyWebsite', type: 'string' },
560
+ { key: 'companyEmail', type: 'string' },
483
561
  ];
484
562
 
485
- const missing = required.filter(key => {
563
+ const errors = required.filter(({ key, type }) => {
486
564
  const value = key.split('.').reduce((obj, k) => obj?.[k], constants);
565
+
566
+ if (type === 'string') {
567
+ // Must be non-empty string
568
+ return !value || typeof value !== 'string' || value.trim().length === 0;
569
+ } else if (type === 'array') {
570
+ // Must be non-empty array
571
+ return !Array.isArray(value) || value.length === 0;
572
+ }
487
573
  return !value;
488
- });
574
+ }).map(({ key }) => key);
489
575
 
490
- if (missing.length > 0) {
491
- throw new Error(`Missing required constants: ${missing.join(', ')}`);
576
+ if (errors.length > 0) {
577
+ throw new Error(`Invalid constants configuration: ${errors.join(', ')} (must be non-empty strings/arrays)`);
492
578
  }
493
579
 
494
580
  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.1",
4
+ "version": "1.1.3",
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"