@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 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,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 localStorage.getItem(csrfKey);
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(localStorage.getItem(csrfKey));
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
- localStorage.setItem(getAppKey("beforeCheckoutURL"), window.location.href);
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 = localStorage.getItem(storageKey);
332
+ const storedUser = safeGetItem(storageKey);
278
333
 
279
334
  let subscriber = false;
280
335
  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));
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
- 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
- });
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
- return response.json();
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 missing = required.filter(key => {
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 (missing.length > 0) {
491
- throw new Error(`Missing required constants: ${missing.join(', ')}`);
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.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"