@ascendkit/nextjs 0.1.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/LICENSE +21 -0
- package/dist/client/hooks.d.ts +109 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +372 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +3 -0
- package/dist/client/provider.d.ts +66 -0
- package/dist/client/provider.d.ts.map +1 -0
- package/dist/client/provider.js +284 -0
- package/dist/client/use-analytics.d.ts +27 -0
- package/dist/client/use-analytics.d.ts.map +1 -0
- package/dist/client/use-analytics.js +133 -0
- package/dist/components/auth-card.d.ts +20 -0
- package/dist/components/auth-card.d.ts.map +1 -0
- package/dist/components/auth-card.js +128 -0
- package/dist/components/auth-modal.d.ts +9 -0
- package/dist/components/auth-modal.d.ts.map +1 -0
- package/dist/components/auth-modal.js +110 -0
- package/dist/components/branding-badge.d.ts +2 -0
- package/dist/components/branding-badge.d.ts.map +1 -0
- package/dist/components/branding-badge.js +9 -0
- package/dist/components/email-verification.d.ts +2 -0
- package/dist/components/email-verification.d.ts.map +1 -0
- package/dist/components/email-verification.js +48 -0
- package/dist/components/forgot-password.d.ts +6 -0
- package/dist/components/forgot-password.d.ts.map +1 -0
- package/dist/components/forgot-password.js +37 -0
- package/dist/components/index.d.ts +13 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +12 -0
- package/dist/components/login.d.ts +9 -0
- package/dist/components/login.d.ts.map +1 -0
- package/dist/components/login.js +48 -0
- package/dist/components/reset-password.d.ts +6 -0
- package/dist/components/reset-password.d.ts.map +1 -0
- package/dist/components/reset-password.js +47 -0
- package/dist/components/sign-in-button.d.ts +19 -0
- package/dist/components/sign-in-button.d.ts.map +1 -0
- package/dist/components/sign-in-button.js +27 -0
- package/dist/components/sign-up-button.d.ts +19 -0
- package/dist/components/sign-up-button.d.ts.map +1 -0
- package/dist/components/sign-up-button.js +27 -0
- package/dist/components/signup.d.ts +9 -0
- package/dist/components/signup.d.ts.map +1 -0
- package/dist/components/signup.js +60 -0
- package/dist/components/social-button.d.ts +8 -0
- package/dist/components/social-button.d.ts.map +1 -0
- package/dist/components/social-button.js +10 -0
- package/dist/components/user-button.d.ts +11 -0
- package/dist/components/user-button.d.ts.map +1 -0
- package/dist/components/user-button.js +14 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/server/access-token.d.ts +39 -0
- package/dist/server/access-token.d.ts.map +1 -0
- package/dist/server/access-token.js +74 -0
- package/dist/server/adapter.d.ts +6 -0
- package/dist/server/adapter.d.ts.map +1 -0
- package/dist/server/adapter.js +57 -0
- package/dist/server/analytics.d.ts +61 -0
- package/dist/server/analytics.d.ts.map +1 -0
- package/dist/server/analytics.js +117 -0
- package/dist/server/ascendkit-auth.d.ts +122 -0
- package/dist/server/ascendkit-auth.d.ts.map +1 -0
- package/dist/server/ascendkit-auth.js +146 -0
- package/dist/server/auth-runtime.d.ts +12 -0
- package/dist/server/auth-runtime.d.ts.map +1 -0
- package/dist/server/auth-runtime.js +123 -0
- package/dist/server/config-fetcher.d.ts +22 -0
- package/dist/server/config-fetcher.d.ts.map +1 -0
- package/dist/server/config-fetcher.js +26 -0
- package/dist/server/email-sender.d.ts +29 -0
- package/dist/server/email-sender.d.ts.map +1 -0
- package/dist/server/email-sender.js +58 -0
- package/dist/server/index.d.ts +10 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +7 -0
- package/dist/server/oauth-proxy-plugin.d.ts +10 -0
- package/dist/server/oauth-proxy-plugin.d.ts.map +1 -0
- package/dist/server/oauth-proxy-plugin.js +156 -0
- package/dist/server/social-providers.d.ts +12 -0
- package/dist/server/social-providers.d.ts.map +1 -0
- package/dist/server/social-providers.js +15 -0
- package/dist/server/webhooks.d.ts +43 -0
- package/dist/server/webhooks.d.ts.map +1 -0
- package/dist/server/webhooks.js +83 -0
- package/dist/shared/http-client.d.ts +17 -0
- package/dist/shared/http-client.d.ts.map +1 -0
- package/dist/shared/http-client.js +52 -0
- package/dist/shared/types.d.ts +49 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { AuthUIProvider } from "@daveyplate/better-auth-ui";
|
|
4
|
+
import { createAuthClient } from "better-auth/react";
|
|
5
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { AuthModal } from "../components/auth-modal";
|
|
7
|
+
const AscendKitContext = createContext(null);
|
|
8
|
+
const AUTH_MODES = new Set(["credentials", "magic-link"]);
|
|
9
|
+
function normalizePath(path) {
|
|
10
|
+
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
|
|
11
|
+
if (withLeadingSlash === "/")
|
|
12
|
+
return "/";
|
|
13
|
+
return withLeadingSlash.replace(/\/+$/, "");
|
|
14
|
+
}
|
|
15
|
+
function buildRoute(basePath, routeSegment) {
|
|
16
|
+
const normalizedBase = normalizePath(basePath || "/");
|
|
17
|
+
const normalizedRoute = routeSegment.replace(/^\/+/, "");
|
|
18
|
+
if (normalizedBase === "/")
|
|
19
|
+
return `/${normalizedRoute}`;
|
|
20
|
+
return `${normalizedBase}/${normalizedRoute}`;
|
|
21
|
+
}
|
|
22
|
+
function getPathnameFromHref(href) {
|
|
23
|
+
try {
|
|
24
|
+
return new URL(href, window.location.origin).pathname;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
const pathOnly = href.split(/[?#]/)[0] || "/";
|
|
28
|
+
return pathOnly.startsWith("/") ? pathOnly : `/${pathOnly}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function useAscendKitContext() {
|
|
32
|
+
const ctx = useContext(AscendKitContext);
|
|
33
|
+
if (!ctx) {
|
|
34
|
+
throw new Error("useAscendKitContext must be used within <AscendKitProvider>");
|
|
35
|
+
}
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Wraps Better Auth UI's AuthUIProvider and provides AscendKit config context.
|
|
40
|
+
*
|
|
41
|
+
* Automatically fetches enabled providers and branding config on mount.
|
|
42
|
+
* Pass `enabledProviders` to skip the fetch and use static config.
|
|
43
|
+
*
|
|
44
|
+
* Usage (zero-config — reads from env vars automatically):
|
|
45
|
+
* ```tsx
|
|
46
|
+
* <AscendKitProvider>
|
|
47
|
+
* {children}
|
|
48
|
+
* </AscendKitProvider>
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* Environment variables (set by `ascendkit init` + `ascendkit set-env`):
|
|
52
|
+
* - NEXT_PUBLIC_ASCENDKIT_ENV_KEY — public key (client-side)
|
|
53
|
+
* - NEXT_PUBLIC_ASCENDKIT_API_URL — backend URL (default: https://api.ascendkit.com)
|
|
54
|
+
*/
|
|
55
|
+
export function AscendKitProvider({ publicKey = process.env.NEXT_PUBLIC_ASCENDKIT_ENV_KEY ?? "", apiUrl = process.env.NEXT_PUBLIC_ASCENDKIT_API_URL || "https://api.ascendkit.com", enabledProviders: overrideProviders, onFocusRefresh = false, basePath, viewPaths, children, }) {
|
|
56
|
+
const [providers, setProviders] = useState(overrideProviders ?? []);
|
|
57
|
+
const [branding, setBranding] = useState({ showBadge: true });
|
|
58
|
+
const [environmentName, setEnvironmentName] = useState("");
|
|
59
|
+
const [emailVerificationEnabled, setEmailVerificationEnabled] = useState(false);
|
|
60
|
+
const [authNotification, setAuthNotification] = useState(null);
|
|
61
|
+
const clearAuthNotification = useCallback(() => setAuthNotification(null), []);
|
|
62
|
+
const [settingsLoaded, setSettingsLoaded] = useState(!!overrideProviders);
|
|
63
|
+
const [settingsError, setSettingsError] = useState(null);
|
|
64
|
+
const settingsEtagRef = useRef(undefined);
|
|
65
|
+
const settingsRefreshInFlightRef = useRef(null);
|
|
66
|
+
const nextSettingsRefreshAtRef = useRef(0);
|
|
67
|
+
const authClient = useMemo(() => createAuthClient(), []);
|
|
68
|
+
// Global modal state
|
|
69
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
70
|
+
const [modalView, setModalView] = useState("sign-in");
|
|
71
|
+
const openModal = useCallback((view = "sign-in") => {
|
|
72
|
+
setModalView(view);
|
|
73
|
+
setModalOpen(true);
|
|
74
|
+
}, []);
|
|
75
|
+
const closeModal = useCallback(() => {
|
|
76
|
+
setModalOpen(false);
|
|
77
|
+
}, []);
|
|
78
|
+
const modal = useMemo(() => ({ isOpen: modalOpen, view: modalView, open: openModal, close: closeModal }), [modalOpen, modalView, openModal, closeModal]);
|
|
79
|
+
// Detect ?verified=true (and optional ?waitlisted=true) from email verification redirect
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const params = new URLSearchParams(window.location.search);
|
|
82
|
+
if (params.get("verified") === "true") {
|
|
83
|
+
const isWaitlisted = params.get("waitlisted") === "true";
|
|
84
|
+
if (isWaitlisted) {
|
|
85
|
+
setAuthNotification({
|
|
86
|
+
variant: "info",
|
|
87
|
+
message: "Email verified! Your account is pending approval. We'll notify you when it's ready.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
setAuthNotification({ variant: "success", message: "Email verified! You can now sign in." });
|
|
92
|
+
setModalView("sign-in");
|
|
93
|
+
setModalOpen(true);
|
|
94
|
+
}
|
|
95
|
+
params.delete("verified");
|
|
96
|
+
params.delete("waitlisted");
|
|
97
|
+
const clean = params.toString();
|
|
98
|
+
const newUrl = clean ? `${window.location.pathname}?${clean}` : window.location.pathname;
|
|
99
|
+
window.history.replaceState({}, "", newUrl);
|
|
100
|
+
}
|
|
101
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- mount only
|
|
102
|
+
// Auto-close modal when user becomes authenticated
|
|
103
|
+
const { data: session } = authClient.useSession();
|
|
104
|
+
const prevUserRef = useRef(null);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const userId = session?.user?.id ?? null;
|
|
107
|
+
// Close modal when transitioning from no-user to user (successful sign-in/up)
|
|
108
|
+
if (userId && !prevUserRef.current && modalOpen) {
|
|
109
|
+
setModalOpen(false);
|
|
110
|
+
}
|
|
111
|
+
prevUserRef.current = userId;
|
|
112
|
+
}, [session?.user?.id, modalOpen]);
|
|
113
|
+
const refreshSettings = useCallback(async () => {
|
|
114
|
+
if (Date.now() < nextSettingsRefreshAtRef.current) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (settingsRefreshInFlightRef.current) {
|
|
118
|
+
return settingsRefreshInFlightRef.current;
|
|
119
|
+
}
|
|
120
|
+
if (overrideProviders) {
|
|
121
|
+
setSettingsLoaded(true);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const headers = { "X-AscendKit-Public-Key": publicKey };
|
|
125
|
+
if (settingsEtagRef.current) {
|
|
126
|
+
headers["If-None-Match"] = settingsEtagRef.current;
|
|
127
|
+
}
|
|
128
|
+
const refreshTask = (async () => {
|
|
129
|
+
try {
|
|
130
|
+
const response = await fetch(`${apiUrl}/api/auth/settings`, {
|
|
131
|
+
headers,
|
|
132
|
+
cache: "no-store",
|
|
133
|
+
});
|
|
134
|
+
if (response.status === 304) {
|
|
135
|
+
setSettingsLoaded(true);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`Settings fetch failed: ${response.status}`);
|
|
140
|
+
}
|
|
141
|
+
settingsEtagRef.current = response.headers.get("etag") ?? settingsEtagRef.current;
|
|
142
|
+
const json = await response.json();
|
|
143
|
+
const rawProviders = Array.isArray(json.data?.providers)
|
|
144
|
+
? json.data.providers.filter((provider) => typeof provider === "string")
|
|
145
|
+
: [];
|
|
146
|
+
const oauthStatus = json.data?.oauthProviderStatus;
|
|
147
|
+
let readyProviders;
|
|
148
|
+
if (oauthStatus) {
|
|
149
|
+
readyProviders = rawProviders.filter((provider) => AUTH_MODES.has(provider) || oauthStatus[provider]?.ready === true);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
readyProviders = rawProviders;
|
|
153
|
+
}
|
|
154
|
+
setProviders(readyProviders);
|
|
155
|
+
if (json.data?.branding)
|
|
156
|
+
setBranding(json.data.branding);
|
|
157
|
+
setEnvironmentName(json.data?.environmentName ?? "");
|
|
158
|
+
setEmailVerificationEnabled(json.data?.features?.emailVerification === true);
|
|
159
|
+
if (readyProviders.length === 0 && rawProviders.length > 0) {
|
|
160
|
+
setSettingsError("Sign-in providers are enabled but not yet configured. Please contact the app developer.");
|
|
161
|
+
}
|
|
162
|
+
else if (readyProviders.length === 0) {
|
|
163
|
+
setSettingsError("No sign-in methods are configured for this application.");
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
setSettingsError(null);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
setSettingsError("Unable to load authentication settings. Please try again later.");
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
setSettingsLoaded(true);
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
settingsRefreshInFlightRef.current = refreshTask.finally(() => {
|
|
177
|
+
settingsRefreshInFlightRef.current = null;
|
|
178
|
+
nextSettingsRefreshAtRef.current = Date.now() + 5_000;
|
|
179
|
+
});
|
|
180
|
+
return settingsRefreshInFlightRef.current;
|
|
181
|
+
}, [publicKey, apiUrl, overrideProviders]);
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
void refreshSettings();
|
|
184
|
+
}, [refreshSettings]);
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!onFocusRefresh || overrideProviders)
|
|
187
|
+
return undefined;
|
|
188
|
+
const handleFocus = () => {
|
|
189
|
+
void refreshSettings();
|
|
190
|
+
};
|
|
191
|
+
window.addEventListener("focus", handleFocus);
|
|
192
|
+
return () => {
|
|
193
|
+
window.removeEventListener("focus", handleFocus);
|
|
194
|
+
};
|
|
195
|
+
}, [onFocusRefresh, overrideProviders, refreshSettings]);
|
|
196
|
+
// Auth modes are non-social authentication methods — don't render them as social buttons
|
|
197
|
+
const socialProviders = providers.filter((p) => !AUTH_MODES.has(p));
|
|
198
|
+
const credentialsEnabled = providers.includes("credentials");
|
|
199
|
+
const magicLinkEnabled = providers.includes("magic-link");
|
|
200
|
+
// Intercept navigation when modal is open to switch views instead of navigating
|
|
201
|
+
const modalOpenRef = useRef(modalOpen);
|
|
202
|
+
modalOpenRef.current = modalOpen;
|
|
203
|
+
const resolvedBasePath = basePath ?? "/auth";
|
|
204
|
+
const signInPath = viewPaths?.SIGN_IN ?? "sign-in";
|
|
205
|
+
const signUpPath = viewPaths?.SIGN_UP ?? "sign-up";
|
|
206
|
+
const signOutPath = viewPaths?.SIGN_OUT ?? "sign-out";
|
|
207
|
+
const signOutRoute = buildRoute(resolvedBasePath, signOutPath);
|
|
208
|
+
const performSignOut = useCallback(() => {
|
|
209
|
+
void authClient.signOut().finally(() => {
|
|
210
|
+
window.location.href = "/";
|
|
211
|
+
});
|
|
212
|
+
}, [authClient]);
|
|
213
|
+
const isSignOutNavigation = useCallback((href) => {
|
|
214
|
+
const targetPathname = getPathnameFromHref(href);
|
|
215
|
+
return targetPathname === signOutRoute;
|
|
216
|
+
}, [signOutRoute]);
|
|
217
|
+
const handleAuthNav = useCallback((href) => {
|
|
218
|
+
if (!modalOpenRef.current)
|
|
219
|
+
return false;
|
|
220
|
+
if (href.includes(signUpPath)) {
|
|
221
|
+
setModalView("sign-up");
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (href.includes(signInPath)) {
|
|
225
|
+
setModalView("sign-in");
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}, [signInPath, signUpPath]);
|
|
230
|
+
const navigate = useCallback((href) => {
|
|
231
|
+
if (isSignOutNavigation(href)) {
|
|
232
|
+
performSignOut();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!handleAuthNav(href)) {
|
|
236
|
+
window.location.href = href;
|
|
237
|
+
}
|
|
238
|
+
}, [handleAuthNav, isSignOutNavigation, performSignOut]);
|
|
239
|
+
// Custom Link that intercepts auth paths when modal is open
|
|
240
|
+
const ModalAwareLink = useMemo(() => {
|
|
241
|
+
return function AkLink({ href, className, children: linkChildren }) {
|
|
242
|
+
return (_jsx("a", { href: href, className: className, onClick: (e) => {
|
|
243
|
+
if (isSignOutNavigation(href)) {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
performSignOut();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (handleAuthNav(href)) {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
}
|
|
251
|
+
}, children: linkChildren }));
|
|
252
|
+
};
|
|
253
|
+
}, [handleAuthNav, isSignOutNavigation, performSignOut]);
|
|
254
|
+
return (_jsx(AscendKitContext.Provider, { value: {
|
|
255
|
+
publicKey,
|
|
256
|
+
apiUrl,
|
|
257
|
+
authClient,
|
|
258
|
+
enabledProviders: providers,
|
|
259
|
+
refreshSettings,
|
|
260
|
+
settingsLoaded,
|
|
261
|
+
settingsError,
|
|
262
|
+
branding,
|
|
263
|
+
modal,
|
|
264
|
+
environmentName,
|
|
265
|
+
authNotification,
|
|
266
|
+
clearAuthNotification,
|
|
267
|
+
}, children: _jsxs(AuthUIProvider, { authClient: authClient, credentials: credentialsEnabled ? { forgotPassword: true } : false, social: socialProviders.length > 0 ? { providers: socialProviders } : undefined, magicLink: magicLinkEnabled, emailVerification: emailVerificationEnabled, signUp: true, account: false, navigate: navigate, replace: navigate, Link: ModalAwareLink, toast: ({ variant = "default", message }) => {
|
|
268
|
+
if (message)
|
|
269
|
+
setAuthNotification({ variant, message });
|
|
270
|
+
}, localization: {
|
|
271
|
+
SIGN_IN: environmentName ? `Log in to ${environmentName}` : "Log In",
|
|
272
|
+
SIGN_IN_DESCRIPTION: credentialsEnabled
|
|
273
|
+
? "Enter your credentials to continue"
|
|
274
|
+
: magicLinkEnabled
|
|
275
|
+
? "Enter your email to receive a sign-in link"
|
|
276
|
+
: "Choose how you'd like to sign in",
|
|
277
|
+
SIGN_UP: environmentName ? `Sign up for ${environmentName}` : "Sign Up",
|
|
278
|
+
SIGN_UP_DESCRIPTION: "Enter your information to create an account",
|
|
279
|
+
SIGN_IN_WITH: "Continue with",
|
|
280
|
+
DISABLED_CREDENTIALS_DESCRIPTION: socialProviders.length === 1
|
|
281
|
+
? `Continue with ${socialProviders[0].charAt(0).toUpperCase() + socialProviders[0].slice(1)} to sign in`
|
|
282
|
+
: "Choose how you'd like to continue",
|
|
283
|
+
}, ...(basePath !== undefined && { basePath }), ...(viewPaths !== undefined && { viewPaths }), children: [children, _jsx(AuthModal, {})] }) }));
|
|
284
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side analytics hook that auto-captures browser context.
|
|
3
|
+
*
|
|
4
|
+
* Events are queued in-memory and flushed in batches. Browser details
|
|
5
|
+
* (userAgent, locale, screen size, referrer, URL) are captured automatically.
|
|
6
|
+
*
|
|
7
|
+
* Requires the user to be authenticated — events are tied to the current
|
|
8
|
+
* user's `usr_` ID automatically.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* function CheckoutButton() {
|
|
13
|
+
* const { track } = useAnalytics();
|
|
14
|
+
*
|
|
15
|
+
* return (
|
|
16
|
+
* <button onClick={() => track("checkout.completed", { total: 99.99 })}>
|
|
17
|
+
* Buy
|
|
18
|
+
* </button>
|
|
19
|
+
* );
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function useAnalytics(): {
|
|
24
|
+
track: (event: string, properties?: Record<string, unknown>) => void;
|
|
25
|
+
flush: () => Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=use-analytics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-analytics.d.ts","sourceRoot":"","sources":["../../src/client/use-analytics.ts"],"names":[],"mappings":"AA0CA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,YAAY;mBAgFhB,MAAM,eAAe,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAsBvD"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
3
|
+
import { useAscendKitContext } from "./provider";
|
|
4
|
+
import { useAscendKit } from "./hooks";
|
|
5
|
+
const SDK_VERSION = "0.1.0";
|
|
6
|
+
const FLUSH_INTERVAL_MS = 30_000;
|
|
7
|
+
const BATCH_SIZE = 10;
|
|
8
|
+
const MAX_RETRY_ATTEMPTS = 3;
|
|
9
|
+
function getBrowserContext() {
|
|
10
|
+
return {
|
|
11
|
+
url: window.location.href,
|
|
12
|
+
referrer: document.referrer,
|
|
13
|
+
userAgent: navigator.userAgent,
|
|
14
|
+
locale: navigator.language,
|
|
15
|
+
screenWidth: window.screen.width,
|
|
16
|
+
screenHeight: window.screen.height,
|
|
17
|
+
sdk: "js-client",
|
|
18
|
+
sdkVersion: SDK_VERSION,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Client-side analytics hook that auto-captures browser context.
|
|
23
|
+
*
|
|
24
|
+
* Events are queued in-memory and flushed in batches. Browser details
|
|
25
|
+
* (userAgent, locale, screen size, referrer, URL) are captured automatically.
|
|
26
|
+
*
|
|
27
|
+
* Requires the user to be authenticated — events are tied to the current
|
|
28
|
+
* user's `usr_` ID automatically.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* function CheckoutButton() {
|
|
33
|
+
* const { track } = useAnalytics();
|
|
34
|
+
*
|
|
35
|
+
* return (
|
|
36
|
+
* <button onClick={() => track("checkout.completed", { total: 99.99 })}>
|
|
37
|
+
* Buy
|
|
38
|
+
* </button>
|
|
39
|
+
* );
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function useAnalytics() {
|
|
44
|
+
const { publicKey, apiUrl } = useAscendKitContext();
|
|
45
|
+
const { user } = useAscendKit();
|
|
46
|
+
const queueRef = useRef([]);
|
|
47
|
+
const flushingRef = useRef(false);
|
|
48
|
+
const userIdRef = useRef(null);
|
|
49
|
+
// Keep userId ref in sync so flush closures see the latest value
|
|
50
|
+
userIdRef.current = user?.id ?? null;
|
|
51
|
+
const sendBatch = useCallback(async (batch, attempt) => {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${apiUrl}/api/v1/events`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
"X-AscendKit-Public-Key": publicKey,
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({ batch }),
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
throw new Error(`Event flush failed: ${res.status}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (attempt < MAX_RETRY_ATTEMPTS) {
|
|
67
|
+
const delay = 2 ** attempt * 1000;
|
|
68
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
69
|
+
return sendBatch(batch, attempt + 1);
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}, [apiUrl, publicKey]);
|
|
74
|
+
const flush = useCallback(async () => {
|
|
75
|
+
if (flushingRef.current || queueRef.current.length === 0)
|
|
76
|
+
return;
|
|
77
|
+
flushingRef.current = true;
|
|
78
|
+
const batch = queueRef.current.splice(0);
|
|
79
|
+
try {
|
|
80
|
+
await sendBatch(batch, 0);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Put events back
|
|
84
|
+
queueRef.current.unshift(...batch);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
flushingRef.current = false;
|
|
88
|
+
}
|
|
89
|
+
}, [sendBatch]);
|
|
90
|
+
// Periodic flush
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const timer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
|
|
93
|
+
return () => clearInterval(timer);
|
|
94
|
+
}, [flush]);
|
|
95
|
+
// Flush on page hide (tab close, navigation) using keepalive fetch
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const handleVisibilityChange = () => {
|
|
98
|
+
if (document.visibilityState === "hidden" && queueRef.current.length > 0) {
|
|
99
|
+
const batch = queueRef.current.splice(0);
|
|
100
|
+
fetch(`${apiUrl}/api/v1/events`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"X-AscendKit-Public-Key": publicKey,
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({ batch }),
|
|
107
|
+
keepalive: true,
|
|
108
|
+
}).catch(() => {
|
|
109
|
+
// Best-effort — events may be lost on tab close
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
114
|
+
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
115
|
+
}, [apiUrl, publicKey]);
|
|
116
|
+
const track = useCallback((event, properties) => {
|
|
117
|
+
const userId = userIdRef.current;
|
|
118
|
+
if (!userId)
|
|
119
|
+
return;
|
|
120
|
+
const context = getBrowserContext();
|
|
121
|
+
const prefixedEvent = event.startsWith("app.") ? event : `app.${event}`;
|
|
122
|
+
queueRef.current.push({
|
|
123
|
+
event: prefixedEvent,
|
|
124
|
+
userId,
|
|
125
|
+
properties: { ...properties, _context: context },
|
|
126
|
+
timestamp: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
if (queueRef.current.length >= BATCH_SIZE) {
|
|
129
|
+
void flush();
|
|
130
|
+
}
|
|
131
|
+
}, [flush]);
|
|
132
|
+
return { track, flush };
|
|
133
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AuthViewProps } from "@daveyplate/better-auth-ui";
|
|
2
|
+
interface AscendKitAuthCardProps extends Omit<AuthViewProps, "view"> {
|
|
3
|
+
/** Which view to show. Defaults to "SIGN_IN". */
|
|
4
|
+
view?: AuthViewProps["view"];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Drop-in auth card powered by Better Auth UI.
|
|
8
|
+
*
|
|
9
|
+
* Supports sign-in, sign-up, forgot-password views with social login buttons.
|
|
10
|
+
* Shows "Powered by AscendKit" badge for free-tier projects (server-driven).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <AscendKitAuthCard />
|
|
15
|
+
* <AscendKitAuthCard view="SIGN_UP" />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function AscendKitAuthCard(props: AscendKitAuthCardProps): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=auth-card.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-card.d.ts","sourceRoot":"","sources":["../../src/components/auth-card.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAY,KAAK,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAK1E,UAAU,sBAAuB,SAAQ,IAAI,CAAC,aAAa,EAAE,MAAM,CAAC;IAClE,iDAAiD;IACjD,IAAI,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC9B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,sBAAsB,2CAqJ9D"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { AuthView } from "@daveyplate/better-auth-ui";
|
|
5
|
+
import { useAscendKitContext } from "../client/provider";
|
|
6
|
+
import { BrandingBadge } from "./branding-badge";
|
|
7
|
+
/**
|
|
8
|
+
* Drop-in auth card powered by Better Auth UI.
|
|
9
|
+
*
|
|
10
|
+
* Supports sign-in, sign-up, forgot-password views with social login buttons.
|
|
11
|
+
* Shows "Powered by AscendKit" badge for free-tier projects (server-driven).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <AscendKitAuthCard />
|
|
16
|
+
* <AscendKitAuthCard view="SIGN_UP" />
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function AscendKitAuthCard(props) {
|
|
20
|
+
const { settingsLoaded, settingsError, refreshSettings, authNotification, clearAuthNotification } = useAscendKitContext();
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
void refreshSettings();
|
|
23
|
+
}, [refreshSettings]);
|
|
24
|
+
// Auto-dismiss notification after 8 seconds
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!authNotification)
|
|
27
|
+
return;
|
|
28
|
+
const timer = setTimeout(clearAuthNotification, 8000);
|
|
29
|
+
return () => clearTimeout(timer);
|
|
30
|
+
}, [authNotification, clearAuthNotification]);
|
|
31
|
+
if (!settingsLoaded) {
|
|
32
|
+
return (_jsxs("div", { className: "ak-auth-loading", children: [_jsx("div", { className: "ak-spinner" }), _jsx("style", { children: `
|
|
33
|
+
.ak-auth-loading { display: flex; justify-content: center; align-items: center; padding: 48px 24px; }
|
|
34
|
+
.ak-spinner { width: 24px; height: 24px; border: 2px solid rgba(128,128,128,0.3); border-top-color: currentColor; border-radius: 50%; animation: ak-spin 0.6s linear infinite; }
|
|
35
|
+
@keyframes ak-spin { to { transform: rotate(360deg); } }
|
|
36
|
+
` })] }));
|
|
37
|
+
}
|
|
38
|
+
if (settingsError) {
|
|
39
|
+
return (_jsxs("div", { children: [_jsxs("div", { className: "ak-auth-error", children: [_jsx("p", { className: "ak-auth-error-message", children: settingsError }), _jsx("style", { children: `
|
|
40
|
+
.ak-auth-error { padding: 24px; text-align: center; }
|
|
41
|
+
.ak-auth-error-message { color: var(--ak-modal-muted, #888); font-size: 14px; line-height: 1.5; margin: 0; }
|
|
42
|
+
` })] }), _jsx(BrandingBadge, {})] }));
|
|
43
|
+
}
|
|
44
|
+
const notifClass = authNotification?.variant === "error" || authNotification?.variant === "warning"
|
|
45
|
+
? "ak-auth-notification--error"
|
|
46
|
+
: authNotification?.variant === "info"
|
|
47
|
+
? "ak-auth-notification--info"
|
|
48
|
+
: "ak-auth-notification--success";
|
|
49
|
+
return (_jsxs("div", { children: [authNotification && (_jsxs("div", { className: `ak-auth-notification ${notifClass}`, role: "alert", children: [_jsx("p", { className: "ak-auth-notification-msg", children: authNotification.message }), _jsx("button", { type: "button", className: "ak-auth-notification-close", onClick: clearAuthNotification, "aria-label": "Dismiss", children: "\u2715" }), _jsx("style", { children: `
|
|
50
|
+
.ak-auth-notification {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: flex-start;
|
|
53
|
+
gap: 8px;
|
|
54
|
+
padding: 12px 16px;
|
|
55
|
+
margin-bottom: 16px;
|
|
56
|
+
border-radius: 8px;
|
|
57
|
+
font-size: 14px;
|
|
58
|
+
line-height: 1.5;
|
|
59
|
+
}
|
|
60
|
+
.ak-auth-notification--success {
|
|
61
|
+
background: var(--ak-notif-success-bg, #f0fdf4);
|
|
62
|
+
color: var(--ak-notif-success-color, #166534);
|
|
63
|
+
border: 1px solid var(--ak-notif-success-border, #bbf7d0);
|
|
64
|
+
}
|
|
65
|
+
.ak-auth-notification--error {
|
|
66
|
+
background: var(--ak-notif-error-bg, #fef2f2);
|
|
67
|
+
color: var(--ak-notif-error-color, #991b1b);
|
|
68
|
+
border: 1px solid var(--ak-notif-error-border, #fecaca);
|
|
69
|
+
}
|
|
70
|
+
.ak-auth-notification--info {
|
|
71
|
+
background: var(--ak-notif-info-bg, #eff6ff);
|
|
72
|
+
color: var(--ak-notif-info-color, #1e40af);
|
|
73
|
+
border: 1px solid var(--ak-notif-info-border, #bfdbfe);
|
|
74
|
+
}
|
|
75
|
+
.ak-auth-notification-msg {
|
|
76
|
+
flex: 1;
|
|
77
|
+
margin: 0;
|
|
78
|
+
}
|
|
79
|
+
.ak-auth-notification-close {
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
background: none;
|
|
82
|
+
border: none;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
font-size: 12px;
|
|
85
|
+
color: inherit;
|
|
86
|
+
opacity: 0.6;
|
|
87
|
+
padding: 0 2px;
|
|
88
|
+
line-height: 1;
|
|
89
|
+
}
|
|
90
|
+
.ak-auth-notification-close:hover { opacity: 1; }
|
|
91
|
+
|
|
92
|
+
:root.dark .ak-auth-notification--success,
|
|
93
|
+
html.dark .ak-auth-notification--success {
|
|
94
|
+
--ak-notif-success-bg: rgba(34, 197, 94, 0.1);
|
|
95
|
+
--ak-notif-success-color: #86efac;
|
|
96
|
+
--ak-notif-success-border: rgba(34, 197, 94, 0.2);
|
|
97
|
+
}
|
|
98
|
+
:root.dark .ak-auth-notification--error,
|
|
99
|
+
html.dark .ak-auth-notification--error {
|
|
100
|
+
--ak-notif-error-bg: rgba(239, 68, 68, 0.1);
|
|
101
|
+
--ak-notif-error-color: #fca5a5;
|
|
102
|
+
--ak-notif-error-border: rgba(239, 68, 68, 0.2);
|
|
103
|
+
}
|
|
104
|
+
:root.dark .ak-auth-notification--info,
|
|
105
|
+
html.dark .ak-auth-notification--info {
|
|
106
|
+
--ak-notif-info-bg: rgba(59, 130, 246, 0.1);
|
|
107
|
+
--ak-notif-info-color: #93c5fd;
|
|
108
|
+
--ak-notif-info-border: rgba(59, 130, 246, 0.2);
|
|
109
|
+
}
|
|
110
|
+
@media (prefers-color-scheme: dark) {
|
|
111
|
+
.ak-auth-notification--success:not(.ak-light) {
|
|
112
|
+
--ak-notif-success-bg: rgba(34, 197, 94, 0.1);
|
|
113
|
+
--ak-notif-success-color: #86efac;
|
|
114
|
+
--ak-notif-success-border: rgba(34, 197, 94, 0.2);
|
|
115
|
+
}
|
|
116
|
+
.ak-auth-notification--error:not(.ak-light) {
|
|
117
|
+
--ak-notif-error-bg: rgba(239, 68, 68, 0.1);
|
|
118
|
+
--ak-notif-error-color: #fca5a5;
|
|
119
|
+
--ak-notif-error-border: rgba(239, 68, 68, 0.2);
|
|
120
|
+
}
|
|
121
|
+
.ak-auth-notification--info:not(.ak-light) {
|
|
122
|
+
--ak-notif-info-bg: rgba(59, 130, 246, 0.1);
|
|
123
|
+
--ak-notif-info-color: #93c5fd;
|
|
124
|
+
--ak-notif-info-border: rgba(59, 130, 246, 0.2);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
` })] })), _jsx(AuthView, { ...props }), _jsx(BrandingBadge, {})] }));
|
|
128
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global auth modal rendered automatically by AscendKitProvider.
|
|
3
|
+
*
|
|
4
|
+
* Centered viewport overlay with backdrop, auto-closes on auth success.
|
|
5
|
+
* Supports dark mode via Tailwind `class="dark"` on <html> and
|
|
6
|
+
* `prefers-color-scheme` media query.
|
|
7
|
+
*/
|
|
8
|
+
export declare function AuthModal(): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
//# sourceMappingURL=auth-modal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-modal.d.ts","sourceRoot":"","sources":["../../src/components/auth-modal.tsx"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,wBAAgB,SAAS,4CA2HxB"}
|