@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 +20 -4
- package/AppSidebar.jsx +13 -0
- package/AuthOverlay.jsx +291 -0
- package/CHANGELOG.md +9 -0
- package/Context.jsx +16 -0
- package/DynamicIcon.jsx +19 -1
- package/ErrorBoundary.jsx +18 -0
- package/Header.jsx +19 -0
- package/LandingView.jsx +15 -0
- package/Layout.jsx +19 -0
- package/NotFound.jsx +10 -0
- package/PaymentView.jsx +13 -0
- package/ProtectedRoute.jsx +24 -3
- package/README.md +55 -0
- package/SettingsView.jsx +14 -0
- package/Sheet.jsx +28 -0
- package/SignInView.jsx +15 -0
- package/SignOutView.jsx +13 -0
- package/SignUpView.jsx +16 -0
- package/SkeletonLoader.jsx +42 -0
- package/TabBar.jsx +14 -0
- package/TextView.jsx +15 -0
- package/ThemeToggle.jsx +18 -0
- package/Toast.jsx +18 -0
- package/UpgradeSheet.jsx +25 -0
- package/Utilities.js +115 -0
- package/package.json +9 -1
- package/useAuthGate.js +37 -0
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
|
|
64
|
-
* @param {Array} config.appRoutes -
|
|
65
|
-
* @param {string} [config.defaultRoute] - Default route path under /app
|
|
66
|
-
* @param {JSX.Element} [config.landingPage] - Custom landing page
|
|
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();
|
package/AuthOverlay.jsx
ADDED
|
@@ -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
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
|
-
|
|
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;
|
package/ProtectedRoute.jsx
CHANGED
|
@@ -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
|
|
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 (
|
|
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;
|
|
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
|
package/SkeletonLoader.jsx
CHANGED
|
@@ -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
|
+
"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
|
+
}
|