@stevederico/skateboard-ui 1.4.1 → 1.5.0

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
@@ -22,6 +22,7 @@ import ErrorBoundary from './ErrorBoundary.jsx';
22
22
  import { useAppSetup, initializeUtilities, validateConstants } from './Utilities.js';
23
23
  import { ContextProvider } from './Context.jsx';
24
24
  import Toast from './Toast.jsx';
25
+ import AuthOverlay from './AuthOverlay.jsx';
25
26
 
26
27
  function App({ constants, appRoutes, defaultRoute, landingPage }) {
27
28
  const location = useLocation();
@@ -59,12 +60,26 @@ function App({ constants, appRoutes, defaultRoute, landingPage }) {
59
60
  }
60
61
 
61
62
  /**
63
+ * Bootstrap and render a skateboard-ui application.
64
+ *
65
+ * Sets up routing, authentication, layout, theming, and toast notifications.
66
+ * Mounts the app to the #root DOM element.
67
+ *
62
68
  * @param {Object} config
63
- * @param {Object} config.constants - App constants from constants.json
64
- * @param {Array} config.appRoutes - Array of route objects with path and element
65
- * @param {string} [config.defaultRoute] - Default route path under /app
66
- * @param {JSX.Element} [config.landingPage] - Custom landing page element for the "/" route. Defaults to built-in LandingView.
69
+ * @param {Object} config.constants - App constants (see README for required fields)
70
+ * @param {Array<{path: string, element: JSX.Element}>} config.appRoutes - Routes rendered under /app
71
+ * @param {string} [config.defaultRoute] - Default route path under /app (defaults to first route)
72
+ * @param {JSX.Element} [config.landingPage] - Custom landing page for "/". Defaults to LandingView.
67
73
  * @param {React.ComponentType} [config.wrapper] - Optional wrapper component around the router
74
+ *
75
+ * @example
76
+ * import { createSkateboardApp } from '@stevederico/skateboard-ui/App';
77
+ * import constants from './constants.json';
78
+ *
79
+ * createSkateboardApp({
80
+ * constants,
81
+ * appRoutes: [{ path: 'home', element: <HomeView /> }]
82
+ * });
68
83
  */
69
84
  export function createSkateboardApp({ constants, appRoutes, defaultRoute = appRoutes[0]?.path || 'home', landingPage, wrapper: Wrapper }) {
70
85
  // Validate constants before initialization
@@ -81,6 +96,7 @@ export function createSkateboardApp({ constants, appRoutes, defaultRoute = appRo
81
96
  <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
82
97
  <Toast />
83
98
  <ContextProvider constants={constants}>
99
+ <AuthOverlay />
84
100
  {Wrapper ? (
85
101
  <Wrapper>
86
102
  <Router>
package/AppSidebar.jsx CHANGED
@@ -18,6 +18,19 @@ import {
18
18
  // Use this if your DynamicIcon import isn't working
19
19
  const DynamicIconComponent = DynamicIcon
20
20
 
21
+ /**
22
+ * Desktop navigation sidebar.
23
+ *
24
+ * Renders app pages from constants.pages with icons, tooltips when collapsed,
25
+ * and a settings link in the footer. Uses shadcn Sidebar primitives.
26
+ *
27
+ * @returns {JSX.Element} Sidebar navigation
28
+ *
29
+ * @example
30
+ * import AppSidebar from '@stevederico/skateboard-ui/AppSidebar';
31
+ *
32
+ * <AppSidebar />
33
+ */
21
34
  export default function AppSidebar() {
22
35
  const { open, setOpen } = useSidebar();
23
36
  const navigate = useNavigate();
@@ -0,0 +1,291 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Dialog, DialogContent } from './shadcn/ui/dialog.jsx';
3
+ import { Input } from './shadcn/ui/input.jsx';
4
+ import DynamicIcon from './DynamicIcon.jsx';
5
+ import { getState } from './Context.jsx';
6
+ import { getBackendURL } from './Utilities.js';
7
+
8
+ /**
9
+ * Modal authentication overlay with sign-in and sign-up forms.
10
+ *
11
+ * Rendered at the app root and controlled via context state.
12
+ * Opens when SHOW_AUTH_OVERLAY is dispatched (typically via useAuthGate).
13
+ * On successful auth, runs the pending callback and closes.
14
+ *
15
+ * @returns {JSX.Element} Auth dialog overlay
16
+ *
17
+ * @example
18
+ * import AuthOverlay from '@stevederico/skateboard-ui/AuthOverlay';
19
+ *
20
+ * // Rendered automatically by createSkateboardApp
21
+ * <AuthOverlay />
22
+ */
23
+ export default function AuthOverlay() {
24
+ const { state, dispatch } = getState();
25
+ const constants = state.constants;
26
+ const { visible } = state.authOverlay;
27
+
28
+ const [mode, setMode] = useState('signin');
29
+ const [email, setEmail] = useState('');
30
+ const [password, setPassword] = useState('');
31
+ const [name, setName] = useState('');
32
+ const [errorMessage, setErrorMessage] = useState('');
33
+ const [isSubmitting, setIsSubmitting] = useState(false);
34
+ const firstInputRef = useRef(null);
35
+
36
+ // Reset form when dialog opens
37
+ useEffect(() => {
38
+ if (visible) {
39
+ setMode('signin');
40
+ setEmail('');
41
+ setPassword('');
42
+ setName('');
43
+ setErrorMessage('');
44
+ setIsSubmitting(false);
45
+ }
46
+ }, [visible]);
47
+
48
+ // Focus first input when dialog opens or mode changes
49
+ useEffect(() => {
50
+ if (visible && firstInputRef.current) {
51
+ // Small delay to let dialog animation finish
52
+ const t = setTimeout(() => firstInputRef.current?.focus(), 100);
53
+ return () => clearTimeout(t);
54
+ }
55
+ }, [visible, mode]);
56
+
57
+ function handleClose() {
58
+ dispatch({ type: 'HIDE_AUTH_OVERLAY' });
59
+ }
60
+
61
+ async function handleSignIn(e) {
62
+ e.preventDefault();
63
+ if (isSubmitting) return;
64
+ setIsSubmitting(true);
65
+ try {
66
+ const response = await fetch(`${getBackendURL()}/signin`, {
67
+ method: 'POST',
68
+ credentials: 'include',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ email, password })
71
+ });
72
+
73
+ if (response.ok) {
74
+ const data = await response.json();
75
+ dispatch({ type: 'SET_USER', payload: data });
76
+ dispatch({ type: 'AUTH_OVERLAY_SUCCESS' });
77
+ } else {
78
+ setErrorMessage('Invalid Credentials');
79
+ }
80
+ } catch (error) {
81
+ setErrorMessage('Server Error');
82
+ } finally {
83
+ setIsSubmitting(false);
84
+ }
85
+ }
86
+
87
+ async function handleSignUp(e) {
88
+ e.preventDefault();
89
+ if (isSubmitting) return;
90
+ if (password.length < 6) {
91
+ setErrorMessage('Password must be at least 6 characters');
92
+ return;
93
+ }
94
+ if (password.length > 72) {
95
+ setErrorMessage('Password must be 72 characters or less');
96
+ return;
97
+ }
98
+ setIsSubmitting(true);
99
+ try {
100
+ const response = await fetch(`${getBackendURL()}/signup`, {
101
+ method: 'POST',
102
+ credentials: 'include',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ email, password, name })
105
+ });
106
+
107
+ if (response.ok) {
108
+ const data = await response.json();
109
+ // Save CSRF token
110
+ const csrfCookie = document.cookie.split('; ').find(row => row.startsWith('csrf_token='));
111
+ const csrfToken = csrfCookie ? csrfCookie.split('=')[1] : data.csrfToken;
112
+ if (csrfToken) {
113
+ const appName = constants.appName || 'skateboard';
114
+ const csrfKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
115
+ localStorage.setItem(csrfKey, csrfToken);
116
+ }
117
+ dispatch({ type: 'SET_USER', payload: data });
118
+ dispatch({ type: 'AUTH_OVERLAY_SUCCESS' });
119
+ } else {
120
+ setErrorMessage('Invalid Credentials');
121
+ }
122
+ } catch (error) {
123
+ setErrorMessage('Server Error');
124
+ } finally {
125
+ setIsSubmitting(false);
126
+ }
127
+ }
128
+
129
+ const inputClass = "py-7 px-4 placeholder:text-gray-400 rounded-lg border-2 bg-secondary dark:bg-secondary dark:border-secondary";
130
+ const inputStyle = { fontSize: '20px' };
131
+
132
+ const buttonGradient = `linear-gradient(to bottom right,
133
+ var(--color-app),
134
+ oklch(from var(--color-app) calc(l - 0.05) c h),
135
+ oklch(from var(--color-app) calc(l - 0.08) c h),
136
+ oklch(from var(--color-app) calc(l - 0.12) c h))`;
137
+
138
+ const buttonGradientHover = `linear-gradient(to bottom right,
139
+ oklch(from var(--color-app) calc(l - 0.05) c h),
140
+ oklch(from var(--color-app) calc(l - 0.08) c h),
141
+ oklch(from var(--color-app) calc(l - 0.12) c h),
142
+ oklch(from var(--color-app) calc(l - 0.16) c h))`;
143
+
144
+ const buttonShadow = '0 25px 50px -12px color-mix(in srgb, var(--color-app) 40%, transparent 60%)';
145
+
146
+ return (
147
+ <Dialog open={visible} onOpenChange={(open) => { if (!open) handleClose(); }}>
148
+ <DialogContent className="sm:max-w-lg p-6 overflow-auto max-h-[90vh]">
149
+ <div className="flex flex-col gap-6">
150
+ {/* App branding */}
151
+ <div className="flex flex-row items-center justify-center">
152
+ <div className="bg-app dark:bg-app dark:border dark:border-gray-700 rounded-2xl flex aspect-square size-12 items-center justify-center">
153
+ <DynamicIcon name={constants.appIcon} size={24} color="white" strokeWidth={2} />
154
+ </div>
155
+ <div className="font-bold ml-3 text-3xl text-gray-900 dark:text-white">{constants.appName}</div>
156
+ </div>
157
+
158
+ {errorMessage && (
159
+ <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm text-center">
160
+ {errorMessage}
161
+ </div>
162
+ )}
163
+
164
+ {mode === 'signin' ? (
165
+ /* Sign In Form */
166
+ <form onSubmit={handleSignIn} className="flex flex-col gap-4">
167
+ <Input
168
+ ref={firstInputRef}
169
+ id="overlay-email"
170
+ type="email"
171
+ placeholder="Email"
172
+ className={inputClass}
173
+ style={inputStyle}
174
+ required
175
+ value={email}
176
+ onChange={(e) => { setEmail(e.target.value); setErrorMessage(''); }}
177
+ />
178
+ <Input
179
+ id="overlay-password"
180
+ type="password"
181
+ placeholder="Password"
182
+ className={inputClass}
183
+ style={inputStyle}
184
+ required
185
+ value={password}
186
+ onChange={(e) => setPassword(e.target.value)}
187
+ />
188
+ <button
189
+ type="submit"
190
+ className="relative group w-full text-white px-8 py-4 rounded-xl font-semibold text-lg transition-all duration-300 shadow-xl backdrop-blur-sm overflow-hidden cursor-pointer"
191
+ disabled={isSubmitting}
192
+ style={{ backgroundImage: buttonGradient, boxShadow: buttonShadow }}
193
+ onMouseEnter={(e) => { if (!isSubmitting) { e.currentTarget.style.backgroundImage = buttonGradientHover; } }}
194
+ onMouseLeave={(e) => { if (!isSubmitting) { e.currentTarget.style.backgroundImage = buttonGradient; } }}
195
+ >
196
+ <span className="relative z-20 flex items-center justify-center gap-2 drop-shadow-sm">
197
+ <DynamicIcon name="sparkles" size={16} color="currentColor" strokeWidth={2} className="animate-pulse" />
198
+ {isSubmitting ? 'Signing in...' : 'Sign In'}
199
+ </span>
200
+ <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/15 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-800 skew-x-12"></div>
201
+ </button>
202
+ <div className="mt-2 text-center text-base">
203
+ <span className="text-gray-600 dark:text-gray-400 italic">Don't have an account?</span>{' '}
204
+ <span
205
+ onClick={() => { setMode('signup'); setErrorMessage(''); }}
206
+ className="cursor-pointer hover:underline text-gray-900 dark:text-white"
207
+ >
208
+ Sign Up
209
+ </span>
210
+ </div>
211
+ </form>
212
+ ) : (
213
+ /* Sign Up Form */
214
+ <form onSubmit={handleSignUp} className="flex flex-col gap-4">
215
+ <Input
216
+ ref={firstInputRef}
217
+ id="overlay-name"
218
+ placeholder="Name"
219
+ className={inputClass}
220
+ style={inputStyle}
221
+ required
222
+ value={name}
223
+ onChange={(e) => { setName(e.target.value); setErrorMessage(''); }}
224
+ />
225
+ <Input
226
+ id="overlay-signup-email"
227
+ type="email"
228
+ placeholder="Email"
229
+ className={inputClass}
230
+ style={inputStyle}
231
+ required
232
+ value={email}
233
+ onChange={(e) => { setEmail(e.target.value); setErrorMessage(''); }}
234
+ />
235
+ <div className="flex flex-col gap-1">
236
+ <Input
237
+ id="overlay-signup-password"
238
+ type="password"
239
+ placeholder="Password"
240
+ className={inputClass}
241
+ style={inputStyle}
242
+ required
243
+ minLength={6}
244
+ maxLength={72}
245
+ value={password}
246
+ onChange={(e) => { setPassword(e.target.value); setErrorMessage(''); }}
247
+ />
248
+ <p className="text-xs text-gray-500 dark:text-gray-400 ml-1">Minimum 6 characters</p>
249
+ </div>
250
+ <button
251
+ type="submit"
252
+ className="relative group w-full text-white px-8 py-4 rounded-xl font-semibold text-lg transition-all duration-300 shadow-xl backdrop-blur-sm overflow-hidden cursor-pointer"
253
+ disabled={isSubmitting}
254
+ style={{ backgroundImage: buttonGradient, boxShadow: buttonShadow }}
255
+ onMouseEnter={(e) => { if (!isSubmitting) { e.currentTarget.style.backgroundImage = buttonGradientHover; } }}
256
+ onMouseLeave={(e) => { if (!isSubmitting) { e.currentTarget.style.backgroundImage = buttonGradient; } }}
257
+ >
258
+ <span className="relative z-20 flex items-center justify-center gap-2 drop-shadow-sm">
259
+ <DynamicIcon name="sparkles" size={16} color="currentColor" strokeWidth={2} className="animate-pulse" />
260
+ {isSubmitting ? 'Signing up...' : 'Sign Up'}
261
+ </span>
262
+ <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/15 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-800 skew-x-12"></div>
263
+ </button>
264
+ <div className="mt-2 text-center text-base">
265
+ <span className="text-gray-600 dark:text-gray-400 italic">Already have an account?</span>{' '}
266
+ <span
267
+ onClick={() => { setMode('signin'); setErrorMessage(''); }}
268
+ className="cursor-pointer hover:underline text-gray-900 dark:text-white"
269
+ >
270
+ Sign In
271
+ </span>
272
+ </div>
273
+ <div className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
274
+ By registering you agree to our
275
+ <a href="/terms" target="_blank" rel="noopener noreferrer" className="ml-1 underline underline-offset-4 whitespace-nowrap text-gray-900 dark:text-white">
276
+ Terms of Service
277
+ </a>,
278
+ <a href="/eula" target="_blank" rel="noopener noreferrer" className="ml-1 underline underline-offset-4 whitespace-nowrap text-gray-900 dark:text-white">
279
+ EULA
280
+ </a>,
281
+ <a href="/privacy" target="_blank" rel="noopener noreferrer" className="ml-1 underline underline-offset-4 whitespace-nowrap text-gray-900 dark:text-white">
282
+ Privacy Policy
283
+ </a>
284
+ </div>
285
+ </form>
286
+ )}
287
+ </div>
288
+ </DialogContent>
289
+ </Dialog>
290
+ );
291
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # CHANGELOG
2
2
 
3
+ 1.5.0
4
+
5
+ Add AuthOverlay component
6
+ Add useAuthGate hook
7
+ Add auth overlay state
8
+ Add noProtectedRoutes support
9
+ Add JSDoc comments
10
+ Update README documentation
11
+
3
12
  1.4.1
4
13
 
5
14
  Add landingPage param
package/Context.jsx CHANGED
@@ -116,6 +116,10 @@ export function ContextProvider({ children, constants }) {
116
116
  sidebarVisible: true,
117
117
  tabBarVisible: true
118
118
  },
119
+ authOverlay: {
120
+ visible: false,
121
+ pendingCallback: null
122
+ },
119
123
  constants
120
124
  };
121
125
 
@@ -148,6 +152,18 @@ export function ContextProvider({ children, constants }) {
148
152
  case 'SET_UI_VISIBILITY': {
149
153
  return { ...state, ui: { ...state.ui, ...action.payload } };
150
154
  }
155
+ case 'SHOW_AUTH_OVERLAY': {
156
+ return { ...state, authOverlay: { visible: true, pendingCallback: action.payload || null } };
157
+ }
158
+ case 'HIDE_AUTH_OVERLAY': {
159
+ return { ...state, authOverlay: { visible: false, pendingCallback: null } };
160
+ }
161
+ case 'AUTH_OVERLAY_SUCCESS': {
162
+ if (state.authOverlay.pendingCallback) {
163
+ try { state.authOverlay.pendingCallback(); } catch (e) { console.error('Auth callback error:', e); }
164
+ }
165
+ return { ...state, authOverlay: { visible: false, pendingCallback: null } };
166
+ }
151
167
  default:
152
168
  return state;
153
169
  }
package/DynamicIcon.jsx CHANGED
@@ -1,7 +1,25 @@
1
1
  import React from "react";
2
2
  import * as LucideIcons from "lucide-react";
3
3
 
4
- // Dynamic Icon Component
4
+ /**
5
+ * Render a Lucide icon by name string.
6
+ *
7
+ * Accepts kebab-case, snake_case, or PascalCase icon names and resolves
8
+ * them to the matching lucide-react component.
9
+ *
10
+ * @param {Object} props
11
+ * @param {string} props.name - Icon name (e.g. "home", "arrow-right", "Settings")
12
+ * @param {number} [props.size=24] - Icon size in pixels
13
+ * @param {string} [props.color='currentColor'] - Icon stroke color
14
+ * @param {number} [props.strokeWidth=2] - Stroke width
15
+ * @returns {JSX.Element|null} Rendered icon or null if name not found
16
+ *
17
+ * @example
18
+ * import DynamicIcon from '@stevederico/skateboard-ui/DynamicIcon';
19
+ *
20
+ * <DynamicIcon name="home" size={24} />
21
+ * <DynamicIcon name="arrow-right" size={20} color="red" />
22
+ */
5
23
  const DynamicIcon = ({ name, size = 24, color = 'currentColor', strokeWidth = 2, ...props }) => {
6
24
  const toPascalCase = (str) => str.split(/[-_\s]/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join('');
7
25
  const possibleNames = [name, toPascalCase(name), name.charAt(0).toUpperCase() + name.slice(1)];
package/ErrorBoundary.jsx CHANGED
@@ -1,5 +1,23 @@
1
1
  import React from 'react';
2
2
 
3
+ /**
4
+ * Top-level error boundary that catches render errors, unhandled promise
5
+ * rejections, and global errors.
6
+ *
7
+ * Displays a fallback UI with "Try Again" and "Reload Page" buttons.
8
+ * Wrap your app root with this component.
9
+ *
10
+ * @param {Object} props
11
+ * @param {React.ReactNode} props.children - Child components to protect
12
+ * @returns {JSX.Element} Children or error fallback UI
13
+ *
14
+ * @example
15
+ * import ErrorBoundary from '@stevederico/skateboard-ui/ErrorBoundary';
16
+ *
17
+ * <ErrorBoundary>
18
+ * <App />
19
+ * </ErrorBoundary>
20
+ */
3
21
  class ErrorBoundary extends React.Component {
4
22
  constructor(props) {
5
23
  super(props);
package/Header.jsx CHANGED
@@ -1,3 +1,22 @@
1
+ /**
2
+ * App header bar with title and optional action button.
3
+ *
4
+ * @param {Object} props
5
+ * @param {string} props.title - Header title text
6
+ * @param {string} [props.buttonTitle] - Action button label (omit to hide button)
7
+ * @param {Function} [props.onButtonTitleClick] - Button click handler
8
+ * @param {string} [props.buttonClass] - Additional CSS classes for the button
9
+ * @returns {JSX.Element} Header bar
10
+ *
11
+ * @example
12
+ * import Header from '@stevederico/skateboard-ui/Header';
13
+ *
14
+ * <Header
15
+ * title="Dashboard"
16
+ * buttonTitle="Add"
17
+ * onButtonTitleClick={() => console.log('clicked')}
18
+ * />
19
+ */
1
20
  function Header(props) {
2
21
  return (
3
22
  <div className="flex w-full bg-background pb-4 pt-5 px-4 border-b ">
package/LandingView.jsx CHANGED
@@ -13,6 +13,21 @@ const DynamicIcon = ({ name, size = 24, color = 'currentColor', strokeWidth = 2,
13
13
  return LucideIcon ? React.createElement(LucideIcon, { size, color, strokeWidth, ...props }) : null;
14
14
  };
15
15
 
16
+ /**
17
+ * Default landing page with hero section, features grid, pricing card,
18
+ * CTA section, and footer.
19
+ *
20
+ * Reads app branding, tagline, features, and pricing from constants.
21
+ * Uses the app's --color-app CSS variable for theming.
22
+ *
23
+ * @returns {JSX.Element} Full landing page
24
+ *
25
+ * @example
26
+ * import LandingView from '@stevederico/skateboard-ui/LandingView';
27
+ *
28
+ * // Used automatically by createSkateboardApp, or pass as landingPage prop:
29
+ * createSkateboardApp({ constants, appRoutes, landingPage: <LandingView /> });
30
+ */
16
31
  export default function LandingView() {
17
32
  const { state } = getState();
18
33
  const constants = state.constants;
package/Layout.jsx CHANGED
@@ -5,6 +5,25 @@ import AppSidebar from "./AppSidebar"
5
5
  import { useEffect } from 'react';
6
6
  import { getState } from './Context.jsx';
7
7
 
8
+ /**
9
+ * Page layout wrapper with sidebar and tab bar.
10
+ *
11
+ * Renders AppSidebar (desktop) and TabBar (mobile) based on constants
12
+ * configuration and programmatic visibility state. Wraps child routes
13
+ * via react-router Outlet.
14
+ *
15
+ * @param {Object} props
16
+ * @param {React.ReactNode} [props.children] - Child content (unused, Outlet renders routes)
17
+ * @returns {JSX.Element} Layout with sidebar, main content, and tab bar
18
+ *
19
+ * @example
20
+ * import Layout from '@stevederico/skateboard-ui/Layout';
21
+ *
22
+ * // Used internally by createSkateboardApp route config
23
+ * <Route element={<Layout />}>
24
+ * <Route path="home" element={<HomeView />} />
25
+ * </Route>
26
+ */
8
27
  export default function Layout({ children }) {
9
28
  const { state } = getState();
10
29
  const { sidebarVisible, tabBarVisible } = state.ui;
package/NotFound.jsx CHANGED
@@ -1,3 +1,13 @@
1
+ /**
2
+ * 404 page displayed for unmatched routes.
3
+ *
4
+ * @returns {JSX.Element} Not found message
5
+ *
6
+ * @example
7
+ * import NotFound from '@stevederico/skateboard-ui/NotFound';
8
+ *
9
+ * <Route path="*" element={<NotFound />} />
10
+ */
1
11
  export default function NotFound() {
2
12
 
3
13
  return (
package/PaymentView.jsx CHANGED
@@ -13,6 +13,19 @@ function isAllowedRedirect(path) {
13
13
  return ALLOWED_REDIRECT_PREFIXES.some(prefix => path.startsWith(prefix));
14
14
  }
15
15
 
16
+ /**
17
+ * Post-payment redirect handler.
18
+ *
19
+ * Processes Stripe checkout success/cancel/portal return query params,
20
+ * refreshes user data on successful payment, then redirects back to
21
+ * the page the user was on before checkout.
22
+ *
23
+ * @returns {JSX.Element} Redirect loading screen
24
+ *
25
+ * @example
26
+ * // Used internally by createSkateboardApp at /app/payment
27
+ * <Route path="payment" element={<PaymentView />} />
28
+ */
16
29
  export default function PaymentView() {
17
30
  const { state, dispatch } = getState();
18
31
  const constants = state.constants;
@@ -2,17 +2,38 @@ import { useState, useEffect } from 'react';
2
2
  import { Navigate, Outlet } from 'react-router-dom';
3
3
  import { isAuthenticated, apiRequest, getAppKey, getConstants } from './Utilities';
4
4
 
5
+ /**
6
+ * Route guard that validates authentication before rendering child routes.
7
+ *
8
+ * Checks client-side auth via isAuthenticated(), then validates the
9
+ * session with the backend via /me. Redirects to /signin if invalid.
10
+ * Bypassed when constants.noProtectedRoutes is true (for lazy auth).
11
+ * Bypassed when constants.noLogin is true (no auth required).
12
+ *
13
+ * @returns {JSX.Element} Outlet if authenticated, Navigate to /signin otherwise
14
+ *
15
+ * @example
16
+ * import ProtectedRoute from '@stevederico/skateboard-ui/ProtectedRoute';
17
+ *
18
+ * <Route path="/app" element={<ProtectedRoute />}>
19
+ * <Route path="home" element={<HomeView />} />
20
+ * </Route>
21
+ */
5
22
  const ProtectedRoute = () => {
6
- const [status, setStatus] = useState('checking'); // 'checking' | 'valid' | 'invalid'
23
+ const constants = getConstants();
24
+ const skipProtection = constants.noProtectedRoutes === true;
25
+ const [status, setStatus] = useState(skipProtection ? 'valid' : 'checking');
7
26
 
8
27
  useEffect(() => {
28
+ if (skipProtection) return;
29
+
9
30
  if (!isAuthenticated()) {
10
31
  setStatus('invalid');
11
32
  return;
12
33
  }
13
34
 
14
35
  // Skip backend validation for noLogin apps
15
- if (getConstants().noLogin === true) {
36
+ if (constants.noLogin === true) {
16
37
  setStatus('valid');
17
38
  return;
18
39
  }
@@ -31,7 +52,7 @@ const ProtectedRoute = () => {
31
52
  }, []);
32
53
 
33
54
  if (status === 'checking') {
34
- return null; // Or a loading spinner
55
+ return null;
35
56
  }
36
57
 
37
58
  return status === 'valid' ? <Outlet /> : <Navigate to="/signin" replace />;
package/README.md CHANGED
@@ -97,6 +97,7 @@ const constants = {
97
97
 
98
98
  // Optional: Authentication
99
99
  noLogin: false, // Set true to disable authentication
100
+ noProtectedRoutes: false, // Set true to allow unauthenticated access to /app routes (use with useAuthGate)
100
101
 
101
102
  // Optional: Payments (Stripe)
102
103
  stripeProducts: [
@@ -177,6 +178,13 @@ Quick overview:
177
178
  | TextView | `@stevederico/skateboard-ui/TextView` | Legal pages |
178
179
  | NotFound | `@stevederico/skateboard-ui/NotFound` | 404 page |
179
180
 
181
+ ### Auth Overlay (Lazy Authentication)
182
+
183
+ | Export | Import | Description |
184
+ |--------|--------|-------------|
185
+ | AuthOverlay | `@stevederico/skateboard-ui/AuthOverlay` | Modal sign-in/sign-up dialog |
186
+ | useAuthGate | `@stevederico/skateboard-ui/useAuthGate` | Hook to gate actions behind auth |
187
+
180
188
  ### Enhanced Components (New in v1.3.0)
181
189
 
182
190
  | Component | Import | Description |
@@ -383,6 +391,53 @@ Import base theme and override as needed:
383
391
 
384
392
  Dark mode is automatic via CSS custom properties.
385
393
 
394
+ ## Lazy Authentication (Auth Overlay)
395
+
396
+ Let users explore `/app` without signing in — prompt them only when they perform a protected action.
397
+
398
+ ### Setup
399
+
400
+ Set `noProtectedRoutes: true` in your constants to allow unauthenticated access to `/app` routes:
401
+
402
+ ```json
403
+ {
404
+ "noProtectedRoutes": true
405
+ }
406
+ ```
407
+
408
+ The `AuthOverlay` component is rendered automatically by `createSkateboardApp` — no additional wiring needed.
409
+
410
+ ### Usage
411
+
412
+ ```javascript
413
+ import { useAuthGate } from '@stevederico/skateboard-ui/useAuthGate';
414
+
415
+ function SaveButton() {
416
+ const requireAuth = useAuthGate();
417
+
418
+ function handleSave() {
419
+ requireAuth(() => {
420
+ // Only runs if user is authenticated
421
+ // If not signed in, auth overlay appears first
422
+ saveThing();
423
+ });
424
+ }
425
+
426
+ return <button onClick={handleSave}>Save</button>;
427
+ }
428
+ ```
429
+
430
+ ### How it works
431
+
432
+ 1. User clicks a protected action (Save, Like, Post, etc.)
433
+ 2. `requireAuth()` checks if user is signed in
434
+ 3. If signed in — callback runs immediately
435
+ 4. If not — a modal dialog appears with sign-in/sign-up forms
436
+ 5. After successful auth, the original callback executes automatically
437
+ 6. User stays on the same page throughout — no navigation
438
+
439
+ The dialog supports toggling between sign-in and sign-up modes inline, and can be dismissed with the X button (cancels the action).
440
+
386
441
  ## Protected Routes
387
442
 
388
443
  ```javascript
package/SettingsView.jsx CHANGED
@@ -17,6 +17,20 @@ import {
17
17
  } from './shadcn/ui/alert-dialog.jsx';
18
18
  import { showCheckout, showManage } from './Utilities';
19
19
 
20
+ /**
21
+ * User settings page with account info, sign out, support contact,
22
+ * and billing management.
23
+ *
24
+ * Shows subscription status and provides upgrade/manage buttons
25
+ * for Stripe billing. Hidden when constants.noLogin is true.
26
+ *
27
+ * @returns {JSX.Element} Settings page
28
+ *
29
+ * @example
30
+ * import SettingsView from '@stevederico/skateboard-ui/SettingsView';
31
+ *
32
+ * <Route path="settings" element={<SettingsView />} />
33
+ */
20
34
  export default function SettingsView() {
21
35
  const { state, dispatch } = getState();
22
36
  const constants = state.constants;
package/Sheet.jsx CHANGED
@@ -6,6 +6,34 @@ import {
6
6
  DrawerTitle,
7
7
  } from "./shadcn/ui/drawer"
8
8
 
9
+ /**
10
+ * Bottom sheet (drawer) component with imperative open/close API.
11
+ *
12
+ * Use a ref to control visibility programmatically.
13
+ *
14
+ * @param {Object} props
15
+ * @param {string} [props.title=""] - Sheet header title
16
+ * @param {string} [props.minHeight="auto"] - Minimum sheet height CSS value
17
+ * @param {React.ReactNode} props.children - Sheet body content
18
+ * @param {React.Ref} ref - Ref exposing { show, hide, open, close, toggle }
19
+ * @returns {JSX.Element} Drawer sheet
20
+ *
21
+ * @example
22
+ * import { useRef } from 'react';
23
+ * import Sheet from '@stevederico/skateboard-ui/Sheet';
24
+ *
25
+ * function MyComponent() {
26
+ * const sheetRef = useRef();
27
+ * return (
28
+ * <>
29
+ * <button onClick={() => sheetRef.current.show()}>Open</button>
30
+ * <Sheet ref={sheetRef} title="Details">
31
+ * <p>Sheet content</p>
32
+ * </Sheet>
33
+ * </>
34
+ * );
35
+ * }
36
+ */
9
37
  const MySheet = forwardRef(function MySheet(props, ref) {
10
38
  const { title = "", minHeight = "auto", children } = props;
11
39
  const [isOpen, setIsOpen] = useState(false);
package/SignInView.jsx CHANGED
@@ -15,6 +15,21 @@ import { useNavigate } from 'react-router-dom';
15
15
  import { getState } from "./Context.jsx";
16
16
  import { getBackendURL } from './Utilities'
17
17
 
18
+ /**
19
+ * Full-page sign-in form.
20
+ *
21
+ * Authenticates via POST to /signin, dispatches SET_USER on success,
22
+ * and navigates to /app. Shows app branding and a link to sign up.
23
+ *
24
+ * @param {Object} props
25
+ * @param {string} [props.className] - Additional CSS classes
26
+ * @returns {JSX.Element} Sign-in page
27
+ *
28
+ * @example
29
+ * import SignInView from '@stevederico/skateboard-ui/SignInView';
30
+ *
31
+ * <Route path="/signin" element={<SignInView />} />
32
+ */
18
33
  export default function LoginForm({
19
34
  className,
20
35
  ...props
package/SignOutView.jsx CHANGED
@@ -2,6 +2,19 @@ import { useEffect } from 'react';
2
2
  import { useNavigate } from 'react-router-dom';
3
3
  import { getBackendURL, getCSRFToken } from './Utilities';
4
4
 
5
+ /**
6
+ * Sign-out handler page.
7
+ *
8
+ * Calls POST /signout on mount to clear the server session,
9
+ * then redirects to /signin.
10
+ *
11
+ * @returns {JSX.Element} Sign-out loading screen
12
+ *
13
+ * @example
14
+ * import SignOutView from '@stevederico/skateboard-ui/SignOutView';
15
+ *
16
+ * <Route path="/signout" element={<SignOutView />} />
17
+ */
5
18
  function SignOutView() {
6
19
  const navigate = useNavigate();
7
20
 
package/SignUpView.jsx CHANGED
@@ -13,6 +13,22 @@ import { useNavigate } from 'react-router-dom';
13
13
  import { getState } from "./Context.jsx";
14
14
  import { getBackendURL } from './Utilities'
15
15
 
16
+ /**
17
+ * Full-page sign-up form.
18
+ *
19
+ * Creates account via POST to /signup with name, email, and password.
20
+ * Validates password length (6-72 chars), dispatches SET_USER on success,
21
+ * and navigates to /app. Includes legal agreement links.
22
+ *
23
+ * @param {Object} props
24
+ * @param {string} [props.className] - Additional CSS classes
25
+ * @returns {JSX.Element} Sign-up page
26
+ *
27
+ * @example
28
+ * import SignUpView from '@stevederico/skateboard-ui/SignUpView';
29
+ *
30
+ * <Route path="/signup" element={<SignUpView />} />
31
+ */
16
32
  export default function LoginForm({
17
33
  className,
18
34
  ...props
@@ -1,5 +1,15 @@
1
1
  import { Skeleton } from './shadcn/ui/skeleton.jsx';
2
2
 
3
+ /**
4
+ * Card-shaped loading skeleton with three lines of varying width.
5
+ *
6
+ * @returns {JSX.Element} Card skeleton placeholder
7
+ *
8
+ * @example
9
+ * import { CardSkeleton } from '@stevederico/skateboard-ui/SkeletonLoader';
10
+ *
11
+ * {loading ? <CardSkeleton /> : <Card data={data} />}
12
+ */
3
13
  export function CardSkeleton() {
4
14
  return (
5
15
  <div className="space-y-3">
@@ -10,6 +20,18 @@ export function CardSkeleton() {
10
20
  );
11
21
  }
12
22
 
23
+ /**
24
+ * Table loading skeleton with configurable row count.
25
+ *
26
+ * @param {Object} props
27
+ * @param {number} [props.rows=5] - Number of skeleton rows
28
+ * @returns {JSX.Element} Table skeleton placeholder
29
+ *
30
+ * @example
31
+ * import { TableSkeleton } from '@stevederico/skateboard-ui/SkeletonLoader';
32
+ *
33
+ * {loading ? <TableSkeleton rows={3} /> : <Table data={data} />}
34
+ */
13
35
  export function TableSkeleton({ rows = 5 }) {
14
36
  return (
15
37
  <div className="space-y-2">
@@ -20,10 +42,30 @@ export function TableSkeleton({ rows = 5 }) {
20
42
  );
21
43
  }
22
44
 
45
+ /**
46
+ * Circular avatar loading skeleton.
47
+ *
48
+ * @returns {JSX.Element} Avatar skeleton placeholder
49
+ *
50
+ * @example
51
+ * import { AvatarSkeleton } from '@stevederico/skateboard-ui/SkeletonLoader';
52
+ *
53
+ * {loading ? <AvatarSkeleton /> : <Avatar user={user} />}
54
+ */
23
55
  export function AvatarSkeleton() {
24
56
  return <Skeleton className="h-12 w-12 rounded-full" />;
25
57
  }
26
58
 
59
+ /**
60
+ * Form loading skeleton with label and input placeholders.
61
+ *
62
+ * @returns {JSX.Element} Form skeleton placeholder
63
+ *
64
+ * @example
65
+ * import { FormSkeleton } from '@stevederico/skateboard-ui/SkeletonLoader';
66
+ *
67
+ * {loading ? <FormSkeleton /> : <MyForm />}
68
+ */
27
69
  export function FormSkeleton() {
28
70
  return (
29
71
  <div className="space-y-4">
package/TabBar.jsx CHANGED
@@ -3,6 +3,20 @@ import { Link, useLocation } from 'react-router-dom';
3
3
  import DynamicIcon from './DynamicIcon';
4
4
  import { getState } from './Context.jsx';
5
5
 
6
+ /**
7
+ * Mobile bottom tab bar navigation.
8
+ *
9
+ * Renders page icons from constants.pages plus a settings tab.
10
+ * Only visible on mobile (hidden on md+ screens via Layout).
11
+ *
12
+ * @returns {JSX.Element} Bottom tab bar
13
+ *
14
+ * @example
15
+ * import TabBar from '@stevederico/skateboard-ui/TabBar';
16
+ *
17
+ * // Used internally by Layout component
18
+ * <TabBar />
19
+ */
6
20
  export default function TabBar() {
7
21
  const location = useLocation();
8
22
  const { state } = getState();
package/TextView.jsx CHANGED
@@ -1,6 +1,21 @@
1
1
  import { getState } from "./Context.jsx";
2
2
 
3
3
 
4
+ /**
5
+ * Legal/text document viewer.
6
+ *
7
+ * Renders a plain-text document with placeholder replacement for
8
+ * _COMPANY_, _WEBSITE_, and _EMAIL_ using values from constants.
9
+ *
10
+ * @param {Object} props
11
+ * @param {string} props.details - Raw text content with optional placeholders
12
+ * @returns {JSX.Element} Formatted text page
13
+ *
14
+ * @example
15
+ * import TextView from '@stevederico/skateboard-ui/TextView';
16
+ *
17
+ * <Route path="/terms" element={<TextView details={constants.termsOfService} />} />
18
+ */
4
19
  export default function TextView({ details }) {
5
20
  const { state } = getState();
6
21
  const constants = state.constants;
package/ThemeToggle.jsx CHANGED
@@ -9,6 +9,24 @@ const DynamicIcon = ({ name, size = 24, color = 'currentColor', strokeWidth = 2,
9
9
  return LucideIcon ? React.createElement(LucideIcon, { size, color, strokeWidth, ...props }) : null;
10
10
  };
11
11
 
12
+ /**
13
+ * Dark/light mode toggle button.
14
+ *
15
+ * Renders a sun/moon icon that toggles the theme via next-themes.
16
+ * Supports two visual variants: "settings" (minimal) and "landing" (boxed).
17
+ *
18
+ * @param {Object} props
19
+ * @param {string} [props.className=""] - Additional CSS classes
20
+ * @param {number} [props.iconSize=24] - Icon size in pixels
21
+ * @param {string} [props.variant="settings"] - Visual style ("settings" | "landing")
22
+ * @returns {JSX.Element|null} Toggle button or null before mount
23
+ *
24
+ * @example
25
+ * import ThemeToggle from '@stevederico/skateboard-ui/ThemeToggle';
26
+ *
27
+ * <ThemeToggle />
28
+ * <ThemeToggle variant="landing" iconSize={18} />
29
+ */
12
30
  export default function ThemeToggle({ className = "", iconSize = 24, variant = "settings" }) {
13
31
  const { theme, setTheme } = useTheme();
14
32
  const [mounted, setMounted] = React.useState(false);
package/Toast.jsx CHANGED
@@ -1,5 +1,23 @@
1
1
  import { Toaster } from './shadcn/ui/sonner.jsx';
2
2
 
3
+ /**
4
+ * Global toast notification container.
5
+ *
6
+ * Renders the Sonner Toaster in the top-right with rich colors
7
+ * and close buttons enabled. Rendered once at the app root.
8
+ *
9
+ * @returns {JSX.Element} Toast container
10
+ *
11
+ * @example
12
+ * import Toast from '@stevederico/skateboard-ui/Toast';
13
+ *
14
+ * // Rendered automatically by createSkateboardApp
15
+ * <Toast />
16
+ *
17
+ * // Trigger toasts from anywhere:
18
+ * import { toast } from 'sonner';
19
+ * toast.success('Saved!');
20
+ */
3
21
  export default function Toast() {
4
22
  return (
5
23
  <Toaster
package/UpgradeSheet.jsx CHANGED
@@ -9,6 +9,31 @@ import { getState } from "./Context.jsx";
9
9
  import { showCheckout } from './Utilities.js';
10
10
  import { Sparkles } from 'lucide-react';
11
11
 
12
+ /**
13
+ * Premium upgrade bottom sheet with pricing and checkout button.
14
+ *
15
+ * Displays the first Stripe product from constants with price,
16
+ * features list, and a checkout button. Controlled via ref.
17
+ *
18
+ * @param {Object} props
19
+ * @param {string} [props.userEmail=""] - User email for Stripe checkout
20
+ * @param {React.Ref} ref - Ref exposing { show, hide, open, close, toggle }
21
+ * @returns {JSX.Element} Upgrade drawer
22
+ *
23
+ * @example
24
+ * import { useRef } from 'react';
25
+ * import UpgradeSheet from '@stevederico/skateboard-ui/UpgradeSheet';
26
+ *
27
+ * function MyComponent() {
28
+ * const upgradeRef = useRef();
29
+ * return (
30
+ * <>
31
+ * <button onClick={() => upgradeRef.current.show()}>Upgrade</button>
32
+ * <UpgradeSheet ref={upgradeRef} userEmail={user.email} />
33
+ * </>
34
+ * );
35
+ * }
36
+ */
12
37
  const UpgradeSheet = forwardRef(function UpgradeSheet(props, ref) {
13
38
  const { userEmail = "" } = props;
14
39
  const [isOpen, setIsOpen] = useState(false);
package/Utilities.js CHANGED
@@ -97,6 +97,15 @@ export function initializeUtilities(constants) {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Get the app constants object.
102
+ *
103
+ * Returns constants from window.__SKATEBOARD_CONSTANTS__ (handles Vite
104
+ * module duplication) or the module-level variable.
105
+ *
106
+ * @returns {Object} App constants
107
+ * @throws {Error} If initializeUtilities() hasn't been called
108
+ */
100
109
  export function getConstants() {
101
110
  // Check window object first (handles module duplication)
102
111
  if (typeof window !== 'undefined' && window.__SKATEBOARD_CONSTANTS__) {
@@ -109,6 +118,14 @@ export function getConstants() {
109
118
  return _constants;
110
119
  }
111
120
 
121
+ /**
122
+ * Read a browser cookie by name.
123
+ *
124
+ * For the "token" cookie, automatically prefixes with the app name.
125
+ *
126
+ * @param {string} name - Cookie name
127
+ * @returns {string|null} Cookie value or null
128
+ */
112
129
  export function getCookie(name) {
113
130
  // For token cookies, use app-specific name
114
131
  let cookieName = name;
@@ -216,6 +233,11 @@ export function getBackendURL() {
216
233
  return result
217
234
  }
218
235
 
236
+ /**
237
+ * Check if the app is running inside a native WebKit wrapper (iOS/macOS).
238
+ *
239
+ * @returns {boolean} True if webkit.messageHandlers is available
240
+ */
219
241
  export function isAppMode() {
220
242
  let a = !!(
221
243
  typeof window !== 'undefined' &&
@@ -225,6 +247,18 @@ export function isAppMode() {
225
247
  return a
226
248
  }
227
249
 
250
+ /**
251
+ * Fetch the current authenticated user from the backend.
252
+ *
253
+ * Calls GET /me with credentials and CSRF token.
254
+ * Returns an empty object if constants.noLogin is true.
255
+ *
256
+ * @returns {Promise<Object|null>} User object or null on error
257
+ *
258
+ * @example
259
+ * const user = await getCurrentUser();
260
+ * if (user) dispatch({ type: 'SET_USER', payload: user });
261
+ */
228
262
  export async function getCurrentUser() {
229
263
 
230
264
  if (getConstants().noLogin == true) {
@@ -254,6 +288,14 @@ export async function getCurrentUser() {
254
288
  }
255
289
  }
256
290
 
291
+ /**
292
+ * Check if the current user has an active subscription.
293
+ *
294
+ * Calls GET /isSubscriber with credentials.
295
+ * Returns false if constants.noLogin is true.
296
+ *
297
+ * @returns {Promise<boolean>} True if user is an active subscriber
298
+ */
257
299
  export async function isSubscriber() {
258
300
  if (getConstants().noLogin == true) {
259
301
  return false
@@ -285,10 +327,25 @@ export async function isSubscriber() {
285
327
  }
286
328
  }
287
329
 
330
+ /**
331
+ * Log an analytics event. Stub for custom analytics integration.
332
+ *
333
+ * @param {string} event - Event name to log
334
+ * @returns {Promise<void>}
335
+ */
288
336
  export async function logEvent(event) {
289
337
  //insert analytics code here
290
338
  }
291
339
 
340
+ /**
341
+ * Open the Stripe billing portal for subscription management.
342
+ *
343
+ * Saves the current URL for redirect-back after portal return,
344
+ * then redirects the browser to the Stripe portal URL.
345
+ *
346
+ * @param {string} stripeID - Stripe customer ID
347
+ * @returns {Promise<void>}
348
+ */
292
349
  export async function showManage(stripeID) {
293
350
  try {
294
351
  const csrfToken = getCSRFToken();
@@ -319,6 +376,16 @@ export async function showManage(stripeID) {
319
376
  }
320
377
  }
321
378
 
379
+ /**
380
+ * Start a Stripe checkout session.
381
+ *
382
+ * Creates a checkout session via POST /checkout, saves the current URL
383
+ * for redirect-back, then redirects to the Stripe checkout page.
384
+ *
385
+ * @param {string} email - Customer email for Stripe
386
+ * @param {number} [productIndex=0] - Index into constants.stripeProducts
387
+ * @returns {Promise<boolean>} True if redirect initiated, false on error
388
+ */
322
389
  export async function showCheckout(email, productIndex = 0) {
323
390
  try {
324
391
  const csrfToken = getCSRFToken();
@@ -361,6 +428,15 @@ export async function showCheckout(email, productIndex = 0) {
361
428
  }
362
429
  }
363
430
 
431
+ /**
432
+ * Check remaining usage quota for a given action.
433
+ *
434
+ * Calls POST /usage with operation "check".
435
+ * Returns unlimited usage (-1) if constants.noLogin is true.
436
+ *
437
+ * @param {string} action - Action type to check usage for
438
+ * @returns {Promise<{remaining: number, total: number, isSubscriber: boolean}>}
439
+ */
364
440
  export async function getRemainingUsage(action) {
365
441
  if (getConstants().noLogin === true) {
366
442
  return { remaining: -1, total: -1, isSubscriber: true };
@@ -390,6 +466,16 @@ export async function getRemainingUsage(action) {
390
466
  }
391
467
  }
392
468
 
469
+ /**
470
+ * Track (decrement) usage for a given action.
471
+ *
472
+ * Calls POST /usage with operation "track".
473
+ * Returns unlimited usage (-1) if constants.noLogin is true.
474
+ * Returns usage data even when rate limited (HTTP 429).
475
+ *
476
+ * @param {string} action - Action type to track
477
+ * @returns {Promise<{remaining: number, total: number, isSubscriber: boolean}>}
478
+ */
393
479
  export async function trackUsage(action) {
394
480
  if (getConstants().noLogin === true) {
395
481
  return { remaining: -1, total: -1, isSubscriber: true };
@@ -425,6 +511,16 @@ export async function trackUsage(action) {
425
511
  }
426
512
  }
427
513
 
514
+ /**
515
+ * Show the upgrade sheet if the user is not a subscriber.
516
+ *
517
+ * Checks subscription status from localStorage user data.
518
+ * Does nothing for active subscribers. Falls back to navigation
519
+ * if the ref is unavailable.
520
+ *
521
+ * @param {React.RefObject} upgradeSheetRef - Ref to UpgradeSheet component
522
+ * @returns {Promise<void>}
523
+ */
428
524
  export async function showUpgradeSheet(upgradeSheetRef) {
429
525
  // Check subscription from user data in localStorage instead of API call
430
526
  const appName = getConstants().appName || 'skateboard';
@@ -456,6 +552,20 @@ export async function showUpgradeSheet(upgradeSheetRef) {
456
552
  }
457
553
  }
458
554
 
555
+ /**
556
+ * Convert a timestamp or Date to a formatted string.
557
+ *
558
+ * Handles both seconds and milliseconds timestamps, and Date objects.
559
+ *
560
+ * @param {number|Date} input - Unix timestamp (seconds or ms) or Date
561
+ * @param {string} [format="DOB"] - Format: "DOB" | "ISO" | "ago" | "day-month-time" | "day" | "time" | "full" | "DOBT"
562
+ * @returns {string} Formatted date string
563
+ *
564
+ * @example
565
+ * timestampToString(1700000000, 'ago') // "2 months ago"
566
+ * timestampToString(Date.now(), 'DOBT') // "3:45 PM"
567
+ * timestampToString(new Date(), 'full') // "Monday, November 13 2023 10:00 AM"
568
+ */
459
569
  export function timestampToString(input, format = "DOB") {
460
570
 
461
571
  let seconds = 0
@@ -555,6 +665,11 @@ export function timestampToString(input, format = "DOB") {
555
665
  }
556
666
  }
557
667
 
668
+ /**
669
+ * Hook that sets the document title and removes dark mode on non-app pages.
670
+ *
671
+ * @param {Object} location - react-router location object
672
+ */
558
673
  export function useAppSetup(location) {
559
674
  useEffect(() => {
560
675
  document.title = getConstants().appName;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stevederico/skateboard-ui",
3
3
  "private": false,
4
- "version": "1.4.1",
4
+ "version": "1.5.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./AppSidebar": {
@@ -100,6 +100,14 @@
100
100
  "import": "./Toast.jsx",
101
101
  "default": "./Toast.jsx"
102
102
  },
103
+ "./AuthOverlay": {
104
+ "import": "./AuthOverlay.jsx",
105
+ "default": "./AuthOverlay.jsx"
106
+ },
107
+ "./useAuthGate": {
108
+ "import": "./useAuthGate.js",
109
+ "default": "./useAuthGate.js"
110
+ },
103
111
  "./SkeletonLoader": {
104
112
  "import": "./SkeletonLoader.jsx",
105
113
  "default": "./SkeletonLoader.jsx"
package/useAuthGate.js ADDED
@@ -0,0 +1,37 @@
1
+ import { useCallback } from 'react';
2
+ import { getState } from './Context.jsx';
3
+
4
+ /**
5
+ * Hook that gates actions behind authentication.
6
+ *
7
+ * If the user is authenticated, the callback runs immediately.
8
+ * If not, the auth overlay is shown and the callback runs after successful auth.
9
+ *
10
+ * @returns {Function} requireAuth - Call with a callback to gate behind auth
11
+ *
12
+ * @example
13
+ * import { useAuthGate } from '@stevederico/skateboard-ui/useAuthGate';
14
+ *
15
+ * function MyComponent() {
16
+ * const requireAuth = useAuthGate();
17
+ *
18
+ * function handleSave() {
19
+ * requireAuth(() => {
20
+ * saveThing();
21
+ * });
22
+ * }
23
+ * }
24
+ */
25
+ export function useAuthGate() {
26
+ const { state, dispatch } = getState();
27
+
28
+ const requireAuth = useCallback((callback) => {
29
+ if (state.user) {
30
+ callback();
31
+ } else {
32
+ dispatch({ type: 'SHOW_AUTH_OVERLAY', payload: callback });
33
+ }
34
+ }, [state.user, dispatch]);
35
+
36
+ return requireAuth;
37
+ }