mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Restructure/frontend editor (#6404)
## Move editor under `frontend/editor/`
Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.
### Why
`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
lint config / Storybook.
### What moves
frontend/
├── editor/ ← NEW: everything editor-specific
│ ├── src/ ← was frontend/src/
│ ├── public/ ← was frontend/public/
│ ├── src-tauri/ ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
│ ├── tsconfig*.json, tailwind.config.js, postcss.config.js
│ ├── scripts/
│ ├── .env, .env.desktop, .env.saas
│ └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
├── .gitignore
└── README.md
### Wiring edits (40 files)
- `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
- `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
- `docker/frontend/Dockerfile`
- 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
`.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
- `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
`frontend/editor/DeveloperGuide.md`
Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
relative to script).
### Verification
| Check | Result |
|---|---|
| `task frontend:typecheck:all` (6 variants) | exit 0 |
| `task frontend:lint` (eslint + dpdm) | exit 0 |
| `task frontend:format:check` | exit 0 |
| `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
| `playwright test --list --project=stubbed` | 172 tests discovered |
`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
wrong it wouldn't have built.
### Test plan
- [ ] `frontend-validation.yml` green
- [ ] `e2e-stubbed.yml` green
- [ ] `tauri-build.yml` green on at least one platform
- [ ] `check_toml.yml` runs on a translation-touching PR
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -0,0 +1,51 @@
|
||||
import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { setBaseUrl } from "@app/constants/app";
|
||||
import type { AppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
import Landing from "@app/routes/Landing";
|
||||
import Login from "@app/routes/Login";
|
||||
import Signup from "@app/routes/Signup";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import ResetPassword from "@app/routes/ResetPassword";
|
||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
import "@app/styles/saas-theme.css";
|
||||
import "@app/styles/cookieconsent.css";
|
||||
import "@app/styles/index.css";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
function handleConfigLoaded(config: AppConfig) {
|
||||
if (config.baseUrl) setBaseUrl(config.baseUrl);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders
|
||||
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
|
||||
>
|
||||
<AppLayout>
|
||||
<OnboardingBootstrap />
|
||||
<TrialExpiredBootstrap />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/auth/reset" element={<ResetPassword />} />
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<OnboardingTour />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
Stirling PDF User License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
License Scope & Usage Rights
|
||||
|
||||
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||
|
||||
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||
covering the appropriate number of licensed users.
|
||||
|
||||
Trial and Minimal Use
|
||||
|
||||
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||
|
||||
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||
|
||||
Modifications and Derivative Works
|
||||
|
||||
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||
|
||||
* May not be deployed in production environments without a valid User License;
|
||||
* May not be distributed or sublicensed;
|
||||
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||
|
||||
Prohibited Actions
|
||||
|
||||
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||
|
||||
* Use the Software in production environments;
|
||||
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||
* Remove or alter any licensing or copyright notices;
|
||||
* Circumvent access restrictions or licensing requirements.
|
||||
|
||||
Third-Party Components
|
||||
|
||||
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||
their original license terms as provided by their respective owners.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,710 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import type {
|
||||
Session,
|
||||
User as SupabaseUser,
|
||||
AuthError,
|
||||
} from "@supabase/supabase-js";
|
||||
import {
|
||||
CreditSummary,
|
||||
SubscriptionInfo,
|
||||
CreditCheckResult,
|
||||
ApiCredits,
|
||||
} from "@app/types/credits";
|
||||
import apiClient, {
|
||||
setGlobalCreditUpdateCallback,
|
||||
} from "@app/services/apiClient";
|
||||
import { synchronizeUserUpgrade } from "@app/services/userService";
|
||||
import {
|
||||
syncOAuthAvatar,
|
||||
getProfilePictureMetadata,
|
||||
type ProfilePictureMetadata,
|
||||
} from "@app/services/avatarSyncService";
|
||||
|
||||
// Extend Supabase User to include optional username for compatibility
|
||||
export type User = SupabaseUser & { username?: string };
|
||||
|
||||
export interface TrialStatus {
|
||||
isTrialing: boolean;
|
||||
trialEnd: string;
|
||||
daysRemaining: number;
|
||||
hasPaymentMethod: boolean;
|
||||
hasScheduledSub: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: AuthError | null;
|
||||
creditBalance: number | null;
|
||||
subscription: SubscriptionInfo | null;
|
||||
creditSummary: CreditSummary | null;
|
||||
isPro: boolean | null;
|
||||
trialStatus: TrialStatus | null;
|
||||
profilePictureUrl: string | null;
|
||||
profilePictureMetadata: ProfilePictureMetadata | null;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult;
|
||||
updateCredits: (newBalance: number) => void;
|
||||
refreshCredits: () => Promise<void>;
|
||||
refreshProStatus: () => Promise<void>;
|
||||
refreshTrialStatus: () => Promise<void>;
|
||||
refreshProfilePicture: () => Promise<void>;
|
||||
refreshProfilePictureMetadata: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
session: null,
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
creditBalance: null,
|
||||
subscription: null,
|
||||
creditSummary: null,
|
||||
isPro: null,
|
||||
trialStatus: null,
|
||||
profilePictureUrl: null,
|
||||
profilePictureMetadata: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
hasSufficientCredits: () => ({
|
||||
hasSufficientCredits: false,
|
||||
currentBalance: 0,
|
||||
requiredCredits: 0,
|
||||
}),
|
||||
updateCredits: () => {},
|
||||
refreshCredits: async () => {},
|
||||
refreshProStatus: async () => {},
|
||||
refreshTrialStatus: async () => {},
|
||||
refreshProfilePicture: async () => {},
|
||||
refreshProfilePictureMetadata: async () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<AuthError | null>(null);
|
||||
const [creditBalance, setCreditBalance] = useState<number | null>(null);
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [isPro, setIsPro] = useState<boolean | null>(null);
|
||||
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null);
|
||||
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [profilePictureMetadata, setProfilePictureMetadata] =
|
||||
useState<ProfilePictureMetadata | null>(null);
|
||||
|
||||
const fetchCredits = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug("[Auth Debug] No user session, skipping credit fetch");
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching credits for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
const response = await apiClient.get<ApiCredits>("/api/v1/credits");
|
||||
const apiCredits = response.data;
|
||||
|
||||
// Map server payload to app CreditSummary
|
||||
const credits: CreditSummary = {
|
||||
currentCredits: apiCredits.totalAvailableCredits,
|
||||
maxCredits:
|
||||
apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
|
||||
creditsUsed:
|
||||
apiCredits.weeklyCreditsAllocated -
|
||||
apiCredits.weeklyCreditsRemaining +
|
||||
(apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
|
||||
creditsRemaining: apiCredits.totalAvailableCredits,
|
||||
resetDate: apiCredits.weeklyResetDate,
|
||||
weeklyAllowance: apiCredits.weeklyCreditsAllocated,
|
||||
};
|
||||
|
||||
setCreditSummary(credits);
|
||||
setCreditBalance(credits.creditsRemaining);
|
||||
|
||||
const subscriptionInfo: SubscriptionInfo = {
|
||||
status: "active",
|
||||
tier: (credits.weeklyAllowance || 0) > 100 ? "premium" : "free",
|
||||
creditsPerWeek: credits.weeklyAllowance,
|
||||
maxCredits: credits.maxCredits,
|
||||
};
|
||||
setSubscription(subscriptionInfo);
|
||||
|
||||
console.debug("[Auth Debug] Credits fetched successfully:", credits);
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch credits:", error);
|
||||
// Don't set error state for credit fetching failures to avoid disrupting auth flow
|
||||
// Credits might not be available in all deployments
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const refreshCredits = useCallback(async () => {
|
||||
await fetchCredits();
|
||||
}, [fetchCredits]);
|
||||
|
||||
const fetchProStatus = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug(
|
||||
"[Auth Debug] No user session, skipping pro status fetch",
|
||||
);
|
||||
setIsPro(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching pro status for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
const { data: proStatus, error } = await supabase.rpc("is_pro");
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth Debug] Error checking Pro status:", error);
|
||||
setIsPro(false); // Default to false if there's an error
|
||||
} else {
|
||||
const isProUser = Boolean(proStatus);
|
||||
setIsPro(isProUser);
|
||||
console.debug("[Auth Debug] Pro status fetched:", isProUser);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch pro status:", error);
|
||||
setIsPro(false); // Default to false if there's an error
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const refreshProStatus = useCallback(async () => {
|
||||
await fetchProStatus();
|
||||
}, [fetchProStatus]);
|
||||
|
||||
const fetchTrialStatus = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug(
|
||||
"[Auth Debug] No user session, skipping trial status fetch",
|
||||
);
|
||||
setTrialStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching trial status for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
const { data, error } = await supabase
|
||||
.from("billing_subscriptions")
|
||||
.select(
|
||||
"status, trial_end, has_payment_method, scheduled_subscription_id",
|
||||
)
|
||||
.in("status", ["trialing", "incomplete_expired", "canceled"])
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth Debug] Error fetching trial status:", error);
|
||||
setTrialStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.trial_end) {
|
||||
const trialEnd = new Date(data.trial_end);
|
||||
const now = new Date();
|
||||
const daysRemaining = Math.ceil(
|
||||
(trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
setTrialStatus({
|
||||
isTrialing: data.status === "trialing" && daysRemaining > 0,
|
||||
trialEnd: data.trial_end,
|
||||
daysRemaining: Math.max(0, daysRemaining),
|
||||
hasPaymentMethod: data.has_payment_method || false,
|
||||
hasScheduledSub: !!data.scheduled_subscription_id,
|
||||
status: data.status,
|
||||
});
|
||||
console.debug("[Auth Debug] Trial status fetched:", {
|
||||
status: data.status,
|
||||
daysRemaining: Math.max(0, daysRemaining),
|
||||
hasPaymentMethod: data.has_payment_method,
|
||||
isTrialing: data.status === "trialing" && daysRemaining > 0,
|
||||
});
|
||||
} else {
|
||||
setTrialStatus(null);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch trial status:", error);
|
||||
setTrialStatus(null);
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const refreshTrialStatus = useCallback(async () => {
|
||||
await fetchTrialStatus();
|
||||
}, [fetchTrialStatus]);
|
||||
|
||||
const fetchProfilePicture = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug(
|
||||
"[Auth Debug] No user session, skipping profile picture fetch",
|
||||
);
|
||||
setProfilePictureUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const PROFILE_BUCKET = "profile-pictures";
|
||||
const profilePath = `${currentSession.user.id}/avatar`;
|
||||
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching profile picture for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
const { data, error } = await supabase.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.createSignedUrl(profilePath, 60 * 60);
|
||||
|
||||
if (error) {
|
||||
// Profile picture not found is expected for users without uploads
|
||||
console.debug(
|
||||
"[Auth Debug] Profile picture not available:",
|
||||
error.message,
|
||||
);
|
||||
setProfilePictureUrl(null);
|
||||
} else {
|
||||
setProfilePictureUrl(data.signedUrl);
|
||||
console.debug(
|
||||
"[Auth Debug] Profile picture URL fetched successfully",
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch profile picture:", error);
|
||||
setProfilePictureUrl(null);
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const refreshProfilePicture = useCallback(async () => {
|
||||
await fetchProfilePicture();
|
||||
}, [fetchProfilePicture]);
|
||||
|
||||
const fetchProfilePictureMetadata = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug(
|
||||
"[Auth Debug] No user session, skipping profile picture metadata fetch",
|
||||
);
|
||||
setProfilePictureMetadata(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching profile picture metadata for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
const metadata = await getProfilePictureMetadata(
|
||||
currentSession.user.id,
|
||||
);
|
||||
setProfilePictureMetadata(metadata);
|
||||
console.debug(
|
||||
"[Auth Debug] Profile picture metadata fetched:",
|
||||
metadata,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.debug(
|
||||
"[Auth Debug] Failed to fetch profile picture metadata:",
|
||||
error,
|
||||
);
|
||||
setProfilePictureMetadata(null);
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const refreshProfilePictureMetadata = useCallback(async () => {
|
||||
await fetchProfilePictureMetadata();
|
||||
}, [fetchProfilePictureMetadata]);
|
||||
|
||||
const updateCredits = useCallback(
|
||||
(newBalance: number) => {
|
||||
console.debug("[Auth Debug] Updating credit balance:", {
|
||||
from: creditBalance,
|
||||
to: newBalance,
|
||||
});
|
||||
setCreditBalance(newBalance);
|
||||
// Also update the creditSummary if it exists
|
||||
if (creditSummary) {
|
||||
const updatedSummary: CreditSummary = {
|
||||
...creditSummary,
|
||||
creditsRemaining: newBalance,
|
||||
currentCredits: newBalance,
|
||||
};
|
||||
setCreditSummary(updatedSummary);
|
||||
}
|
||||
},
|
||||
[creditSummary],
|
||||
);
|
||||
|
||||
const hasSufficientCredits = useCallback(
|
||||
(requiredCredits: number): CreditCheckResult => {
|
||||
const currentBalance = creditBalance ?? 0;
|
||||
const hasSufficient = currentBalance >= requiredCredits;
|
||||
console.debug("[Auth Debug] Credit check:", {
|
||||
requiredCredits,
|
||||
currentBalance,
|
||||
hasSufficient,
|
||||
});
|
||||
|
||||
return {
|
||||
hasSufficientCredits: hasSufficient,
|
||||
currentBalance,
|
||||
requiredCredits,
|
||||
shortfall: hasSufficient ? undefined : requiredCredits - currentBalance,
|
||||
};
|
||||
},
|
||||
[creditBalance],
|
||||
);
|
||||
|
||||
const refreshSession = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error } = await supabase.auth.refreshSession();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth Debug] Session refresh error:", error);
|
||||
setError(error);
|
||||
setSession(null);
|
||||
} else {
|
||||
console.debug("[Auth Debug] Session refreshed successfully");
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Auth Debug] Unexpected error during session refresh:",
|
||||
err,
|
||||
);
|
||||
setError(err as AuthError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth Debug] Sign out error:", error);
|
||||
setError(error);
|
||||
} else {
|
||||
console.debug("[Auth Debug] Signed out successfully");
|
||||
setSession(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth Debug] Unexpected error during sign out:", err);
|
||||
setError(err as AuthError);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up global credit update callback
|
||||
useEffect(() => {
|
||||
setGlobalCreditUpdateCallback(updateCredits);
|
||||
}, [updateCredits]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
// Load current session on first mount
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
console.debug("[Auth Debug] Initializing auth...");
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth Debug] Initial session error:", error);
|
||||
setError(error);
|
||||
} else {
|
||||
console.debug("[Auth Debug] Initial session loaded:", {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
});
|
||||
setSession(data.session);
|
||||
|
||||
// Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response
|
||||
if (data.session?.user) {
|
||||
// Sync OAuth avatar in background
|
||||
syncOAuthAvatar(data.session.user).catch((err) => {
|
||||
console.debug(
|
||||
"[Auth Debug] Failed to sync OAuth avatar on init:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
|
||||
await fetchCredits(data.session);
|
||||
await fetchProStatus(data.session);
|
||||
await fetchTrialStatus(data.session);
|
||||
await fetchProfilePictureMetadata(data.session);
|
||||
|
||||
// Small delay to allow avatar sync to complete if quick
|
||||
setTimeout(() => {
|
||||
fetchProfilePicture(data.session);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Auth Debug] Unexpected error during auth initialization:",
|
||||
err,
|
||||
);
|
||||
if (mounted) {
|
||||
setError(err as AuthError);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
|
||||
// Subscribe to auth state changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(async (event, newSession) => {
|
||||
if (!mounted) return;
|
||||
|
||||
console.debug("[Auth Debug] Auth state change:", {
|
||||
event,
|
||||
hasSession: !!newSession,
|
||||
userId: newSession?.user?.id,
|
||||
email: newSession?.user?.email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Don't run supabase calls inside this callback; schedule them
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setSession(newSession);
|
||||
setError(null);
|
||||
|
||||
// Additional handling for specific events
|
||||
if (event === "SIGNED_OUT") {
|
||||
console.debug("[Auth Debug] User signed out, clearing session");
|
||||
// Clear credit data, pro status, trial status, profile picture, and metadata on sign out
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
setIsPro(null);
|
||||
setTrialStatus(null);
|
||||
setProfilePictureUrl(null);
|
||||
setProfilePictureMetadata(null);
|
||||
} else if (event === "SIGNED_IN") {
|
||||
console.debug("[Auth Debug] User signed in successfully");
|
||||
if (newSession?.user) {
|
||||
setLoading(true);
|
||||
|
||||
// Sync OAuth avatar in background (don't block other fetches)
|
||||
syncOAuthAvatar(newSession.user).catch((err) => {
|
||||
console.debug("[Auth Debug] Failed to sync OAuth avatar:", err);
|
||||
});
|
||||
|
||||
// Fetch user data in parallel
|
||||
Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
]).then(() => {
|
||||
// Fetch profile picture AFTER sync has had time to complete
|
||||
// Use a small delay to allow avatar sync to finish if it's quick
|
||||
setTimeout(() => {
|
||||
fetchProfilePicture(newSession).finally(() => {
|
||||
setLoading(false);
|
||||
console.debug(
|
||||
"[Auth Debug] User data fully loaded after sign in",
|
||||
);
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
} else if (event === "TOKEN_REFRESHED") {
|
||||
console.debug("[Auth Debug] Token refreshed");
|
||||
// Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh
|
||||
if (newSession?.user) {
|
||||
Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession),
|
||||
]).then(() => {
|
||||
console.debug(
|
||||
"[Auth Debug] User data refreshed after token refresh",
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (event === "USER_UPDATED") {
|
||||
console.debug("[Auth Debug] User updated");
|
||||
|
||||
// Check if this is a pending OAuth upgrade completion
|
||||
const pendingUpgrade = sessionStorage.getItem("pendingUpgrade");
|
||||
const upgradeProvider = sessionStorage.getItem("upgradeProvider");
|
||||
|
||||
if (
|
||||
pendingUpgrade &&
|
||||
newSession?.user &&
|
||||
newSession.user.is_anonymous === false
|
||||
) {
|
||||
console.debug(
|
||||
"[Auth Debug] Processing pending OAuth upgrade:",
|
||||
upgradeProvider,
|
||||
);
|
||||
|
||||
// Clear the flags first to prevent loops
|
||||
sessionStorage.removeItem("pendingUpgrade");
|
||||
sessionStorage.removeItem("upgradeProvider");
|
||||
|
||||
// Synchronize with backend
|
||||
synchronizeUserUpgrade(upgradeProvider || undefined)
|
||||
.then(() => {
|
||||
console.debug(
|
||||
"[Auth Debug] User upgrade synchronized successfully",
|
||||
);
|
||||
|
||||
// Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade
|
||||
if (newSession?.user) {
|
||||
return Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession),
|
||||
]);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
console.debug(
|
||||
"[Auth Debug] User data refreshed after upgrade",
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
"[Auth Debug] Failed to synchronize user upgrade:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: AuthContextType = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
loading,
|
||||
error,
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
trialStatus,
|
||||
profilePictureUrl,
|
||||
profilePictureMetadata,
|
||||
signOut,
|
||||
refreshSession,
|
||||
hasSufficientCredits,
|
||||
updateCredits,
|
||||
refreshCredits,
|
||||
refreshProStatus,
|
||||
refreshTrialStatus,
|
||||
refreshProfilePicture,
|
||||
refreshProfilePictureMetadata,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// Debug hook to expose auth state for debugging
|
||||
export function useAuthDebug() {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[Auth Debug] Current auth state:", {
|
||||
hasSession: !!auth.session,
|
||||
hasUser: !!auth.user,
|
||||
loading: auth.loading,
|
||||
hasError: !!auth.error,
|
||||
userId: auth.user?.id,
|
||||
email: auth.user?.email,
|
||||
provider: auth.user?.app_metadata?.provider,
|
||||
});
|
||||
}, [auth.session, auth.user, auth.loading, auth.error]);
|
||||
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Debug helper to log Supabase configuration
|
||||
const debugConfig = () => {
|
||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
console.log("[Supabase Debug] Configuration:", {
|
||||
url: url ? "✓ URL configured" : "✗ URL missing",
|
||||
key: key ? "✓ Key configured" : "✗ Key missing",
|
||||
urlValue: url || "undefined",
|
||||
keyValue: key ? `${key.substring(0, 20)}...` : "undefined",
|
||||
});
|
||||
|
||||
return { url, key };
|
||||
};
|
||||
|
||||
const config = debugConfig();
|
||||
|
||||
if (!config.url) {
|
||||
throw new Error("Missing VITE_SUPABASE_URL environment variable");
|
||||
}
|
||||
|
||||
if (!config.key) {
|
||||
throw new Error(
|
||||
"Missing VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY environment variable",
|
||||
);
|
||||
}
|
||||
|
||||
export const supabase = createClient(config.url, config.key, {
|
||||
auth: {
|
||||
persistSession: true, // keep session in localStorage
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: true, // helpful on first load after redirect
|
||||
// debug: import.meta.env.DEV, // Enable debug logs in development
|
||||
},
|
||||
});
|
||||
|
||||
// Debug helper for auth events
|
||||
export const debugAuthEvents = () => {
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log("[Supabase Debug] Auth state change:", {
|
||||
event,
|
||||
hasSession: !!session,
|
||||
userId: session?.user?.id,
|
||||
email: session?.user?.email,
|
||||
provider: session?.user?.app_metadata?.provider,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Debug auth events can be manually enabled by calling debugAuthEvents()
|
||||
// Commented out to prevent excessive logging on every page load
|
||||
// if (import.meta.env.DEV) {
|
||||
// debugAuthEvents()
|
||||
// }
|
||||
|
||||
// Anonymous authentication functions
|
||||
export const signInAnonymously = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInAnonymously();
|
||||
|
||||
if (error) {
|
||||
console.error("[Supabase] Anonymous sign-in error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log("[Supabase] Anonymous sign-in successful:", {
|
||||
userId: data.user?.id,
|
||||
isAnonymous: data.user?.is_anonymous,
|
||||
});
|
||||
|
||||
return { data, error };
|
||||
} catch (error) {
|
||||
console.error("[Supabase] Anonymous sign-in failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Account linking functions
|
||||
export const linkEmailIdentity = async (email: string, password?: string) => {
|
||||
try {
|
||||
const updateData: { email: string; password?: string } = { email };
|
||||
if (password) {
|
||||
updateData.password = password;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.updateUser(updateData);
|
||||
|
||||
if (error) {
|
||||
console.error("[Supabase] Email linking error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Refresh session to get updated token with new user metadata
|
||||
const { error: refreshError } = await supabase.auth.refreshSession();
|
||||
|
||||
if (refreshError) {
|
||||
console.warn(
|
||||
"[Supabase] Session refresh after email linking failed:",
|
||||
refreshError,
|
||||
);
|
||||
// Don't throw - linking was successful, refresh is just for consistency
|
||||
} else {
|
||||
console.log("[Supabase] Session refreshed after email linking");
|
||||
}
|
||||
|
||||
console.log("[Supabase] Email linked successfully:", {
|
||||
email,
|
||||
userId: data.user?.id,
|
||||
});
|
||||
|
||||
return { data, error };
|
||||
} catch (error) {
|
||||
console.error("[Supabase] Email linking failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const linkOAuthIdentity = async (
|
||||
provider: "google" | "github" | "apple" | "azure",
|
||||
redirectTo?: string,
|
||||
) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.linkIdentity({
|
||||
provider: provider,
|
||||
options: redirectTo ? { redirectTo } : undefined,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[Supabase] OAuth linking error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log("[Supabase] OAuth identity linked successfully:", {
|
||||
provider,
|
||||
redirectTo,
|
||||
url: data.url,
|
||||
});
|
||||
|
||||
return { data, error };
|
||||
} catch (error) {
|
||||
console.error("[Supabase] OAuth linking failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if user is anonymous
|
||||
export const isUserAnonymous = (user: { is_anonymous?: boolean }) => {
|
||||
return user?.is_anonymous === true;
|
||||
};
|
||||
|
||||
// Get current user session
|
||||
export const getCurrentUser = async () => {
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (error) {
|
||||
console.error("[Supabase] Get user error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error("[Supabase] Get user failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { useOnboarding } from "@app/contexts/OnboardingContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal";
|
||||
|
||||
const STORAGE_KEY = "saas_onboarding_seen";
|
||||
const ONBOARDING_SESSION_BLOCK_KEY = "stirling-onboarding-session-active";
|
||||
|
||||
/**
|
||||
* SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed,
|
||||
* and show SaaS-specific onboarding on first login.
|
||||
*/
|
||||
export default function OnboardingBootstrap() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { clearPendingTourRequest, setStartAfterToolModeSelection } =
|
||||
useOnboarding();
|
||||
const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [pollAttempts, setPollAttempts] = useState(0);
|
||||
|
||||
// Start polling when user logs in
|
||||
useEffect(() => {
|
||||
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
|
||||
if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
|
||||
console.debug("[Onboarding] Starting poll for trial data");
|
||||
setIsPolling(true);
|
||||
setPollAttempts(0);
|
||||
}
|
||||
}, [user, loading, isPolling, showModal]);
|
||||
|
||||
// Poll for trial data
|
||||
useEffect(() => {
|
||||
if (!isPolling) return;
|
||||
|
||||
const pollInterval = 500; // Check every 500ms
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const newAttempts = pollAttempts + 1;
|
||||
console.debug(
|
||||
"[Onboarding] Polling for trial data, attempt:",
|
||||
newAttempts,
|
||||
);
|
||||
|
||||
await refreshTrialStatus();
|
||||
setPollAttempts(newAttempts);
|
||||
|
||||
// Check will happen in the next effect
|
||||
}, pollInterval);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isPolling, pollAttempts, refreshTrialStatus]);
|
||||
|
||||
// Stop polling when data arrives or timeout
|
||||
useEffect(() => {
|
||||
if (!isPolling) return;
|
||||
|
||||
const hasData = trialStatus !== undefined && trialStatus !== null;
|
||||
const hasProStatus = isPro !== null;
|
||||
const maxAttempts = 10;
|
||||
|
||||
if (hasData || pollAttempts >= maxAttempts) {
|
||||
console.debug("[Onboarding] Trial data ready or timeout, showing modal", {
|
||||
hasData,
|
||||
hasProStatus,
|
||||
attempts: pollAttempts,
|
||||
trialStatus,
|
||||
isPro,
|
||||
});
|
||||
setIsPolling(false);
|
||||
setShowModal(true);
|
||||
}
|
||||
}, [isPolling, trialStatus, isPro, pollAttempts]);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
// Keep existing logic to disable core onboarding flags
|
||||
useEffect(() => {
|
||||
// Ensure tool panel preference is set so tours are never deferred.
|
||||
if (
|
||||
!preferences.toolPanelModePromptSeen ||
|
||||
!preferences.hasSelectedToolPanelMode
|
||||
) {
|
||||
updatePreference("toolPanelModePromptSeen", true);
|
||||
updatePreference("hasSelectedToolPanelMode", true);
|
||||
}
|
||||
|
||||
// Clear any lingering deferred tour requests.
|
||||
clearPendingTourRequest();
|
||||
setStartAfterToolModeSelection(false);
|
||||
|
||||
// In SaaS, skip the core intro onboarding entirely.
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference("hasSeenIntroOnboarding", true);
|
||||
}
|
||||
// Also mark completed to avoid follow-up banners/modals.
|
||||
if (!preferences.hasCompletedOnboarding) {
|
||||
updatePreference("hasCompletedOnboarding", true);
|
||||
}
|
||||
|
||||
// Also clear any session flag that might mark onboarding as active.
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
|
||||
}
|
||||
}, [
|
||||
preferences.hasSelectedToolPanelMode,
|
||||
preferences.toolPanelModePromptSeen,
|
||||
preferences.hasSeenIntroOnboarding,
|
||||
preferences.hasCompletedOnboarding,
|
||||
updatePreference,
|
||||
clearPendingTourRequest,
|
||||
setStartAfterToolModeSelection,
|
||||
]);
|
||||
|
||||
// Only render modal when it should be shown to avoid running hooks unnecessarily
|
||||
return showModal ? (
|
||||
<SaasOnboardingModal opened={showModal} onClose={handleClose} />
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { TrialExpiredModal } from "@app/components/shared/TrialExpiredModal";
|
||||
import StripeCheckout from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
/**
|
||||
* Bootstrap component that shows the trial expired modal when a user's trial has ended
|
||||
* and they haven't added a payment method. Shows once per user per expired trial.
|
||||
*/
|
||||
export default function TrialExpiredBootstrap() {
|
||||
const { user, trialStatus, isPro } = useAuth();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [checkoutOpened, setCheckoutOpened] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Close modal if user logs out or session expires
|
||||
if (!user) {
|
||||
if (showModal) {
|
||||
console.debug("[TrialExpired] User logged out, closing modal");
|
||||
setShowModal(false);
|
||||
}
|
||||
if (checkoutOpened) {
|
||||
setCheckoutOpened(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check conditions when auth is fully loaded
|
||||
if (trialStatus === null || isPro === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build localStorage key unique to this user
|
||||
const storageKey = `trialExpiredModalShown_${user.id}`;
|
||||
const hasSeenModal = localStorage.getItem(storageKey) === "true";
|
||||
|
||||
// If user is currently trialing, clear any previous "seen" flag
|
||||
// This handles the edge case where a user might re-enter a trial
|
||||
if (trialStatus.isTrialing) {
|
||||
if (hasSeenModal) {
|
||||
console.debug("[TrialExpired] User is trialing, clearing seen flag");
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all conditions are met to show the modal
|
||||
const isExpired =
|
||||
trialStatus.status === "incomplete_expired" ||
|
||||
trialStatus.status === "canceled";
|
||||
const hasNoPayment =
|
||||
!trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub;
|
||||
const wasDowngraded = !isPro;
|
||||
const trialEndedRecently = trialStatus.daysRemaining === 0;
|
||||
|
||||
const shouldShowModal =
|
||||
isExpired &&
|
||||
hasNoPayment &&
|
||||
wasDowngraded &&
|
||||
trialEndedRecently &&
|
||||
!hasSeenModal;
|
||||
|
||||
if (shouldShowModal) {
|
||||
console.debug("[TrialExpired] Showing trial expired modal", {
|
||||
status: trialStatus.status,
|
||||
daysRemaining: trialStatus.daysRemaining,
|
||||
hasPaymentMethod: trialStatus.hasPaymentMethod,
|
||||
hasScheduledSub: trialStatus.hasScheduledSub,
|
||||
isPro,
|
||||
});
|
||||
setShowModal(true);
|
||||
}
|
||||
}, [user, trialStatus, isPro, showModal, checkoutOpened]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (user) {
|
||||
const storageKey = `trialExpiredModalShown_${user.id}`;
|
||||
localStorage.setItem(storageKey, "true");
|
||||
console.debug("[TrialExpired] Modal dismissed, marking as seen");
|
||||
}
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleSubscribe = () => {
|
||||
console.debug("[TrialExpired] User clicked Subscribe to Pro");
|
||||
setCheckoutOpened(true);
|
||||
};
|
||||
|
||||
const handleCheckoutSuccess = () => {
|
||||
console.debug("[TrialExpired] Subscription successful, refreshing page");
|
||||
// Close modal and refresh to update subscription status
|
||||
handleClose();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleCheckoutClose = () => {
|
||||
console.debug("[TrialExpired] Checkout closed");
|
||||
setCheckoutOpened(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrialExpiredModal
|
||||
opened={showModal && !checkoutOpened}
|
||||
onClose={handleClose}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<StripeCheckout
|
||||
opened={checkoutOpened}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType="subscription"
|
||||
planId="pro"
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) =>
|
||||
console.error("[TrialExpired] Checkout error:", error)
|
||||
}
|
||||
isTrialConversion={false} // Trial already ended, so this is not a conversion
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
.guest-banner {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 4.5rem;
|
||||
z-index: 1000;
|
||||
background: var(--modal-content-bg, #111418);
|
||||
border: 1px solid var(--api-keys-card-border, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
padding: 12px 16px;
|
||||
max-width: 30rem;
|
||||
width: 30rem;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.guest-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guest-banner-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.guest-banner-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-text);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.guest-banner-message {
|
||||
font-size: 12px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guest-banner-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guest-banner-dismiss {
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guest-banner-dismiss:hover {
|
||||
background: var(--mantine-color-gray-1, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.guest-banner-signup {
|
||||
white-space: nowrap;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--blue-6, #2563eb);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guest-banner-signup:hover {
|
||||
background: var(--blue-7, #1d4ed8);
|
||||
}
|
||||
|
||||
.guest-banner-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.guest-banner-signup-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PersonAddIcon from "@mui/icons-material/PersonAdd";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import "@app/components/auth/GuestUserBanner.css";
|
||||
|
||||
interface GuestUserBannerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Ensure the toast only appears once per full page load, not on re-hydration
|
||||
let hasShownThisLoad = false;
|
||||
|
||||
/**
|
||||
* Guest user toast encouraging account creation.
|
||||
* Appears 2s after load, top-right of the viewport.
|
||||
*/
|
||||
export function GuestUserBanner({ className = "" }: GuestUserBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { session } = useAuth();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const isAnon = Boolean(session?.user && isUserAnonymous(session.user));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnon || hasShownThisLoad) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(true);
|
||||
hasShownThisLoad = true;
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isAnon]);
|
||||
|
||||
if (!isAnon || isDismissed || !visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSignUp = () => {
|
||||
window.location.href = withBasePath("/signup");
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`guest-banner ${className || ""}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="guest-banner-content">
|
||||
<div className="guest-banner-text">
|
||||
<div className="guest-banner-title">
|
||||
{t("guestBanner.title", "You're using Stirling PDF as a guest!")}
|
||||
</div>
|
||||
<div className="guest-banner-message">
|
||||
{t(
|
||||
"guestBanner.message",
|
||||
"Create a free account to save your work, access more features, and support the project.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="guest-banner-actions">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
aria-label={t("guestBanner.dismiss", "Dismiss banner")}
|
||||
className="guest-banner-dismiss"
|
||||
>
|
||||
<CloseIcon className="guest-banner-icon" />
|
||||
</button>
|
||||
<button onClick={handleSignUp} className="guest-banner-signup">
|
||||
<PersonAddIcon className="guest-banner-signup-icon" />
|
||||
{t("guestBanner.signUp", "Sign Up Free")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuestUserBanner;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth";
|
||||
|
||||
interface RequireAuthProps {
|
||||
fallbackPath?: string;
|
||||
}
|
||||
|
||||
export function RequireAuth({ fallbackPath = "/login" }: RequireAuthProps) {
|
||||
const { session, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
const { isAutoAuthenticating } = useAutoAnonymousAuth();
|
||||
|
||||
// Safe development-only auth bypass
|
||||
const isLocalhost =
|
||||
typeof window !== "undefined" &&
|
||||
/^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname);
|
||||
const devBypassEnabled = Boolean(
|
||||
import.meta.env.DEV &&
|
||||
isLocalhost &&
|
||||
import.meta.env.VITE_DEV_BYPASS_AUTH === "true",
|
||||
);
|
||||
|
||||
if (devBypassEnabled) {
|
||||
console.warn(
|
||||
"[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost",
|
||||
);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// Wait for both auth bootstrap and auto-anon to finish
|
||||
if (loading || isAutoAuthenticating) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin h-6 w-6 mx-auto mb-3 border-2 border-gray-300 border-t-transparent rounded-full" />
|
||||
<p className="text-gray-600">Preparing your session…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Change the URL to /login
|
||||
return <Navigate to={fallbackPath} replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
// Render protected routes
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export default RequireAuth;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Userback from "@userback/widget";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
interface UserbackWidgetProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface UserbackInstance {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export default function UserbackWidget({ token }: UserbackWidgetProps) {
|
||||
const { user } = useAuth();
|
||||
const userbackRef = useRef<UserbackInstance | null>(null);
|
||||
const initializingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || initializingRef.current) return;
|
||||
|
||||
initializingRef.current = true;
|
||||
|
||||
const initializeUserback = async () => {
|
||||
try {
|
||||
// Prepare user data options
|
||||
const userInfo: { name?: string; email?: string } = {};
|
||||
if (user.user_metadata?.full_name)
|
||||
userInfo.name = user.user_metadata.full_name;
|
||||
if (user.email) userInfo.email = user.email;
|
||||
|
||||
const options = {
|
||||
user_data: {
|
||||
id: user.id,
|
||||
info: userInfo,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize Userback
|
||||
userbackRef.current = await Userback(token, options);
|
||||
} finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
initializeUserback();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (
|
||||
userbackRef.current &&
|
||||
typeof userbackRef.current.destroy === "function"
|
||||
) {
|
||||
userbackRef.current.destroy();
|
||||
}
|
||||
initializingRef.current = false;
|
||||
};
|
||||
}, [user, token]);
|
||||
|
||||
return null; // This component doesn't render anything visible
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import UserbackWidget from "@app/components/feedback/UserbackWidget";
|
||||
|
||||
export function HomePageExtensions() {
|
||||
const userbackToken = import.meta.env.VITE_USERBACK_TOKEN;
|
||||
return userbackToken ? <UserbackWidget token={userbackToken} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* SaaS stub — core tour system is suppressed in SaaS.
|
||||
* SaaS uses SaasOnboardingModal instead.
|
||||
*/
|
||||
export default function OnboardingTour() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import React from "react";
|
||||
import { Modal, Stack } from "@mantine/core";
|
||||
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
|
||||
import { renderButtons } from "@app/components/onboarding/renderButtons";
|
||||
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||
import { useSaasOnboardingState } from "@app/components/onboarding/useSaasOnboardingState";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
|
||||
interface SaasOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const flow = useSaasOnboardingState(props);
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
flowState,
|
||||
handleButtonAction,
|
||||
} = flow;
|
||||
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === "dual-icon") {
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img
|
||||
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||
alt="Stirling icon"
|
||||
className={styles.downloadIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === "rocket" && (
|
||||
<LocalIcon
|
||||
icon="rocket-launch"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "diamond" && (
|
||||
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
onClose={props.onClose}
|
||||
closeOnClickOutside={false}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: {
|
||||
overflow: "hidden",
|
||||
border: "none",
|
||||
background: "var(--bg-surface)",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={0}
|
||||
className={styles.modalContent}
|
||||
style={{
|
||||
height: "100%",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={currentSlide.background.gradientStops}
|
||||
circles={currentSlide.background.circles}
|
||||
isActive
|
||||
slideKey={currentSlide.key}
|
||||
/>
|
||||
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
{currentSlide.title}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div
|
||||
key={`body-${currentSlide.key}`}
|
||||
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
|
||||
>
|
||||
{currentSlide.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper
|
||||
totalSteps={totalSteps}
|
||||
activeStep={currentStep}
|
||||
/>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{renderButtons({
|
||||
slideDefinition,
|
||||
flowState,
|
||||
onAction: handleButtonAction,
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { Button, Group, ActionIcon } from "@mantine/core";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
ButtonDefinition,
|
||||
type FlowState,
|
||||
type ButtonAction,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
interface RenderButtonsProps {
|
||||
slideDefinition: {
|
||||
buttons: ButtonDefinition[];
|
||||
id: string;
|
||||
};
|
||||
flowState: FlowState;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function renderButtons({
|
||||
slideDefinition,
|
||||
flowState,
|
||||
onAction,
|
||||
t,
|
||||
}: RenderButtonsProps) {
|
||||
const leftButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "left",
|
||||
);
|
||||
const rightButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "right",
|
||||
);
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
? {
|
||||
root: {
|
||||
background: "var(--onboarding-primary-button-bg)",
|
||||
color: "var(--onboarding-primary-button-text)",
|
||||
},
|
||||
}
|
||||
: {
|
||||
root: {
|
||||
background: "var(--onboarding-secondary-button-bg)",
|
||||
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||
color: "var(--onboarding-secondary-button-text)",
|
||||
},
|
||||
};
|
||||
|
||||
const resolveButtonLabel = (button: ButtonDefinition) => {
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? "";
|
||||
if (!label) return "";
|
||||
|
||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||
const fallback = label.split(".").pop() || label;
|
||||
return t(label, fallback);
|
||||
};
|
||||
|
||||
const renderButton = (button: ButtonDefinition) => {
|
||||
const disabled = button.disabledWhen?.(flowState) ?? false;
|
||||
|
||||
if (button.type === "icon") {
|
||||
return (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
radius="md"
|
||||
size={40}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
background: "var(--onboarding-secondary-button-bg)",
|
||||
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||
color: "var(--onboarding-secondary-button-text)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === "chevron-left" && (
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
const variant = button.variant ?? "secondary";
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={disabled}
|
||||
styles={buttonStyles(variant)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (leftButtons.length === 0) {
|
||||
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
if (rightButtons.length === 0) {
|
||||
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
import {
|
||||
FLOW_SEQUENCES,
|
||||
SlideId,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
export interface FlowConfig {
|
||||
type: "saas-trial" | "saas-paid";
|
||||
ids: SlideId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the appropriate onboarding flow based on user's subscription status.
|
||||
*
|
||||
* @param trialStatus - User's trial information from Supabase
|
||||
* @param _isPro - Whether user has Pro subscription
|
||||
* @returns FlowConfig with the appropriate slide sequence
|
||||
*/
|
||||
export function resolveSaasFlow(
|
||||
trialStatus: TrialStatus | null,
|
||||
_isPro: boolean | null,
|
||||
): FlowConfig {
|
||||
// Show free trial card if:
|
||||
// 1. User has active trial (isTrialing = true)
|
||||
// 2. Trial has not expired (daysRemaining > 0)
|
||||
// 3. User is not paid Pro (or Pro is from trial)
|
||||
const hasActiveTrial =
|
||||
trialStatus?.isTrialing === true && trialStatus.daysRemaining > 0;
|
||||
|
||||
if (hasActiveTrial) {
|
||||
return {
|
||||
type: "saas-trial",
|
||||
ids: FLOW_SEQUENCES.saasTrialUser,
|
||||
};
|
||||
}
|
||||
|
||||
// For paid users, expired trials, or no trial info
|
||||
return {
|
||||
type: "saas-paid",
|
||||
ids: FLOW_SEQUENCES.saasPaidUser,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
|
||||
import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide";
|
||||
import FreeTrialSlide from "@app/components/onboarding/slides/FreeTrialSlide";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
|
||||
export type SlideId = "welcome" | "free-trial" | "desktop-install";
|
||||
|
||||
export type HeroType = "rocket" | "dual-icon" | "diamond";
|
||||
|
||||
export type ButtonAction = "next" | "prev" | "close" | "download-selected";
|
||||
|
||||
export type FlowState = Record<string, never>;
|
||||
|
||||
export interface OSOption {
|
||||
label: string;
|
||||
url: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SlideFactoryParams {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
osOptions?: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
trialStatus?: TrialStatus | null;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
type: HeroType;
|
||||
}
|
||||
|
||||
export interface ButtonDefinition {
|
||||
key: string;
|
||||
type: "button" | "icon";
|
||||
label?: string;
|
||||
icon?: "chevron-left";
|
||||
variant?: "primary" | "secondary" | "default";
|
||||
group: "left" | "right";
|
||||
action: ButtonAction;
|
||||
disabledWhen?: (state: FlowState) => boolean;
|
||||
}
|
||||
|
||||
export interface SlideDefinition {
|
||||
id: SlideId;
|
||||
createSlide: (params: SlideFactoryParams) => SlideConfig;
|
||||
hero: HeroDefinition;
|
||||
buttons: ButtonDefinition[];
|
||||
}
|
||||
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
welcome: {
|
||||
id: "welcome",
|
||||
createSlide: () => WelcomeSlide(),
|
||||
hero: { type: "rocket" },
|
||||
buttons: [
|
||||
{
|
||||
key: "welcome-next",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.next",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "next",
|
||||
},
|
||||
],
|
||||
},
|
||||
"free-trial": {
|
||||
id: "free-trial",
|
||||
createSlide: ({ trialStatus }) => {
|
||||
if (!trialStatus) {
|
||||
throw new Error("Trial status is required for free-trial slide");
|
||||
}
|
||||
return FreeTrialSlide({ trialStatus });
|
||||
},
|
||||
hero: { type: "diamond" },
|
||||
buttons: [
|
||||
{
|
||||
key: "trial-back",
|
||||
type: "icon",
|
||||
icon: "chevron-left",
|
||||
group: "left",
|
||||
action: "prev",
|
||||
},
|
||||
{
|
||||
key: "trial-next",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.next",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "next",
|
||||
},
|
||||
],
|
||||
},
|
||||
"desktop-install": {
|
||||
id: "desktop-install",
|
||||
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
|
||||
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
||||
hero: { type: "dual-icon" },
|
||||
buttons: [
|
||||
{
|
||||
key: "desktop-back",
|
||||
type: "icon",
|
||||
icon: "chevron-left",
|
||||
group: "left",
|
||||
action: "prev",
|
||||
},
|
||||
{
|
||||
key: "desktop-skip",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.skipForNow",
|
||||
variant: "secondary",
|
||||
group: "left",
|
||||
action: "close",
|
||||
},
|
||||
{
|
||||
key: "desktop-download",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.download",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "download-selected",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const FLOW_SEQUENCES = {
|
||||
saasTrialUser: ["welcome", "free-trial", "desktop-install"] as SlideId[],
|
||||
saasPaidUser: ["welcome", "desktop-install"] as SlideId[],
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
|
||||
interface FreeTrialSlideProps {
|
||||
trialStatus: TrialStatus;
|
||||
}
|
||||
|
||||
function FreeTrialSlideTitle() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span>{t("onboarding.freeTrial.title", "Your 30-Day Pro Trial")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Format the trial end date
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
// Determine which message to show based on payment method
|
||||
const afterTrialMessage = trialStatus.hasScheduledSub
|
||||
? t(
|
||||
"onboarding.freeTrial.afterTrialWithPayment",
|
||||
"Your Pro subscription will start automatically when the trial ends.",
|
||||
)
|
||||
: trialStatus.hasPaymentMethod
|
||||
? t(
|
||||
"onboarding.freeTrial.afterTrialWithPayment",
|
||||
"Your Pro subscription will start automatically when the trial ends.",
|
||||
)
|
||||
: t(
|
||||
"onboarding.freeTrial.afterTrialWithoutPayment",
|
||||
"After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access.",
|
||||
);
|
||||
|
||||
// Pluralize days remaining
|
||||
const daysText =
|
||||
trialStatus.daysRemaining === 1
|
||||
? t(
|
||||
"onboarding.freeTrial.daysRemainingSingular",
|
||||
"{{days}} day remaining",
|
||||
{ days: trialStatus.daysRemaining },
|
||||
)
|
||||
: t("onboarding.freeTrial.daysRemaining", "{{days}} days remaining", {
|
||||
days: trialStatus.daysRemaining,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<p>
|
||||
{t(
|
||||
"onboarding.freeTrial.body",
|
||||
"You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.",
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{daysText}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.9rem", opacity: 0.9 }}>
|
||||
{t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", {
|
||||
date: trialEndDate,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.9rem", opacity: 0.9 }}>{afterTrialMessage}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function FreeTrialSlide({
|
||||
trialStatus,
|
||||
}: FreeTrialSlideProps): SlideConfig {
|
||||
return {
|
||||
key: "free-trial",
|
||||
title: <FreeTrialSlideTitle />,
|
||||
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
|
||||
background: {
|
||||
gradientStops: ["#10B981", "#06B6D4"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useOs } from "@app/hooks/useOs";
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type ButtonAction,
|
||||
type FlowState,
|
||||
type SlideId,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver";
|
||||
import { DOWNLOAD_URLS } from "@app/constants/downloads";
|
||||
|
||||
interface UseSaasOnboardingStateResult {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]["createSlide"]>;
|
||||
flowState: FlowState;
|
||||
handleButtonAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
interface UseSaasOnboardingStateProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useSaasOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
|
||||
const { trialStatus, isPro, loading } = useAuth();
|
||||
const osType = useOs();
|
||||
const selectedDownloadUrlRef = useRef<string>("");
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<number>(0);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Determine OS details for desktop download
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case "windows":
|
||||
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||
case "mac":
|
||||
return { label: "Mac", url: DOWNLOAD_URLS.MAC };
|
||||
case "linux-x64":
|
||||
case "linux-arm64":
|
||||
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: "", url: "" };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
|
||||
{ label: "Mac", url: DOWNLOAD_URLS.MAC, value: "mac" },
|
||||
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
|
||||
];
|
||||
return options.filter((opt) => opt.url);
|
||||
}, []);
|
||||
|
||||
// Store selected download URL
|
||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
||||
selectedDownloadUrlRef.current = url;
|
||||
}, []);
|
||||
|
||||
// Resolve flow based on trial status
|
||||
const resolvedFlow = useMemo(
|
||||
() => resolveSaasFlow(trialStatus, isPro),
|
||||
[trialStatus, isPro],
|
||||
);
|
||||
|
||||
const flowSlideIds = resolvedFlow.ids;
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
|
||||
// Ensure current step is within bounds
|
||||
useEffect(() => {
|
||||
if (currentStep >= flowSlideIds.length) {
|
||||
setCurrentStep(Math.max(flowSlideIds.length - 1, 0));
|
||||
}
|
||||
}, [flowSlideIds.length, currentStep]);
|
||||
|
||||
const currentSlideId =
|
||||
flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
// Create slide with appropriate params - must be called before any early returns
|
||||
const currentSlide = useMemo(() => {
|
||||
if (!slideDefinition) return null;
|
||||
return slideDefinition.createSlide({
|
||||
osLabel: os.label,
|
||||
osUrl: os.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
trialStatus: trialStatus ?? undefined,
|
||||
});
|
||||
}, [
|
||||
slideDefinition,
|
||||
os.label,
|
||||
os.url,
|
||||
osOptions,
|
||||
handleDownloadUrlChange,
|
||||
trialStatus,
|
||||
]);
|
||||
|
||||
// Navigation functions
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, maxIndex));
|
||||
}, [maxIndex]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
// Handle button actions
|
||||
const handleButtonAction = useCallback(
|
||||
(action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case "next":
|
||||
// If on last slide, close modal
|
||||
if (currentStep === maxIndex) {
|
||||
onClose();
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
return;
|
||||
case "prev":
|
||||
goPrev();
|
||||
return;
|
||||
case "close":
|
||||
onClose();
|
||||
return;
|
||||
case "download-selected": {
|
||||
// Open download URL in new tab
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
// Then advance to next slide or close if last
|
||||
if (currentStep === maxIndex) {
|
||||
onClose();
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
console.warn(`Unhandled button action: ${action}`);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[currentStep, maxIndex, goNext, goPrev, onClose, os.url],
|
||||
);
|
||||
|
||||
const flowState: FlowState = {};
|
||||
|
||||
// Early return after all hooks have been called
|
||||
if (!slideDefinition || !currentSlide || loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
slideDefinition,
|
||||
currentSlide,
|
||||
flowState,
|
||||
handleButtonAction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { Modal, Button, Text, ActionIcon } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import Overview from "@app/components/shared/config/configSections/Overview";
|
||||
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
import {
|
||||
Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
Z_INDEX_OVER_SETTINGS_MODAL,
|
||||
} from "@app/styles/zIndex";
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
const { signOut, user, creditBalance, refreshCredits } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [active, setActive] = useState<NavKey>("overview");
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// Check if user can access billing features (non-anonymous users only)
|
||||
const isAnonymous = user ? isUserAnonymous(user) : false;
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"appConfig:navigate",
|
||||
handler as EventListener,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Listen for notice updates (e.g., "Not enough credits..." next to Plan title)
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as
|
||||
| { key?: NavKey; notice?: string }
|
||||
| undefined;
|
||||
if (detail?.notice && (detail?.key ? detail.key === "plan" : true)) {
|
||||
setNotice(detail.notice);
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:notice", handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener("appConfig:notice", handler as EventListener);
|
||||
}, []);
|
||||
|
||||
// When the modal opens to Plan, proactively refresh credits and log values
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (active !== "plan") return;
|
||||
console.log(
|
||||
"[AppConfigModal] Opening Plan section. Current creditBalance:",
|
||||
creditBalance,
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
await refreshCredits();
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[AppConfigModal] Failed to refresh credits on Plan open:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [opened, active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (active !== "plan") return;
|
||||
console.log(
|
||||
"[AppConfigModal] Credit balance updated while viewing Plan:",
|
||||
creditBalance,
|
||||
);
|
||||
}, [opened, active, creditBalance]);
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
navBg: "var(--modal-nav-bg)",
|
||||
sectionTitle: "var(--modal-nav-section-title)",
|
||||
navItem: "var(--modal-nav-item)",
|
||||
navItemActive: "var(--modal-nav-item-active)",
|
||||
navItemActiveBg: "var(--modal-nav-item-active-bg)",
|
||||
contentBg: "var(--modal-content-bg)",
|
||||
headerBorder: "var(--modal-header-border)",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(
|
||||
() =>
|
||||
createSaasConfigNavSections(Overview, openLogoutConfirm, {
|
||||
isDev,
|
||||
isAnonymous,
|
||||
t,
|
||||
}),
|
||||
[openLogoutConfirm, isDev, isAnonymous, t],
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find((i) => i.key === active);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return "";
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const activeComponent = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find((i) => i.key === active);
|
||||
if (found) return found.component;
|
||||
}
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
<div
|
||||
className={`modal-nav ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map((section) => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c={colors.sectionTitle}
|
||||
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map((item) => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive
|
||||
? colors.navItemActive
|
||||
: colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: isActive
|
||||
? colors.navItemActiveBg
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={item.icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="modal-content">
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
background: colors.contentBg,
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">
|
||||
{activeLabel}
|
||||
{active === "plan" && notice ? (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-yellow-7)",
|
||||
}}
|
||||
>
|
||||
– {notice}
|
||||
</span>
|
||||
) : null}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="modal-body">{activeComponent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* Confirm logout modal */}
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
title="Sign out"
|
||||
centered
|
||||
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
|
||||
>
|
||||
<div className="confirm-modal-content">
|
||||
<Text>Are you sure you want to sign out?</Text>
|
||||
<div className="confirm-modal-buttons">
|
||||
<Button variant="default" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} finally {
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
window.location.href = withBasePath("/login");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppConfigModal;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
type InfoBannerTone = "info" | "warning";
|
||||
|
||||
const toneStyles: Record<
|
||||
InfoBannerTone,
|
||||
{
|
||||
background: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
buttonColor: string;
|
||||
}
|
||||
> = {
|
||||
info: {
|
||||
background: "var(--mantine-color-blue-0)",
|
||||
border: "var(--mantine-color-blue-2)",
|
||||
text: "var(--mantine-color-blue-9)",
|
||||
icon: "var(--mantine-color-blue-6)",
|
||||
buttonColor: "blue",
|
||||
},
|
||||
warning: {
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "var(--mantine-color-orange-3)",
|
||||
text: "var(--mantine-color-orange-9)",
|
||||
icon: "var(--mantine-color-orange-7)",
|
||||
buttonColor: "orange",
|
||||
},
|
||||
};
|
||||
|
||||
interface InfoBannerProps {
|
||||
icon?: string | ReactNode; // SaaS supports ReactNode (e.g., logo images)
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: InfoBannerTone;
|
||||
background?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
iconColor?: string;
|
||||
buttonColor?: string;
|
||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||
buttonTextColor?: string; // SaaS-specific for dark theme buttons
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string; // SaaS-specific for dark theme
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS-specific info banner with enhanced theming support
|
||||
* Supports ReactNode icons (e.g., logo images) and custom button text colors
|
||||
*/
|
||||
export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = "check-circle-rounded",
|
||||
onButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = "info",
|
||||
background,
|
||||
borderColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
buttonColor,
|
||||
buttonVariant = "light",
|
||||
buttonTextColor,
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
compact = false,
|
||||
}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toneStyle = toneStyles[tone] ?? toneStyles.info;
|
||||
const handleDismiss = () => {
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{icon &&
|
||||
(typeof icon === "string" ? (
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
))}
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size="sm"
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
lineClamp={2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
color={buttonColor ?? toneStyle.buttonColor}
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
styles={
|
||||
buttonTextColor
|
||||
? {
|
||||
label: {
|
||||
color: buttonTextColor,
|
||||
},
|
||||
}
|
||||
: buttonVariant !== "white" && buttonVariant !== "filled"
|
||||
? {
|
||||
label: {
|
||||
color: textColor ?? toneStyle.text,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={closeIconColor ? undefined : "gray"}
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss"
|
||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { Button } from "@mantine/core";
|
||||
import { usePlans } from "@app/hooks/usePlans";
|
||||
|
||||
interface TrialStatus {
|
||||
isTrialing: boolean;
|
||||
trialEnd: string;
|
||||
daysRemaining: number;
|
||||
hasPaymentMethod: boolean;
|
||||
hasScheduledSub: boolean;
|
||||
}
|
||||
|
||||
export function ManageBillingButton({
|
||||
returnUrl = typeof window !== "undefined" ? window.location.href : "/",
|
||||
children = "Manage billing",
|
||||
trialStatus,
|
||||
}: {
|
||||
returnUrl?: string;
|
||||
children?: React.ReactNode;
|
||||
trialStatus?: TrialStatus;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const { data } = usePlans();
|
||||
|
||||
// Hide for free plan users
|
||||
if (!data || data.currentPlan.id === "free") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide for trial users who haven't scheduled a subscription yet
|
||||
if (trialStatus?.isTrialing && !trialStatus.hasScheduledSub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke<{
|
||||
url: string;
|
||||
error?: string;
|
||||
}>("manage-billing", {
|
||||
body: {
|
||||
name: "Functions",
|
||||
return_url: returnUrl,
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || "error" in data)
|
||||
throw new Error(data?.error ?? "No portal URL");
|
||||
window.location.href = data.url;
|
||||
} catch (e: unknown) {
|
||||
setErr(e instanceof Error ? e.message : "Could not open billing portal");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-black text-white"
|
||||
>
|
||||
{loading ? "Opening…" : children}
|
||||
</Button>
|
||||
{err && <div className="mt-2 text-red-600">{err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS override of the OSS PrivateContent wrapper.
|
||||
* Adds both the PostHog no-capture class and the Userback opt-out class
|
||||
* while keeping the same API and layout behavior (display: contents).
|
||||
*/
|
||||
export const PrivateContent: React.FC<PrivateContentProps> = ({
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const baseClass = "ph-no-capture userback-block";
|
||||
const combinedClassName = className ? `${baseClass} ${className}` : baseClass;
|
||||
const combinedStyle = {
|
||||
display: "contents" as const,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={combinedClassName} style={combinedStyle} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Button, Text, Alert, Loader, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import {
|
||||
EmbeddedCheckoutProvider,
|
||||
EmbeddedCheckout,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);
|
||||
|
||||
export type PurchaseType = "subscription" | "credits";
|
||||
export type CreditsPack = "xsmall" | "small" | "medium" | "large" | null;
|
||||
export type PlanID = "pro" | null;
|
||||
|
||||
interface StripeCheckoutProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
// Saas-specific props
|
||||
planId?: PlanID;
|
||||
purchaseType?: PurchaseType;
|
||||
creditsPack?: CreditsPack;
|
||||
planName?: string;
|
||||
planPrice?: number;
|
||||
currency?: string;
|
||||
isTrialConversion?: boolean;
|
||||
// Proprietary-specific props (for compatibility)
|
||||
planGroup?: unknown;
|
||||
minimumSeats?: number;
|
||||
onLicenseActivated?: (licenseInfo: {
|
||||
licenseType: string;
|
||||
enabled: boolean;
|
||||
maxUsers: number;
|
||||
hasKey: boolean;
|
||||
}) => void;
|
||||
hostedCheckoutSuccess?: {
|
||||
isUpgrade: boolean;
|
||||
licenseKey?: string;
|
||||
} | null;
|
||||
// Common props
|
||||
onSuccess?: (sessionId: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
type CheckoutState = {
|
||||
status: "idle" | "loading" | "ready" | "success" | "error";
|
||||
clientSecret?: string;
|
||||
error?: string;
|
||||
sessionParams?: {
|
||||
purchaseType: PurchaseType;
|
||||
planId: PlanID;
|
||||
creditsPack: CreditsPack;
|
||||
};
|
||||
};
|
||||
|
||||
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planId,
|
||||
purchaseType,
|
||||
creditsPack,
|
||||
planName,
|
||||
isTrialConversion,
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: "idle" });
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
try {
|
||||
setState({ status: "loading" });
|
||||
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"create-checkout",
|
||||
{
|
||||
body: {
|
||||
purchase_type: purchaseType,
|
||||
ui_mode: "embedded",
|
||||
plan: planId,
|
||||
credits_pack: creditsPack,
|
||||
callback_base_url: window.location.origin,
|
||||
trial_conversion: isTrialConversion || false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to create checkout session");
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data received from server");
|
||||
}
|
||||
|
||||
const jsonData = typeof data === "string" ? JSON.parse(data) : data;
|
||||
|
||||
if (!jsonData?.clientSecret) {
|
||||
throw new Error("No client secret received from server");
|
||||
}
|
||||
|
||||
setState({
|
||||
status: "ready",
|
||||
clientSecret: jsonData.clientSecret,
|
||||
sessionParams: {
|
||||
purchaseType: purchaseType!,
|
||||
planId: planId!,
|
||||
creditsPack: creditsPack!,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to create checkout session";
|
||||
setState({
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentComplete = () => {
|
||||
setState({ status: "success" });
|
||||
|
||||
// Call success callback immediately - parent will handle timing
|
||||
onSuccess?.("");
|
||||
|
||||
// Note: Parent (Plan.tsx) now handles the delay and modal closing
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state to idle to clean up the session
|
||||
setState({
|
||||
status: "idle",
|
||||
clientSecret: undefined,
|
||||
error: undefined,
|
||||
sessionParams: undefined,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Initialize checkout when modal opens or parameters change
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
// Check if we need a new session (first time or parameters changed)
|
||||
const needsNewSession =
|
||||
state.status === "idle" ||
|
||||
!state.sessionParams ||
|
||||
state.sessionParams.purchaseType !== purchaseType ||
|
||||
state.sessionParams.planId !== planId ||
|
||||
state.sessionParams.creditsPack !== creditsPack;
|
||||
|
||||
if (needsNewSession) {
|
||||
console.log("Creating new checkout session:", {
|
||||
purchaseType,
|
||||
planId,
|
||||
creditsPack,
|
||||
});
|
||||
createCheckoutSession();
|
||||
}
|
||||
} else if (!opened) {
|
||||
// Clean up state when modal closes
|
||||
setState({
|
||||
status: "idle",
|
||||
clientSecret: undefined,
|
||||
error: undefined,
|
||||
sessionParams: undefined,
|
||||
});
|
||||
}
|
||||
}, [opened, purchaseType, planId, creditsPack]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (state.status) {
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t("payment.preparing", "Preparing your checkout...")}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "ready":
|
||||
if (!state.clientSecret) return null;
|
||||
|
||||
return (
|
||||
<EmbeddedCheckoutProvider
|
||||
key={state.clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: state.clientSecret,
|
||||
onComplete: handlePaymentComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
);
|
||||
|
||||
case "success":
|
||||
return (
|
||||
<Alert
|
||||
color="green"
|
||||
title={t("payment.success", "Payment Successful!")}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"payment.successMessage",
|
||||
"Your plan has been upgraded successfully. You will receive a confirmation email shortly.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"payment.autoClose",
|
||||
"This window will close automatically...",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<Alert color="red" title={t("payment.error", "Payment Error")}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{state.error}</Text>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName })}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
size="xl"
|
||||
centered
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeCheckout;
|
||||
export { StripeCheckout };
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Modal, Stack, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
|
||||
interface TrialExpiredModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubscribe: () => void;
|
||||
}
|
||||
|
||||
export function TrialExpiredModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSubscribe,
|
||||
}: TrialExpiredModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use CSS variables for theme colors
|
||||
const amberColor =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-amber-500")
|
||||
.trim() || "#f59e0b";
|
||||
const redColor =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-red-500")
|
||||
.trim() || "#ef4444";
|
||||
const gradientStops: [string, string] = [amberColor, redColor];
|
||||
|
||||
const circles = [
|
||||
{
|
||||
position: "bottom-left" as const,
|
||||
size: 270, // 16.875rem
|
||||
color: "rgba(255, 255, 255, 0.25)",
|
||||
opacity: 0.9,
|
||||
amplitude: 24, // 1.5rem
|
||||
duration: 4.5,
|
||||
offsetX: 18, // 1.125rem
|
||||
offsetY: 14, // 0.875rem
|
||||
},
|
||||
{
|
||||
position: "top-right" as const,
|
||||
size: 300, // 18.75rem
|
||||
color: "rgba(255, 255, 255, 0.2)",
|
||||
opacity: 0.9,
|
||||
amplitude: 28, // 1.75rem
|
||||
duration: 4.5,
|
||||
delay: 0.5,
|
||||
offsetX: 24, // 1.5rem
|
||||
offsetY: 18, // 1.125rem
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}} // Prevent closing by clicking outside or ESC
|
||||
withCloseButton={false}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: {
|
||||
overflow: "hidden",
|
||||
border: "none",
|
||||
background: "var(--bg-surface)",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={0}
|
||||
className={styles.modalContent}
|
||||
style={{
|
||||
height: "100%",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={gradientStops}
|
||||
circles={circles}
|
||||
isActive
|
||||
slideKey="trial-expired"
|
||||
/>
|
||||
<div className={styles.heroLogo}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<div className={`${styles.title} ${styles.titleText}`}>
|
||||
{t("plan.trial.expired", "Your Trial Has Ended")}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{t(
|
||||
"plan.trial.expiredMessage",
|
||||
"Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{t(
|
||||
"plan.trial.freeTierLimitations",
|
||||
"Free tier includes basic PDF tools with usage limits.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<style>{`
|
||||
@media (max-width: 30rem) {
|
||||
.trial-button-container {
|
||||
justify-content: center !important;
|
||||
}
|
||||
.trial-modal-button {
|
||||
flex: 1 1 100% !important;
|
||||
}
|
||||
}
|
||||
.trial-modal-button-primary:hover {
|
||||
background: linear-gradient(135deg, var(--color-amber-600), var(--color-red-600)) !important;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className="trial-button-container"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.75rem",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="trial-modal-button"
|
||||
style={{
|
||||
fontSize: "0.8125rem",
|
||||
padding: "0.5rem 1rem",
|
||||
height: "auto",
|
||||
minWidth: "8.125rem",
|
||||
flex: "0 1 auto",
|
||||
border: "0",
|
||||
}}
|
||||
>
|
||||
{t("plan.trial.continueWithFree", "Continue with Free")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onSubscribe}
|
||||
size="md"
|
||||
className="trial-modal-button trial-modal-button-primary"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))",
|
||||
color: "#FFFFFF",
|
||||
fontSize: "0.9375rem",
|
||||
fontWeight: 600,
|
||||
padding: "0.75rem 1.5rem",
|
||||
height: "auto",
|
||||
border: "none",
|
||||
minWidth: "10.625rem",
|
||||
flex: "0 1 auto",
|
||||
}}
|
||||
>
|
||||
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useBanner } from "@app/contexts/BannerContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import StripeCheckout from "@app/components/shared/StripeCheckoutSaas";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
|
||||
const SESSION_STORAGE_KEY = "trialBannerDismissed";
|
||||
|
||||
export function TrialStatusBanner() {
|
||||
const { setBanner } = useBanner();
|
||||
const { t } = useTranslation();
|
||||
const { trialStatus } = useAuth();
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
return sessionStorage.getItem(SESSION_STORAGE_KEY) === "true";
|
||||
});
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
|
||||
// Only show banner during ACTIVE trial (not after expiration - modal handles that)
|
||||
// Don't show if payment method already added (user has scheduled subscription)
|
||||
const shouldShowBanner =
|
||||
trialStatus &&
|
||||
trialStatus.isTrialing && // Only show during active trial
|
||||
trialStatus.daysRemaining > 0 && // Trial hasn't expired yet
|
||||
!trialStatus.hasPaymentMethod &&
|
||||
!trialStatus.hasScheduledSub &&
|
||||
!dismissed;
|
||||
|
||||
if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) {
|
||||
console.log("Subscription scheduled - hiding trial banner");
|
||||
}
|
||||
|
||||
const handleOpenCheckout = useCallback(() => {
|
||||
setCheckoutOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setDismissed(true);
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, "true");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowBanner) {
|
||||
setBanner(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(
|
||||
"en-GB",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
const message = t(
|
||||
"plan.trial.message",
|
||||
`Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? "s" : ""} (${trialEndDate}). Subscribe to continue Pro access.`,
|
||||
{ days: trialStatus.daysRemaining, date: trialEndDate },
|
||||
);
|
||||
|
||||
const logoIcon = (
|
||||
<img
|
||||
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||
alt="Stirling PDF"
|
||||
style={{
|
||||
width: "1.5rem",
|
||||
height: "1.5rem",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
setBanner(
|
||||
<InfoBanner
|
||||
icon={logoIcon}
|
||||
tone="info"
|
||||
message={message}
|
||||
buttonText={t("plan.trial.subscribe", "Subscribe to Pro")}
|
||||
buttonIcon="credit-card-rounded"
|
||||
onButtonClick={handleOpenCheckout}
|
||||
onDismiss={handleDismiss}
|
||||
dismissible={true}
|
||||
show={true}
|
||||
background="var(--mantine-color-dark-7)"
|
||||
borderColor="var(--mantine-color-dark-5)"
|
||||
textColor="rgba(255, 255, 255, 0.95)"
|
||||
iconColor="rgba(255, 255, 255, 0.95)"
|
||||
buttonColor="gray"
|
||||
buttonVariant="white"
|
||||
buttonTextColor="var(--mantine-color-dark-9)"
|
||||
closeIconColor="rgba(255, 255, 255, 0.7)"
|
||||
/>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
setBanner(null);
|
||||
};
|
||||
}, [
|
||||
shouldShowBanner,
|
||||
trialStatus,
|
||||
setBanner,
|
||||
t,
|
||||
handleOpenCheckout,
|
||||
handleDismiss,
|
||||
]);
|
||||
|
||||
const handleCheckoutSuccess = () => {
|
||||
// Refresh to hide banner and show updated plan
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{trialStatus && (
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen}
|
||||
onClose={() => setCheckoutOpen(false)}
|
||||
purchaseType="subscription"
|
||||
planId="pro"
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) => console.error("Checkout error:", error)}
|
||||
isTrialConversion={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import React, { useEffect, useMemo, useRef, useCallback, useId } from "react";
|
||||
import { Group, Loader, Text } from "@mantine/core";
|
||||
import * as d3 from "d3";
|
||||
import {
|
||||
StackedBarChartProps,
|
||||
TooltipData,
|
||||
FractionData,
|
||||
} from "@app/types/charts";
|
||||
import { generateTooltipHTML } from "@app/components/shared/charts/stackedBarChart/StackedBarTooltip";
|
||||
import {
|
||||
detectTheme,
|
||||
getChartThemeVars,
|
||||
} from "@app/components/shared/charts/utils/themeUtils";
|
||||
import { createTooltipPositioner } from "@app/components/shared/charts/utils/tooltipUtils";
|
||||
import {
|
||||
createRoundedRectPath,
|
||||
createScale,
|
||||
} from "@app/components/shared/charts/utils/d3Utils";
|
||||
|
||||
export default function StackedBarChart({
|
||||
fractions,
|
||||
width = 640,
|
||||
height = 22,
|
||||
showLegend = true,
|
||||
className = "",
|
||||
tooltipPosition = "top",
|
||||
loading = false,
|
||||
animate = true,
|
||||
animationDurationMs = 900,
|
||||
ariaLabel,
|
||||
}: StackedBarChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasAnimatedRef = useRef(false);
|
||||
const reactId = useId();
|
||||
const clipId = useMemo(() => `clip-${reactId}`, [reactId]);
|
||||
|
||||
// Memoize theme detection to avoid recalculation
|
||||
const theme = useMemo(() => detectTheme(), []);
|
||||
const themeVars = useMemo(
|
||||
() => getChartThemeVars(theme.isDark),
|
||||
[theme.isDark],
|
||||
);
|
||||
|
||||
// Memoize tooltip positioner
|
||||
const tooltipPositioner = useMemo(
|
||||
() => createTooltipPositioner(tooltipPosition),
|
||||
[tooltipPosition],
|
||||
);
|
||||
|
||||
const positionTooltip = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!tooltip) return;
|
||||
tooltipPositioner.positionTooltip(event, tooltip, containerRef.current!);
|
||||
},
|
||||
[tooltipPositioner],
|
||||
);
|
||||
|
||||
const setTooltipContent = useCallback(
|
||||
(labelHtml: string) => {
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!tooltip) return;
|
||||
tooltip.innerHTML = labelHtml;
|
||||
tooltip.style.background = themeVars.background;
|
||||
tooltip.style.color = themeVars.textPrimary;
|
||||
tooltip.style.border = themeVars.border;
|
||||
tooltip.style.boxShadow = themeVars.boxShadow;
|
||||
tooltip.style.padding = "8px 10px";
|
||||
tooltip.style.fontSize = "12px";
|
||||
tooltip.style.lineHeight = "1.25";
|
||||
tooltip.style.borderRadius = "8px";
|
||||
},
|
||||
[themeVars],
|
||||
);
|
||||
|
||||
const hideTooltip = useCallback(() => {
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!tooltip) return;
|
||||
tooltip.style.opacity = "0";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
// Calculate total capacity (sum of all denominators)
|
||||
const totalCapacity = fractions.reduce(
|
||||
(sum: number, fraction: FractionData) => sum + fraction.denominator,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalCapacity === 0 && !loading) return;
|
||||
|
||||
// Create data for the bar segments
|
||||
const data = fractions.map((fraction: FractionData) => ({
|
||||
...fraction,
|
||||
value: fraction.numerator,
|
||||
remaining: fraction.denominator - fraction.numerator,
|
||||
}));
|
||||
|
||||
const radius = 8;
|
||||
|
||||
const svg = d3
|
||||
.select(container)
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", height)
|
||||
.attr("viewBox", `0 0 ${width} ${height}`)
|
||||
.attr("role", ariaLabel ? "img" : null)
|
||||
.attr("aria-label", ariaLabel || null);
|
||||
|
||||
const x = createScale([0, totalCapacity], [0, width]);
|
||||
let cursor = 0;
|
||||
const g = svg.append("g");
|
||||
|
||||
// Skip drawing the bar visuals entirely when loading to avoid gray bar under spinner
|
||||
if (!loading) {
|
||||
// Create a single rounded rectangle for the entire bar background
|
||||
const totalBarWidth = x(totalCapacity);
|
||||
g.append("rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", totalBarWidth)
|
||||
.attr("height", height)
|
||||
.attr("rx", radius)
|
||||
.attr("ry", radius)
|
||||
.attr("fill", themeVars.inactive)
|
||||
.attr("stroke", themeVars.cardBorder);
|
||||
|
||||
// Define a clipPath that will reveal the used portion from left to right
|
||||
const defs = svg.append("defs");
|
||||
const clipRect = defs
|
||||
.append("clipPath")
|
||||
.attr("id", clipId)
|
||||
.append("rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", 0)
|
||||
.attr("height", height)
|
||||
.attr("rx", radius)
|
||||
.attr("ry", radius);
|
||||
|
||||
// Group to hold the used segments and apply clip-path
|
||||
const usedGroup = svg.append("g").attr("clip-path", `url(#${clipId})`);
|
||||
|
||||
// Render used segments on top of the background
|
||||
data.forEach((fraction: (typeof data)[number], index: number) => {
|
||||
if (fraction.value <= 0) return;
|
||||
|
||||
const segWidth = x(fraction.value);
|
||||
const xPos = cursor;
|
||||
cursor += segWidth;
|
||||
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === data.length - 1; // Last segment regardless of remaining
|
||||
|
||||
if (isFirst && isLast) {
|
||||
// Single segment: fully rounded
|
||||
usedGroup
|
||||
.append("rect")
|
||||
.attr("x", xPos)
|
||||
.attr("y", 0)
|
||||
.attr("width", segWidth)
|
||||
.attr("height", height)
|
||||
.attr("rx", radius)
|
||||
.attr("ry", radius)
|
||||
.attr("fill", fraction.color);
|
||||
} else if (isFirst) {
|
||||
// First segment: rounded on left side only
|
||||
const path = createRoundedRectPath(
|
||||
xPos,
|
||||
0,
|
||||
segWidth,
|
||||
height,
|
||||
radius,
|
||||
{
|
||||
topLeft: true,
|
||||
topRight: false,
|
||||
bottomLeft: true,
|
||||
bottomRight: false,
|
||||
},
|
||||
);
|
||||
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
|
||||
} else if (isLast) {
|
||||
// Last segment: rounded on right side only
|
||||
const path = createRoundedRectPath(
|
||||
xPos,
|
||||
0,
|
||||
segWidth,
|
||||
height,
|
||||
radius,
|
||||
{
|
||||
topLeft: false,
|
||||
topRight: true,
|
||||
bottomLeft: false,
|
||||
bottomRight: true,
|
||||
},
|
||||
);
|
||||
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
|
||||
} else {
|
||||
// Middle segments: no rounded edges
|
||||
usedGroup
|
||||
.append("rect")
|
||||
.attr("x", xPos)
|
||||
.attr("y", 0)
|
||||
.attr("width", segWidth)
|
||||
.attr("height", height)
|
||||
.attr("fill", fraction.color);
|
||||
}
|
||||
});
|
||||
|
||||
// Add a transparent overlay for hover events on the entire bar (outside clip path)
|
||||
svg
|
||||
.append("rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", totalBarWidth)
|
||||
.attr("height", height)
|
||||
.attr("rx", radius)
|
||||
.attr("ry", radius)
|
||||
.attr("fill", "transparent")
|
||||
.style("pointer-events", "all")
|
||||
.on("mouseenter", (event: MouseEvent) => {
|
||||
const tooltipData: TooltipData = {
|
||||
fractions: data,
|
||||
isDark: theme.isDark,
|
||||
};
|
||||
const html = generateTooltipHTML(tooltipData);
|
||||
setTooltipContent(html);
|
||||
const tooltip = tooltipRef.current;
|
||||
if (tooltip) tooltip.style.opacity = "1";
|
||||
positionTooltip(event as unknown as MouseEvent);
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) =>
|
||||
positionTooltip(event as unknown as MouseEvent),
|
||||
)
|
||||
.on("mouseleave", hideTooltip);
|
||||
|
||||
// Animate reveal of used segments (only on first load, not on re-renders)
|
||||
const totalUsed = data.reduce(
|
||||
(sum: number, f: (typeof data)[number]) => sum + f.value,
|
||||
0,
|
||||
);
|
||||
const revealTo = x(totalUsed);
|
||||
if (animate && !hasAnimatedRef.current) {
|
||||
clipRect
|
||||
.transition()
|
||||
.duration(animationDurationMs)
|
||||
.attr("width", revealTo);
|
||||
hasAnimatedRef.current = true;
|
||||
} else {
|
||||
clipRect.attr("width", revealTo);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
container.innerHTML = "";
|
||||
};
|
||||
}, [
|
||||
fractions,
|
||||
width,
|
||||
height,
|
||||
tooltipPosition,
|
||||
loading,
|
||||
animate,
|
||||
animationDurationMs,
|
||||
clipId,
|
||||
themeVars,
|
||||
setTooltipContent,
|
||||
hideTooltip,
|
||||
positionTooltip,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<div ref={containerRef} />
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
pointerEvents: "none",
|
||||
opacity: 0,
|
||||
transition: "opacity 120ms ease",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Loader size="sm" color="blue" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showLegend && (
|
||||
<Group gap="lg" mt="sm">
|
||||
{fractions.map((fraction: FractionData, index: number) => (
|
||||
<Group key={index} gap={6}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: fraction.color,
|
||||
display: "inline-block",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">{fraction.name}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group gap={6}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: themeVars.inactive,
|
||||
display: "inline-block",
|
||||
borderRadius: 2,
|
||||
outline: `1px solid ${themeVars.cardBorder}`,
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">Remaining</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { TooltipData } from "@app/types/charts";
|
||||
|
||||
interface StackedBarTooltipProps {
|
||||
data: TooltipData;
|
||||
}
|
||||
|
||||
export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
|
||||
const { fractions } = data;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{fractions.map((f, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: "flex", gap: "8px", alignItems: "center" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
background: f.color,
|
||||
borderRadius: "2px",
|
||||
}}
|
||||
></span>
|
||||
<span>
|
||||
<strong>{f.name}</strong> — {f.numeratorLabel}: {f.numerator} ·{" "}
|
||||
{f.denominatorLabel}: {f.denominator - f.numerator}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateTooltipHTML(data: TooltipData): string {
|
||||
const { fractions } = data;
|
||||
|
||||
return `
|
||||
<div style="display:flex;flex-direction:column;gap:6px;white-space:nowrap;">
|
||||
${fractions
|
||||
.map(
|
||||
(f) => `
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<span style="display:inline-block;width:10px;height:10px;background:${f.color};border-radius:2px"></span>
|
||||
<span><strong>${f.name}</strong> — ${f.numeratorLabel}: ${f.numerator} · ${f.denominatorLabel}: ${Math.max(0, f.denominator - f.numerator)}</span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Reusable D3 utility functions for chart creation
|
||||
*/
|
||||
|
||||
import * as d3 from "d3";
|
||||
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
margin?: {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnimationConfig {
|
||||
duration: number;
|
||||
easing?: (t: number) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a basic SVG element with proper attributes
|
||||
* @param container The container element
|
||||
* @param dimensions Chart dimensions
|
||||
* @param className Optional CSS class name
|
||||
* @returns The created SVG selection
|
||||
*/
|
||||
export function createSVG(
|
||||
container: HTMLElement,
|
||||
dimensions: ChartDimensions,
|
||||
className?: string,
|
||||
): d3.Selection<SVGSVGElement, unknown, null, undefined> {
|
||||
const svg = d3
|
||||
.select(container)
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", dimensions.height)
|
||||
.attr("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`)
|
||||
.attr("class", className || "");
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a clip path for revealing content with animation
|
||||
* @param svg The SVG selection
|
||||
* @param clipId Unique ID for the clip path
|
||||
* @param dimensions Chart dimensions
|
||||
* @returns The clip rect selection
|
||||
*/
|
||||
export function createClipPath(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
clipId: string,
|
||||
dimensions: ChartDimensions,
|
||||
): d3.Selection<SVGRectElement, unknown, null, undefined> {
|
||||
const defs = svg.append("defs");
|
||||
const clipRect = defs
|
||||
.append("clipPath")
|
||||
.attr("id", clipId)
|
||||
.append("rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", 0)
|
||||
.attr("height", dimensions.height);
|
||||
|
||||
return clipRect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates a clip path to reveal content
|
||||
* @param clipRect The clip rect selection
|
||||
* @param targetWidth The target width to animate to
|
||||
* @param config Animation configuration
|
||||
*/
|
||||
export function animateClipReveal(
|
||||
clipRect: d3.Selection<SVGRectElement, unknown, null, undefined>,
|
||||
targetWidth: number,
|
||||
config: AnimationConfig,
|
||||
): void {
|
||||
clipRect
|
||||
.transition()
|
||||
.duration(config.duration)
|
||||
.ease(config.easing || d3.easeCubicInOut)
|
||||
.attr("width", targetWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a rounded rectangle path for D3
|
||||
* @param x X position
|
||||
* @param y Y position
|
||||
* @param width Width
|
||||
* @param height Height
|
||||
* @param radius Corner radius
|
||||
* @param corners Which corners to round (default: all)
|
||||
* @returns SVG path string
|
||||
*/
|
||||
export function createRoundedRectPath(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
corners: {
|
||||
topLeft?: boolean;
|
||||
topRight?: boolean;
|
||||
bottomLeft?: boolean;
|
||||
bottomRight?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const {
|
||||
topLeft = true,
|
||||
topRight = true,
|
||||
bottomLeft = true,
|
||||
bottomRight = true,
|
||||
} = corners;
|
||||
|
||||
if (width <= 0 || height <= 0) return "";
|
||||
|
||||
const topLeftRadius = topLeft ? radius : 0;
|
||||
const topRightRadius = topRight ? radius : 0;
|
||||
const bottomRightRadius = bottomRight ? radius : 0;
|
||||
const bottomLeftRadius = bottomLeft ? radius : 0;
|
||||
|
||||
let path = `M ${x + topLeftRadius} ${y}`;
|
||||
|
||||
if (topRight) {
|
||||
path += ` L ${x + width - topRightRadius} ${y}`;
|
||||
path += ` A ${topRightRadius} ${topRightRadius} 0 0 1 ${x + width} ${y + topRightRadius}`;
|
||||
} else {
|
||||
path += ` L ${x + width} ${y}`;
|
||||
}
|
||||
|
||||
if (bottomRight) {
|
||||
path += ` L ${x + width} ${y + height - bottomRightRadius}`;
|
||||
path += ` A ${bottomRightRadius} ${bottomRightRadius} 0 0 1 ${x + width - bottomRightRadius} ${y + height}`;
|
||||
} else {
|
||||
path += ` L ${x + width} ${y + height}`;
|
||||
}
|
||||
|
||||
if (bottomLeft) {
|
||||
path += ` L ${x + bottomLeftRadius} ${y + height}`;
|
||||
path += ` A ${bottomLeftRadius} ${bottomLeftRadius} 0 0 1 ${x} ${y + height - bottomLeftRadius}`;
|
||||
} else {
|
||||
path += ` L ${x} ${y + height}`;
|
||||
}
|
||||
|
||||
if (topLeft) {
|
||||
path += ` L ${x} ${y + topLeftRadius}`;
|
||||
path += ` A ${topLeftRadius} ${topLeftRadius} 0 0 1 ${x + topLeftRadius} ${y}`;
|
||||
} else {
|
||||
path += ` L ${x} ${y}`;
|
||||
}
|
||||
|
||||
return path + " Z";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reusable scale factory
|
||||
* @param domain Domain values
|
||||
* @param range Range values
|
||||
* @returns D3 scale function
|
||||
*/
|
||||
export function createScale(domain: [number, number], range: [number, number]) {
|
||||
return d3.scaleLinear().domain(domain).range(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounces a function call
|
||||
* @param func The function to debounce
|
||||
* @param wait The wait time in milliseconds
|
||||
* @returns Debounced function
|
||||
*/
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Utility functions for theme detection and styling in D3 charts
|
||||
*/
|
||||
|
||||
export interface ThemeInfo {
|
||||
isDark: boolean;
|
||||
schemeAttr: string | null | undefined;
|
||||
prefersDark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the current theme from various sources
|
||||
* @returns ThemeInfo object with theme detection results
|
||||
*/
|
||||
export function detectTheme(): ThemeInfo {
|
||||
const rootEl =
|
||||
typeof document !== "undefined" ? document.documentElement : null;
|
||||
const schemeAttr = rootEl?.getAttribute("data-mantine-color-scheme");
|
||||
const prefersDark =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
const isDark = schemeAttr ? schemeAttr === "dark" : prefersDark;
|
||||
|
||||
return {
|
||||
isDark,
|
||||
schemeAttr,
|
||||
prefersDark,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets CSS custom properties for chart styling
|
||||
* @param isDark Whether the theme is dark
|
||||
* @returns Object with CSS custom property values
|
||||
*/
|
||||
export function getChartThemeVars(isDark: boolean) {
|
||||
return {
|
||||
background: "var(--bg-surface)",
|
||||
textPrimary: "var(--text-primary)",
|
||||
border: isDark ? "1px solid var(--border-subtle)" : "1px solid transparent",
|
||||
boxShadow: isDark ? "none" : "var(--shadow-md)",
|
||||
inactive: "var(--usage-inactive)",
|
||||
cardBorder: "var(--api-keys-card-border)",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies consistent tooltip styling
|
||||
* @param tooltipElement The tooltip DOM element
|
||||
* @param isDark Whether the theme is dark
|
||||
*/
|
||||
export function applyTooltipStyles(
|
||||
tooltipElement: HTMLElement,
|
||||
isDark: boolean,
|
||||
) {
|
||||
const themeVars = getChartThemeVars(isDark);
|
||||
|
||||
tooltipElement.style.background = themeVars.background;
|
||||
tooltipElement.style.color = themeVars.textPrimary;
|
||||
tooltipElement.style.border = themeVars.border;
|
||||
tooltipElement.style.boxShadow = themeVars.boxShadow;
|
||||
tooltipElement.style.padding = "8px 10px";
|
||||
tooltipElement.style.fontSize = "12px";
|
||||
tooltipElement.style.lineHeight = "1.25";
|
||||
tooltipElement.style.borderRadius = "8px";
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Utility functions for tooltip positioning and management in D3 charts
|
||||
*/
|
||||
|
||||
export type TooltipPosition = "top" | "bottom" | "left" | "right";
|
||||
|
||||
export interface TooltipPositioner {
|
||||
positionTooltip: (
|
||||
event: MouseEvent,
|
||||
tooltip: HTMLElement,
|
||||
container: HTMLElement,
|
||||
) => void;
|
||||
hideTooltip: (tooltip: HTMLElement) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tooltip positioner for the specified position
|
||||
* @param position The tooltip position preference
|
||||
* @returns TooltipPositioner object with positioning functions
|
||||
*/
|
||||
export function createTooltipPositioner(
|
||||
position: TooltipPosition,
|
||||
): TooltipPositioner {
|
||||
const positionTooltip = (
|
||||
event: MouseEvent,
|
||||
tooltip: HTMLElement,
|
||||
container: HTMLElement,
|
||||
) => {
|
||||
const bounds = container.getBoundingClientRect();
|
||||
const offsetX = event.clientX - bounds.left;
|
||||
const offsetY = event.clientY - bounds.top;
|
||||
|
||||
// Get tooltip dimensions after content is set
|
||||
const tooltipHeight = tooltip.offsetHeight;
|
||||
const tooltipWidth = tooltip.offsetWidth;
|
||||
const gap = 16; // 1rem gap
|
||||
|
||||
// Position tooltip based on the specified position
|
||||
switch (position) {
|
||||
case "top":
|
||||
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
|
||||
tooltip.style.top = `${offsetY - tooltipHeight - gap}px`;
|
||||
break;
|
||||
case "bottom":
|
||||
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
|
||||
tooltip.style.top = `${offsetY + gap}px`;
|
||||
break;
|
||||
case "left":
|
||||
tooltip.style.left = `${Math.max(10, offsetX - tooltipWidth - gap)}px`;
|
||||
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
|
||||
break;
|
||||
case "right":
|
||||
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, offsetX + gap)}px`;
|
||||
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const hideTooltip = (tooltip: HTMLElement) => {
|
||||
tooltip.style.opacity = "0";
|
||||
};
|
||||
|
||||
return { positionTooltip, hideTooltip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reusable tooltip element with consistent styling
|
||||
* @param container The container element to append the tooltip to
|
||||
* @returns The created tooltip element
|
||||
*/
|
||||
export function createTooltipElement(container: HTMLElement): HTMLElement {
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.style.position = "absolute";
|
||||
tooltip.style.left = "0";
|
||||
tooltip.style.top = "0";
|
||||
tooltip.style.pointerEvents = "none";
|
||||
tooltip.style.opacity = "0";
|
||||
tooltip.style.transition = "opacity 120ms ease";
|
||||
tooltip.style.zIndex = "1000";
|
||||
|
||||
container.appendChild(tooltip);
|
||||
return tooltip;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Modal, Button, Stack, Slider, Alert, Text, Box } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Cropper from "react-easy-crop";
|
||||
import { getCroppedImage, type Area } from "@app/utils/cropImage";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface ProfilePictureCropperProps {
|
||||
file: File | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCropComplete: (croppedBlob: Blob) => void;
|
||||
}
|
||||
|
||||
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
file,
|
||||
opened,
|
||||
onClose,
|
||||
onCropComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State management
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load image when file changes
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setImageSrc(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImageSrc(reader.result as string);
|
||||
setError(null);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError(
|
||||
t(
|
||||
"config.account.profilePicture.cropper.invalidImage",
|
||||
"Invalid image file. Please select a valid PNG, JPG, or WebP file.",
|
||||
),
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc);
|
||||
}
|
||||
};
|
||||
}, [file, t]);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
setProcessing(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Called when crop area changes
|
||||
const onCropChange = useCallback((newCrop: { x: number; y: number }) => {
|
||||
setCrop(newCrop);
|
||||
}, []);
|
||||
|
||||
// Called when zoom changes
|
||||
const onZoomChange = useCallback((newZoom: number) => {
|
||||
setZoom(newZoom);
|
||||
}, []);
|
||||
|
||||
// Called when crop is complete (stores the crop area in pixels)
|
||||
const onCropCompleteCallback = useCallback(
|
||||
(_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Process and save the cropped image
|
||||
const handleSave = async () => {
|
||||
if (!imageSrc || !croppedAreaPixels) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Crop the image
|
||||
const croppedBlob = await getCroppedImage(imageSrc, croppedAreaPixels);
|
||||
|
||||
// Validate size (2MB limit)
|
||||
const maxSize = 2 * 1024 * 1024; // 2MB in bytes
|
||||
if (croppedBlob.size > maxSize) {
|
||||
setError(
|
||||
t(
|
||||
"config.account.profilePicture.cropper.sizeErrorAfterCrop",
|
||||
"Cropped image is too large. Please zoom out or crop a smaller area.",
|
||||
),
|
||||
);
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call parent callback with cropped blob
|
||||
onCropComplete(croppedBlob);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Error cropping image:", err);
|
||||
setError(
|
||||
t(
|
||||
"config.account.profilePicture.cropper.cropError",
|
||||
"Failed to crop image. Please try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t(
|
||||
"config.account.profilePicture.cropper.title",
|
||||
"Crop Profile Picture",
|
||||
)}
|
||||
size="lg"
|
||||
centered
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{error && (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Cropper area */}
|
||||
<Box style={{ position: "relative", width: "100%", height: 400 }}>
|
||||
{imageSrc && (
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
onCropChange={onCropChange}
|
||||
onZoomChange={onZoomChange}
|
||||
onCropComplete={onCropCompleteCallback}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("config.account.profilePicture.cropper.zoom", "Zoom")}
|
||||
</Text>
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={setZoom}
|
||||
disabled={processing}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}
|
||||
>
|
||||
<Button variant="subtle" onClick={onClose} disabled={processing}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={processing}>
|
||||
{processing
|
||||
? t(
|
||||
"config.account.profilePicture.cropper.processing",
|
||||
"Processing crop...",
|
||||
)
|
||||
: t(
|
||||
"config.account.profilePicture.cropper.save",
|
||||
"Save Cropped Image",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from "react";
|
||||
import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
|
||||
import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection";
|
||||
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
|
||||
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
|
||||
import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits";
|
||||
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
|
||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
export default function ApiKeys() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [showRefreshModal, setShowRefreshModal] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
|
||||
const { data: credits, isLoading: creditsLoading } = useCredits();
|
||||
const {
|
||||
apiKey,
|
||||
isLoading: apiKeyLoading,
|
||||
refresh,
|
||||
isRefreshing,
|
||||
error: apiKeyError,
|
||||
refetch,
|
||||
hasAttempted,
|
||||
} = useApiKey();
|
||||
|
||||
const copy = async (text: string, tag: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(tag);
|
||||
setTimeout(() => setCopied(null), 1600);
|
||||
} catch (e) {
|
||||
// noop – you can surface a notification here
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshKeys = async () => {
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setShowRefreshModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goToAccount = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("appConfig:navigate", { detail: { key: "overview" } }),
|
||||
);
|
||||
};
|
||||
|
||||
const showUsage = Boolean(credits);
|
||||
|
||||
return (
|
||||
<Stack gap={20} p={0}>
|
||||
{showUsage && (
|
||||
<UsageSection
|
||||
apiUsage={credits!}
|
||||
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
|
||||
overlayMessage={t(
|
||||
"config.apiKeys.overlayMessage",
|
||||
"Generate a key to see credits and available credits",
|
||||
)}
|
||||
loading={creditsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isAnonymous && apiKeyError && (
|
||||
<Text size="sm" c="red.5">
|
||||
{t(
|
||||
"config.apiKeys.generateError",
|
||||
"We couldn't generate your API key.",
|
||||
)}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
underline="always"
|
||||
onClick={refetch}
|
||||
c="red.4"
|
||||
>
|
||||
{t("common.retry", "Retry")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isAnonymous ? (
|
||||
<Paper
|
||||
radius="md"
|
||||
p={18}
|
||||
style={{
|
||||
background: "var(--api-keys-card-bg)",
|
||||
border: "1px solid var(--api-keys-card-border)",
|
||||
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={10}>
|
||||
<Text fw={500}>{t("config.apiKeys.label", "API Key")}</Text>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{ gap: "1rem" }}
|
||||
>
|
||||
<Text size="sm" c="dimmed" style={{ flex: 1 }}>
|
||||
{t(
|
||||
"config.apiKeys.guestInfo",
|
||||
"Guest users do not receive API keys. Create an account to get an API key you can use in your applications.",
|
||||
)}
|
||||
</Text>
|
||||
<Button size="sm" onClick={goToAccount}>
|
||||
{t("config.apiKeys.goToAccount", "Go to Account")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : apiKeyLoading ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" style={{ marginBottom: 8 }}>
|
||||
{t(
|
||||
"config.apiKeys.description",
|
||||
"Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.",
|
||||
)}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
padding: 18,
|
||||
borderRadius: 12,
|
||||
background: "var(--api-keys-card-bg)",
|
||||
border: "1px solid var(--api-keys-card-border)",
|
||||
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Group align="center" gap={12} wrap="nowrap">
|
||||
<SkeletonLoader type="block" width="100%" height={36} />
|
||||
<SkeletonLoader type="block" width={76} height={32} />
|
||||
<SkeletonLoader type="block" width={92} height={32} />
|
||||
</Group>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ApiKeySection
|
||||
publicKey={apiKey ?? ""}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onRefresh={() => setShowRefreshModal(true)}
|
||||
disabled={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RefreshModal
|
||||
opened={showRefreshModal}
|
||||
onClose={() => setShowRefreshModal(false)}
|
||||
onConfirm={refreshKeys}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Modal,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import {
|
||||
isUserAnonymous,
|
||||
linkEmailIdentity,
|
||||
linkOAuthIdentity,
|
||||
supabase,
|
||||
} from "@app/auth/supabase";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { oauthProviders } from "@app/constants/authProviders";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { absoluteWithBasePath } from "@app/constants/app";
|
||||
import { synchronizeUserUpgrade } from "@app/services/userService";
|
||||
import { ProfilePictureCropper } from "@app/components/shared/config/ProfilePictureCropper";
|
||||
import { updateProfilePictureMetadata } from "@app/services/avatarSyncService";
|
||||
import { deleteCurrentAccount } from "@app/services/accountDeletion";
|
||||
import { alert as showToast } from "@app/components/toast";
|
||||
|
||||
interface OverviewProps {
|
||||
onLogoutClick: () => void;
|
||||
}
|
||||
|
||||
const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
user,
|
||||
refreshSession,
|
||||
signOut,
|
||||
profilePictureUrl,
|
||||
profilePictureMetadata,
|
||||
refreshProfilePicture,
|
||||
refreshProfilePictureMetadata,
|
||||
} = useAuth();
|
||||
|
||||
const PROFILE_BUCKET = "profile-pictures";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [upgradeError, setUpgradeError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [profileUploading, setProfileUploading] = useState(false);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [cropperFile, setCropperFile] = useState<File | null>(null);
|
||||
const [cropperOpen, setCropperOpen] = useState(false);
|
||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [confirmEmail, setConfirmEmail] = useState("");
|
||||
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const isOAuthPicture = profilePictureMetadata?.source === "oauth";
|
||||
const provider = profilePictureMetadata?.provider;
|
||||
|
||||
const profilePath = user ? `${user.id}/avatar` : null;
|
||||
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U";
|
||||
|
||||
const handleProfileUpload = async (file: File | null) => {
|
||||
if (!file || !user || !profilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setProfileError(
|
||||
t(
|
||||
"config.account.profilePicture.sizeError",
|
||||
"Please select an image smaller than 2MB.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open cropper instead of uploading directly
|
||||
setProfileError(null);
|
||||
setCropperFile(file);
|
||||
setCropperOpen(true);
|
||||
};
|
||||
|
||||
const handleCropComplete = async (croppedBlob: Blob) => {
|
||||
if (!user || !profilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate cropped size (2MB limit)
|
||||
if (croppedBlob.size > 2 * 1024 * 1024) {
|
||||
setProfileError(
|
||||
t(
|
||||
"config.account.profilePicture.sizeError",
|
||||
"Please select an image smaller than 2MB.",
|
||||
),
|
||||
);
|
||||
setCropperOpen(false);
|
||||
setCropperFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileUploading(true);
|
||||
setProfileError(null);
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.upload(profilePath, croppedBlob, {
|
||||
upsert: true,
|
||||
cacheControl: "3600",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setProfileError(error.message || "Failed to upload profile picture");
|
||||
} else {
|
||||
// Mark as manual upload in metadata
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: "upload",
|
||||
provider: null,
|
||||
});
|
||||
await refreshProfilePictureMetadata();
|
||||
await refreshProfilePicture();
|
||||
}
|
||||
|
||||
setProfileUploading(false);
|
||||
setCropperOpen(false);
|
||||
setCropperFile(null);
|
||||
};
|
||||
|
||||
const handleProfileRemove = async () => {
|
||||
if (!user || !profilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileUploading(true);
|
||||
setProfileError(null);
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.remove([profilePath]);
|
||||
|
||||
if (error) {
|
||||
setProfileError(error.message || "Failed to remove profile picture");
|
||||
} else {
|
||||
// Clear metadata when removing picture
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: "upload",
|
||||
provider: null,
|
||||
});
|
||||
await refreshProfilePictureMetadata();
|
||||
await refreshProfilePicture();
|
||||
}
|
||||
|
||||
setProfileUploading(false);
|
||||
};
|
||||
|
||||
const handleUseCustomPicture = async () => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileUploading(true);
|
||||
setProfileError(null);
|
||||
|
||||
try {
|
||||
// Update metadata to allow manual uploads
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: "upload",
|
||||
provider: null,
|
||||
});
|
||||
|
||||
await refreshProfilePictureMetadata();
|
||||
setSuccess(
|
||||
t(
|
||||
"config.account.profilePicture.switchedToCustom",
|
||||
"Switched to custom picture. You can now upload your own.",
|
||||
),
|
||||
);
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (error: unknown) {
|
||||
setProfileError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to switch to custom picture",
|
||||
);
|
||||
} finally {
|
||||
setProfileUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailUpgrade = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
setUpgradeError("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setUpgradeError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// First, upgrade the account in Supabase
|
||||
await linkEmailIdentity(email.trim(), password || undefined);
|
||||
|
||||
// Synchronize with backend database (using "email" as auth method for email/password)
|
||||
await synchronizeUserUpgrade("email");
|
||||
|
||||
// Refresh the session to reflect changes
|
||||
await refreshSession();
|
||||
|
||||
setSuccess(
|
||||
"Account upgraded successfully! You can now sign in with your email.",
|
||||
);
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
} catch (err: unknown) {
|
||||
setUpgradeError(
|
||||
err instanceof Error ? err.message : "Failed to upgrade account",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthUpgrade = async (
|
||||
provider: "github" | "google" | "apple" | "azure",
|
||||
) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setUpgradeError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// Store provider info for post-redirect handling
|
||||
sessionStorage.setItem("pendingUpgrade", "true");
|
||||
sessionStorage.setItem("upgradeProvider", provider);
|
||||
|
||||
// Redirect back to homepage after OAuth completes
|
||||
// The UseSession hook will handle the pendingUpgrade synchronization
|
||||
const redirectUrl = absoluteWithBasePath("/auth/callback?next=/");
|
||||
const result = await linkOAuthIdentity(provider, redirectUrl);
|
||||
|
||||
if (result.data?.url) {
|
||||
window.location.href = result.data.url;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Failed to upgrade account with ${provider}`;
|
||||
setUpgradeError(errorMessage);
|
||||
setIsLoading(false);
|
||||
sessionStorage.removeItem("pendingUpgrade");
|
||||
sessionStorage.removeItem("upgradeProvider");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (isAnonymous) return;
|
||||
try {
|
||||
setIsDeletingAccount(true);
|
||||
await deleteCurrentAccount();
|
||||
setDeleteModalOpen(false);
|
||||
setConfirmEmail("");
|
||||
await signOut();
|
||||
window.location.href = absoluteWithBasePath("/login");
|
||||
} catch (err) {
|
||||
const fallbackMessage = t(
|
||||
"config.account.overview.deleteFailed",
|
||||
"Failed to delete account.",
|
||||
);
|
||||
const message = err instanceof Error ? err.message : fallbackMessage;
|
||||
console.error("[Overview] Delete account failed:", err);
|
||||
showToast({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"config.account.overview.deleteFailedTitle",
|
||||
"Unable to delete account",
|
||||
),
|
||||
body: message,
|
||||
expandable: true,
|
||||
location: "top-right",
|
||||
durationMs: 7000,
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
setDeleteModalOpen(false);
|
||||
setConfirmEmail("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<LoadingOverlay visible={isLoading || isDeletingAccount} />
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("config.account.overview.title", "Account Settings")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 0 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{isAnonymous
|
||||
? t(
|
||||
"config.account.overview.guestDescription",
|
||||
"You are signed in as a guest. Consider upgrading your account below.",
|
||||
)
|
||||
: t(
|
||||
"config.account.overview.manageAccountPreferences",
|
||||
"Manage your account preferences",
|
||||
)}
|
||||
</p>
|
||||
{user?.email && (
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 0 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{t("config.account.overview.signedInAs", "Signed in as")}:{" "}
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button color="red" variant="filled" onClick={onLogoutClick}>
|
||||
{t("logOut", "Log out")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("config.account.profilePicture.title", "Profile picture")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"config.account.profilePicture.description",
|
||||
"Upload an image to personalize your account.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
{profileError && (
|
||||
<Alert color="red" mb="md">
|
||||
{profileError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert color="green" mb="md">
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isOAuthPicture ? (
|
||||
<Group align="center" gap="md">
|
||||
<Avatar
|
||||
src={profilePictureUrl || undefined}
|
||||
radius="xl"
|
||||
size={72}
|
||||
color="blue"
|
||||
>
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"config.account.profilePicture.usingProvider",
|
||||
"Using {{provider}} profile picture",
|
||||
{
|
||||
provider: provider
|
||||
? provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
: "OAuth",
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUseCustomPicture}
|
||||
disabled={profileUploading}
|
||||
>
|
||||
{t(
|
||||
"config.account.profilePicture.useCustom",
|
||||
"Use custom picture",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group align="center" gap="md">
|
||||
<Avatar
|
||||
src={profilePictureUrl || undefined}
|
||||
radius="xl"
|
||||
size={72}
|
||||
color="blue"
|
||||
>
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm">
|
||||
<FileButton
|
||||
onChange={handleProfileUpload}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={!user || profileUploading}
|
||||
>
|
||||
{(props) => (
|
||||
<Button {...props} loading={profileUploading}>
|
||||
{t("config.account.profilePicture.upload", "Upload")}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleProfileRemove}
|
||||
disabled={!profilePictureUrl || profileUploading}
|
||||
>
|
||||
{t("config.account.profilePicture.remove", "Remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="var(--mantine-color-dimmed)">
|
||||
{t(
|
||||
"config.account.profilePicture.help",
|
||||
"PNG, JPG, or WebP up to 2MB.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProfilePictureCropper
|
||||
file={cropperFile}
|
||||
opened={cropperOpen}
|
||||
onClose={() => {
|
||||
setCropperOpen(false);
|
||||
setCropperFile(null);
|
||||
}}
|
||||
onCropComplete={handleCropComplete}
|
||||
/>
|
||||
|
||||
{isAnonymous && <Divider />}
|
||||
|
||||
{isAnonymous && (
|
||||
<div>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("config.account.upgrade.title", "Upgrade Guest Account")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"config.account.upgrade.description",
|
||||
"Link your account to preserve your history and access more features!",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{upgradeError && (
|
||||
<Alert color="red" mb="md">
|
||||
{upgradeError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert color="green" mb="md">
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
|
||||
{t(
|
||||
"config.account.upgrade.socialLogin",
|
||||
"Upgrade with Social Account",
|
||||
)}
|
||||
</Text>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{oauthProviders
|
||||
.filter((provider) => !provider.isDisabled)
|
||||
.map((provider) => (
|
||||
<Tooltip
|
||||
key={provider.id}
|
||||
content={`${t("config.account.upgrade.linkWith", "Link with")} ${provider.label}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<Image
|
||||
src={`${BASE_PATH}/Login/${provider.file}`}
|
||||
alt={provider.label}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
}
|
||||
onClick={() =>
|
||||
handleOAuthUpgrade(
|
||||
provider.id as
|
||||
| "github"
|
||||
| "google"
|
||||
| "apple"
|
||||
| "azure",
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{provider.label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
|
||||
{t(
|
||||
"config.account.upgrade.emailPassword",
|
||||
"or enter your email & password",
|
||||
)}
|
||||
</Text>
|
||||
<form onSubmit={handleEmailUpgrade}>
|
||||
<Group align="end" gap="sm">
|
||||
<TextInput
|
||||
label={t("config.account.upgrade.email", "Email")}
|
||||
placeholder={t(
|
||||
"config.account.upgrade.emailPlaceholder",
|
||||
"Enter your email",
|
||||
)}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"config.account.upgrade.password",
|
||||
"Password (optional)",
|
||||
)}
|
||||
placeholder={t(
|
||||
"config.account.upgrade.passwordPlaceholder",
|
||||
"Set a password",
|
||||
)}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
description={t(
|
||||
"config.account.upgrade.passwordNote",
|
||||
"Leave empty to use email verification only",
|
||||
)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{t("config.account.upgrade.upgradeButton", "Upgrade Account")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account Section */}
|
||||
{!isAnonymous && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
paddingTop: "1.5rem",
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
>
|
||||
{t("config.account.overview.deleteAccount", "Delete Account")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation Modal */}
|
||||
<Modal
|
||||
opened={deleteModalOpen}
|
||||
onClose={closeDeleteModal}
|
||||
title={t(
|
||||
"config.account.overview.deleteAccountTitle",
|
||||
"Delete Account",
|
||||
)}
|
||||
centered
|
||||
zIndex={10000}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) {
|
||||
handleDeleteAccount();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text size="sm" mb="md">
|
||||
{t(
|
||||
"config.account.overview.deleteWarning",
|
||||
"This action is permanent and cannot be undone. All your data will be deleted.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t(
|
||||
"config.account.overview.enterEmailConfirm",
|
||||
"To confirm deletion, please type your email address ({{email}}) below:",
|
||||
{ email: user?.email },
|
||||
)}
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder={user?.email || ""}
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
mb="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeDeleteModal} type="button">
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={
|
||||
confirmEmail.toLowerCase() !== user?.email?.toLowerCase()
|
||||
}
|
||||
type="submit"
|
||||
loading={isDeletingAccount}
|
||||
>
|
||||
{t("config.account.overview.confirmDelete", "Delete My Account")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Alert,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
const PasswordSecurity: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [didUpdate, setDidUpdate] = useState(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!newPassword || !confirmPassword) {
|
||||
setError(t("signup.pleaseFillAllFields", "Please fill in all fields"));
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError(
|
||||
t(
|
||||
"signup.passwordTooShort",
|
||||
"Password must be at least 6 characters long",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("signup.passwordsDoNotMatch", "Passwords do not match"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// Update to the new password directly
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password: newPassword,
|
||||
});
|
||||
if (updateError) {
|
||||
setError(updateError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(
|
||||
t(
|
||||
"login.passwordUpdatedSuccess",
|
||||
"Your password has been updated successfully.",
|
||||
),
|
||||
);
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setDidUpdate(true);
|
||||
|
||||
// Replace form with success text, then close after 2s
|
||||
setTimeout(() => {
|
||||
// refresh session after closing to avoid UI jank
|
||||
void refreshSession();
|
||||
setOpened(false);
|
||||
setDidUpdate(false);
|
||||
}, 2000);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to change password");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("config.account.security.title", "Passwords & Security")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"config.account.security.description",
|
||||
"Manage your password and security settings.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setOpened(true)} variant="filled">
|
||||
{t("config.account.security.changePassword", "Change password")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
centered
|
||||
title={t("config.account.security.changePassword", "Change password")}
|
||||
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
|
||||
>
|
||||
{error && (
|
||||
<Alert color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{didUpdate ? (
|
||||
<Alert color="green" mb="md">
|
||||
{success ||
|
||||
t(
|
||||
"login.passwordUpdatedSuccess",
|
||||
"Your password has been updated successfully.",
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
|
||||
>
|
||||
<PasswordInput
|
||||
label={t("account.newPassword", "New Password")}
|
||||
placeholder={t("account.newPassword", "New Password")}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("account.confirmNewPassword", "Confirm New Password")}
|
||||
placeholder={t(
|
||||
"account.confirmNewPassword",
|
||||
"Confirm New Password",
|
||||
)}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Divider my="sm" />
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => setOpened(false)}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleChangePassword}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("config.account.security.update", "Update password")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordSecurity;
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
|
||||
import { usePlans, PlanTier } from "@app/hooks/usePlans";
|
||||
import StripeCheckout, {
|
||||
PurchaseType,
|
||||
CreditsPack,
|
||||
PlanID,
|
||||
} from "@app/components/shared/StripeCheckoutSaas";
|
||||
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
|
||||
import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection";
|
||||
import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
const Plan: React.FC = () => {
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
|
||||
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
|
||||
const [purchaseType, setPurchaseType] =
|
||||
useState<PurchaseType>("subscription");
|
||||
const [selectedCreditsPack, setSelectedCreditsPack] =
|
||||
useState<CreditsPack>(null);
|
||||
const [currency, setCurrency] = useState<string>("gbp");
|
||||
const { trialStatus } = useAuth();
|
||||
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
|
||||
{ value: "usd", label: "US dollar (USD, $)" },
|
||||
{ value: "inr", label: "Indian rupee (INR, ₹)" },
|
||||
{ value: "brl", label: "Brazilian real (BRL, R$)" },
|
||||
{ value: "eur", label: "Euro (EUR, €)" },
|
||||
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
|
||||
{ value: "gbp", label: "British pound (GBP, £)" },
|
||||
];
|
||||
|
||||
const handleUpgradeClick = useCallback(
|
||||
(plan: PlanTier) => {
|
||||
if (!data) return;
|
||||
|
||||
if (plan.isContactOnly) {
|
||||
// Open contact form or redirect to contact page
|
||||
window.open(
|
||||
"mailto:[email protected]?subject=Enterprise Plan Inquiry",
|
||||
"_blank",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.id !== data.currentPlan.id) {
|
||||
setSelectedPlan(plan);
|
||||
setPurchaseType("subscription");
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const handleCreditPurchaseClick = useCallback(
|
||||
(creditsPack: CreditsPack) => {
|
||||
if (!data) return;
|
||||
|
||||
setSelectedCreditsPack(creditsPack);
|
||||
setPurchaseType("credits");
|
||||
setSelectedPlan(null);
|
||||
setCheckoutOpen(true);
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const handlePaymentSuccess = useCallback(
|
||||
(sessionId: string) => {
|
||||
console.log("Payment successful, session:", sessionId);
|
||||
|
||||
// Update local state immediately - no page reload needed
|
||||
if (selectedPlan && purchaseType === "subscription") {
|
||||
updateCurrentPlan(selectedPlan.id);
|
||||
}
|
||||
|
||||
// Close modal after brief delay to show success message
|
||||
setTimeout(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, 2000);
|
||||
},
|
||||
[selectedPlan, purchaseType, updateCurrentPlan],
|
||||
);
|
||||
|
||||
const handlePaymentError = useCallback((error: string) => {
|
||||
console.error("Payment error:", error);
|
||||
// Error is already displayed in the StripeCheckout component
|
||||
}, []);
|
||||
|
||||
const handleCheckoutClose = useCallback(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, []);
|
||||
|
||||
const handleAddPaymentClick = useCallback(() => {
|
||||
if (!data) return;
|
||||
|
||||
// Find Pro plan from available plans
|
||||
const proPlan = Array.from(data.plans.values()).find(
|
||||
(plan) => plan.id === "pro",
|
||||
);
|
||||
|
||||
if (proPlan) {
|
||||
setSelectedPlan(proPlan);
|
||||
setPurchaseType("subscription");
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Check URL parameters for action=add-payment
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("action") === "add-payment") {
|
||||
handleAddPaymentClick();
|
||||
// Clean up URL
|
||||
params.delete("action");
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}, [data, handleAddPaymentClick]);
|
||||
|
||||
// Early returns after all hooks are called
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title="Error loading plans">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="yellow" title="No data available">
|
||||
Plans data is not available at the moment.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } =
|
||||
data;
|
||||
const plansArray = Array.from(plans.values());
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
|
||||
{/* Currency Selector */}
|
||||
<div>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text size="lg" fw={600}>
|
||||
Currency
|
||||
</Text>
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={(value) => setCurrency(value || "gbp")}
|
||||
data={currencyOptions}
|
||||
searchable
|
||||
clearable={true}
|
||||
w={300}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<ActivePlanSection
|
||||
currentPlan={currentPlan}
|
||||
_activeSince={activeSince}
|
||||
_nextBillingDate={nextBillingDate}
|
||||
trialStatus={trialStatus ?? undefined}
|
||||
onAddPaymentClick={handleAddPaymentClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plansArray}
|
||||
currentPlan={currentPlan}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ApiPackagesSection
|
||||
apiPackages={apiPackages}
|
||||
selectedCredits={selectedCredits}
|
||||
onSelectedCreditsChange={setSelectedCredits}
|
||||
onCreditPurchaseClick={handleCreditPurchaseClick}
|
||||
/>
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
<StripeCheckout
|
||||
opened={
|
||||
checkoutOpen &&
|
||||
(selectedPlan !== null || selectedCreditsPack !== null)
|
||||
}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={
|
||||
purchaseType === "subscription" ? (selectedPlan?.id as PlanID) : null
|
||||
}
|
||||
creditsPack={purchaseType === "credits" ? selectedCreditsPack : null}
|
||||
planName={
|
||||
purchaseType === "subscription"
|
||||
? selectedPlan?.name || ""
|
||||
: data?.apiPackages.find((pkg) => pkg.id === selectedCreditsPack)
|
||||
?.name || ""
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={
|
||||
trialStatus?.isTrialing && purchaseType === "subscription"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Plan;
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import React from "react";
|
||||
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
||||
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
|
||||
import { FractionData } from "@app/types/charts";
|
||||
import { ApiCredits as ApiUsage } from "@app/types/credits";
|
||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||
import { formatUTC } from "@app/components/shared/utils/date";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Using shared ApiCredits type as ApiUsage
|
||||
|
||||
interface UsageSectionProps {
|
||||
apiUsage: ApiUsage;
|
||||
obscured?: boolean;
|
||||
overlayMessage?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function UsageSection({
|
||||
apiUsage,
|
||||
obscured,
|
||||
overlayMessage,
|
||||
loading,
|
||||
}: UsageSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const weeklyUsed =
|
||||
apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
|
||||
const boughtUsed =
|
||||
apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
|
||||
|
||||
// Totals for overall usage visualization
|
||||
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
|
||||
|
||||
const formatDate = (iso: string, withTime: boolean) =>
|
||||
formatUTC(iso, withTime);
|
||||
|
||||
// Prepare data for the stacked bar chart
|
||||
const fractions: FractionData[] = [
|
||||
{
|
||||
name: t("config.apiKeys.includedCredits", "Included credits"),
|
||||
numerator: Math.max(0, weeklyUsed),
|
||||
denominator: Math.max(0, apiUsage.weeklyCreditsAllocated),
|
||||
numeratorLabel: t("common.used", "used"),
|
||||
denominatorLabel: t("common.available", "available"),
|
||||
color: "var(--usage-weekly-active)",
|
||||
},
|
||||
{
|
||||
name: t("config.apiKeys.purchasedCredits", "Purchased credits"),
|
||||
numerator: Math.max(0, boughtUsed),
|
||||
denominator: Math.max(0, apiUsage.totalBoughtCredits),
|
||||
numeratorLabel: t("common.used", "used"),
|
||||
denominatorLabel: t("common.available", "available"),
|
||||
color: "var(--usage-bought-active)",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Paper
|
||||
radius="md"
|
||||
p={18}
|
||||
style={{
|
||||
background: "var(--api-keys-card-bg)",
|
||||
border: "1px solid var(--api-keys-card-border)",
|
||||
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={12}
|
||||
style={{
|
||||
fontFamily:
|
||||
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>
|
||||
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={40} height={14} />
|
||||
) : (
|
||||
totalRemaining
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<StackedBarChart
|
||||
fractions={fractions}
|
||||
width={640}
|
||||
height={22}
|
||||
showLegend={true}
|
||||
tooltipPosition="top"
|
||||
loading={Boolean(loading)}
|
||||
animate={!loading}
|
||||
animationDurationMs={900}
|
||||
ariaLabel={t("config.apiKeys.chartAriaLabel", {
|
||||
includedUsed: Math.max(0, weeklyUsed),
|
||||
includedTotal: Math.max(0, apiUsage.weeklyCreditsAllocated),
|
||||
purchasedUsed: Math.max(0, boughtUsed),
|
||||
purchasedTotal: Math.max(0, apiUsage.totalBoughtCredits),
|
||||
defaultValue:
|
||||
"Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
|
||||
})}
|
||||
/>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("config.apiKeys.nextReset", "Next Reset")}:{" "}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={120} height={12} />
|
||||
) : (
|
||||
formatDate(apiUsage.weeklyResetDate, false)
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("config.apiKeys.lastApiUse", "Last API Use")}:{" "}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={160} height={12} />
|
||||
) : (
|
||||
formatDate(apiUsage.lastApiUsage, true)
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{obscured && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
padding: 16,
|
||||
color: "var(--mantine-color-text)",
|
||||
background: "rgba(16, 18, 27, 0.55)",
|
||||
backdropFilter: "blur(6px)",
|
||||
WebkitBackdropFilter: "blur(6px)",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{overlayMessage ||
|
||||
t(
|
||||
"config.apiKeys.overlayMessage",
|
||||
"Generate a key to see credits and available credits",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
type ApiKeyResponse = string | { apiKey?: string };
|
||||
|
||||
export function useApiKey() {
|
||||
const { session, loading, user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
||||
|
||||
const fetchKey = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Backend is POST for get and update
|
||||
const res = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/get-api-key",
|
||||
);
|
||||
const value = typeof res.data === "string" ? res.data : res.data.apiKey;
|
||||
if (typeof value === "string") setApiKey(value);
|
||||
} catch (e: unknown) {
|
||||
// If not found, try to create one by calling update endpoint
|
||||
if (isAxiosError(e) && e.response?.status === 404) {
|
||||
try {
|
||||
const createRes = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/update-api-key",
|
||||
);
|
||||
const created =
|
||||
typeof createRes.data === "string"
|
||||
? createRes.data
|
||||
: createRes.data.apiKey;
|
||||
if (typeof created === "string") setApiKey(created);
|
||||
} catch (createErr: unknown) {
|
||||
setError(
|
||||
createErr instanceof Error
|
||||
? createErr
|
||||
: new Error(String(createErr)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setHasAttempted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/update-api-key",
|
||||
);
|
||||
const value = typeof res.data === "string" ? res.data : res.data.apiKey;
|
||||
if (typeof value === "string") setApiKey(value);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && session && !hasAttempted && !isAnonymous) {
|
||||
fetchKey();
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchKey]);
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
error,
|
||||
refetch: fetchKey,
|
||||
refresh,
|
||||
hasAttempted,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default useApiKey;
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { ApiCredits } from "@app/types/credits";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
function coerceNumber(value: unknown, fallback = 0): number {
|
||||
const n = typeof value === "string" ? Number(value) : (value as number);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function normalizeCredits(raw: Record<string, unknown>): ApiCredits {
|
||||
// Accept a variety of possible backend keys to be resilient
|
||||
return {
|
||||
weeklyCreditsRemaining: coerceNumber(
|
||||
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left,
|
||||
),
|
||||
weeklyCreditsAllocated: coerceNumber(
|
||||
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total,
|
||||
),
|
||||
boughtCreditsRemaining: coerceNumber(
|
||||
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left,
|
||||
),
|
||||
totalBoughtCredits: coerceNumber(
|
||||
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total,
|
||||
),
|
||||
totalAvailableCredits: coerceNumber(
|
||||
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total,
|
||||
),
|
||||
weeklyResetDate: String(
|
||||
raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? "",
|
||||
),
|
||||
lastApiUsage: String(
|
||||
raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? "",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
const { session, loading, user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const [data, setData] = useState<ApiCredits | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
||||
|
||||
const fetchCredits = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res =
|
||||
await apiClient.get<Record<string, unknown>>("/api/v1/credits");
|
||||
const normalized = normalizeCredits(res.data);
|
||||
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
|
||||
const isEmpty =
|
||||
!normalized.weeklyCreditsAllocated &&
|
||||
!normalized.weeklyCreditsRemaining &&
|
||||
!normalized.totalBoughtCredits &&
|
||||
!normalized.boughtCreditsRemaining &&
|
||||
!normalized.totalAvailableCredits &&
|
||||
!normalized.weeklyResetDate &&
|
||||
!normalized.lastApiUsage;
|
||||
setData(isEmpty ? null : normalized);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setHasAttempted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && session && !hasAttempted && !isAnonymous) {
|
||||
fetchCredits();
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchCredits,
|
||||
hasAttempted,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default useCredits;
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import { Card, Text, Group, Flex, Alert, Button, Badge } from "@mantine/core";
|
||||
import AccessTimeIcon from "@mui/icons-material/AccessTime";
|
||||
import CreditCardIcon from "@mui/icons-material/CreditCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
import { ManageBillingButton } from "@app/components/shared/ManageBillingButton";
|
||||
|
||||
interface TrialStatus {
|
||||
isTrialing: boolean;
|
||||
trialEnd: string;
|
||||
daysRemaining: number;
|
||||
hasPaymentMethod: boolean;
|
||||
hasScheduledSub: boolean;
|
||||
}
|
||||
|
||||
interface ActivePlanSectionProps {
|
||||
currentPlan: PlanTier;
|
||||
_activeSince?: string;
|
||||
_nextBillingDate?: string;
|
||||
trialStatus?: TrialStatus;
|
||||
onAddPaymentClick?: () => void;
|
||||
}
|
||||
|
||||
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
currentPlan,
|
||||
_activeSince,
|
||||
_nextBillingDate,
|
||||
trialStatus,
|
||||
onAddPaymentClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center">
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.activePlan.title", "Active Plan")}
|
||||
</h3>
|
||||
<ManageBillingButton
|
||||
returnUrl={`${window.location.origin}/account`}
|
||||
trialStatus={trialStatus}
|
||||
/>
|
||||
</Flex>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.activePlan.subtitle", "Your current subscription details")}
|
||||
</p>
|
||||
|
||||
{/* Trial Status Alert */}
|
||||
{trialStatus?.isTrialing && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
|
||||
mt="md"
|
||||
mb="md"
|
||||
title={t("plan.trial.title", "Free Trial Active")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", {
|
||||
days: trialStatus.daysRemaining,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("plan.trial.endDate", "Expires: {{date}}", {
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
|
||||
})}
|
||||
</Text>
|
||||
{trialStatus.hasScheduledSub ? (
|
||||
<Text size="xs" c="green" fw={500} mt="sm">
|
||||
✓{" "}
|
||||
{t(
|
||||
"plan.trial.subscriptionScheduled",
|
||||
"Subscription scheduled - starts {{date}}",
|
||||
{
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
onAddPaymentClick && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
mt="sm"
|
||||
onClick={onAddPaymentClick}
|
||||
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text size="lg" fw={600}>
|
||||
{currentPlan.name}
|
||||
</Text>
|
||||
{trialStatus?.isTrialing && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t("plan.trial.badge", "Trial")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{/* {activeSince && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.activeSince', 'Active since {{date}}', { date: activeSince })}
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Text size="xl" fw={700}>
|
||||
{currentPlan.currency}
|
||||
{currentPlan.price}/month
|
||||
</Text>
|
||||
{/* {nextBillingDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })}
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivePlanSection;
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
interface ApiPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
credits: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ApiPackagesSectionProps {
|
||||
apiPackages: ApiPackage[];
|
||||
selectedCredits: number;
|
||||
onSelectedCreditsChange: (value: number) => void;
|
||||
onCreditPurchaseClick: (creditsPack: CreditsPack) => void;
|
||||
}
|
||||
|
||||
const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
apiPackages,
|
||||
selectedCredits,
|
||||
onSelectedCreditsChange,
|
||||
onCreditPurchaseClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.apiPackages.title", "API Credit Packages")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"plan.apiPackages.subtitle",
|
||||
"Purchase API credits for your applications",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Card padding="xl" radius="md" className="mb-4">
|
||||
<Stack gap="xl">
|
||||
{/* Credits Selection */}
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t("plan.selectCredits", "Select Credit Amount")}
|
||||
</Text>
|
||||
|
||||
<div className="px-4">
|
||||
<Slider
|
||||
value={selectedCredits}
|
||||
onChange={onSelectedCreditsChange}
|
||||
onChangeEnd={(value) =>
|
||||
onSelectedCreditsChange(Math.round(value))
|
||||
}
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.01}
|
||||
marks={[
|
||||
{ value: 0, label: "100" },
|
||||
{ value: 1, label: "500" },
|
||||
{ value: 2, label: "1K" },
|
||||
{ value: 3, label: "5K" },
|
||||
]}
|
||||
size="lg"
|
||||
className="mb-6"
|
||||
label={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Package Display */}
|
||||
<Flex gap={"xl"} justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[
|
||||
Math.round(selectedCredits)
|
||||
].credits.toLocaleString()}{" "}
|
||||
Credits
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{apiPackages[Math.round(selectedCredits)].description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[Math.round(selectedCredits)].currency}
|
||||
{apiPackages[Math.round(selectedCredits)].price}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("plan.totalCost", "Total Cost")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() =>
|
||||
onCreditPurchaseClick(
|
||||
apiPackages[Math.round(selectedCredits)].id as CreditsPack,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("plan.purchase", "Purchase")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiPackagesSection;
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Badge, Text, Collapse } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentPlan?: PlanTier;
|
||||
currentLicenseInfo?: unknown;
|
||||
onUpgradeClick: (plan: PlanTier) => void;
|
||||
onManageClick?: (plan: PlanTier) => void;
|
||||
currency?: string;
|
||||
onCurrencyChange?: (currency: string) => void;
|
||||
currencyOptions?: Array<{ value: string; label: string }>;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentPlan,
|
||||
onUpgradeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
const isUserProOrAbove =
|
||||
currentPlan?.id === "pro" || currentPlan?.id === "enterprise";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.availablePlans.title", "Available Plans")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"plan.availablePlans.subtitle",
|
||||
"Choose the plan that fits your needs",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex h-[20rem] mb-4 "
|
||||
style={{ gap: "1rem", overflowX: "auto" }}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrentPlan={plan.id === currentPlan?.id}
|
||||
isUserProOrAbove={isUserProOrAbove}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowComparison(!showComparison)}
|
||||
>
|
||||
{showComparison
|
||||
? t("plan.hideComparison", "Hide Feature Comparison")
|
||||
: t("plan.showComparison", "Compare All Features")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={showComparison}>
|
||||
<Card padding="lg" radius="md" withBorder className="mt-4">
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t("plan.featureComparison", "Feature Comparison")}
|
||||
</Text>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">
|
||||
{t("plan.feature.title", "Feature")}
|
||||
</th>
|
||||
{plans.map((plan) => (
|
||||
<th
|
||||
key={plan.id}
|
||||
className="text-center p-2 min-w-24 relative"
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="filled"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0rem",
|
||||
right: "-2rem",
|
||||
fontSize: "0.5rem",
|
||||
fontWeight: "500",
|
||||
height: "1rem",
|
||||
padding: "0 0.1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans[0].features.map((_, featureIndex) => (
|
||||
<tr key={featureIndex} className="border-b">
|
||||
<td className="p-2">
|
||||
{plans[0].features[featureIndex].name}
|
||||
</td>
|
||||
{plans.map((plan) => (
|
||||
<td key={plan.id} className="text-center p-2">
|
||||
{plan.features[featureIndex].included ? (
|
||||
<Text c="green" fw={600}>
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="gray">-</Text>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailablePlansSection;
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
|
||||
interface PlanCardProps {
|
||||
plan?: PlanTier;
|
||||
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
|
||||
isCurrentPlan?: boolean;
|
||||
isCurrentTier?: boolean;
|
||||
isDowngrade?: boolean;
|
||||
isUserProOrAbove?: boolean;
|
||||
currentLicenseInfo?: unknown;
|
||||
currentTier?: string | null; // Accept null for proprietary compatibility
|
||||
onUpgradeClick?: (plan: PlanTier) => void;
|
||||
onManageClick?: (plan: PlanTier) => void;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({
|
||||
plan: propPlan,
|
||||
planGroup,
|
||||
isCurrentPlan,
|
||||
isCurrentTier: _isCurrentTier,
|
||||
isDowngrade: _isDowngrade,
|
||||
isUserProOrAbove,
|
||||
currentLicenseInfo: _currentLicenseInfo,
|
||||
currentTier: _currentTier,
|
||||
onUpgradeClick,
|
||||
onManageClick: _onManageClick,
|
||||
loginEnabled: _loginEnabled,
|
||||
}) => {
|
||||
// Use plan from props, or extract from planGroup if proprietary is using it
|
||||
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!plan) return null; // Safety check
|
||||
|
||||
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="lg"
|
||||
radius="sm"
|
||||
withBorder
|
||||
className="h-full w-[33%] relative"
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md" className="h-full">
|
||||
<div>
|
||||
<Text size="lg" fw={600}>
|
||||
{plan.name}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="2xl" fw={700}>
|
||||
{plan.isContactOnly
|
||||
? t("plan.customPricing", "Custom")
|
||||
: `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{!plan.isContactOnly && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.period}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight: string, index: number) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div className="flex-grow" />
|
||||
|
||||
{!shouldHideUpgrade && (
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "filled"
|
||||
: plan.isContactOnly
|
||||
? "outline"
|
||||
: "filled"
|
||||
}
|
||||
disabled={isCurrentPlan}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick?.(plan)}
|
||||
>
|
||||
{isCurrentPlan
|
||||
? t("plan.current", "Current Plan")
|
||||
: plan.isContactOnly
|
||||
? t("plan.contact", "Get in Touch")
|
||||
: t("plan.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from "react";
|
||||
import { type TFunction } from "i18next";
|
||||
import {
|
||||
createConfigNavSections as createCoreConfigNavSections,
|
||||
type ConfigNavSection,
|
||||
} from "@core/components/shared/config/configNavSections";
|
||||
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||
import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity";
|
||||
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
|
||||
import Plan from "@app/components/shared/config/configSections/Plan";
|
||||
|
||||
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
|
||||
|
||||
interface CreateSaasConfigNavSectionsOptions {
|
||||
isDev?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
}
|
||||
|
||||
function ensurePreferencesSection(
|
||||
sections: ConfigNavSection[],
|
||||
): ConfigNavSection[] {
|
||||
const preferencesIndex = sections.findIndex(
|
||||
(section) => section.title === "Preferences",
|
||||
);
|
||||
|
||||
if (preferencesIndex === -1) {
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: "Preferences",
|
||||
items: [
|
||||
{
|
||||
key: "general",
|
||||
label: "General",
|
||||
icon: "settings-rounded",
|
||||
component: <GeneralSection />,
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
label: "Keyboard Shortcuts",
|
||||
icon: "keyboard-rounded",
|
||||
component: <HotkeysSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function appendDeveloperSection(
|
||||
sections: ConfigNavSection[],
|
||||
): ConfigNavSection[] {
|
||||
const hasDeveloper = sections.some((section) =>
|
||||
section.items.some(
|
||||
(item) => item.key === "developer" || item.key === "api-keys",
|
||||
),
|
||||
);
|
||||
|
||||
if (hasDeveloper) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: "Developer",
|
||||
items: [
|
||||
{
|
||||
key: "api-keys",
|
||||
label: "API Keys",
|
||||
icon: "key-rounded",
|
||||
component: <ApiKeys />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function appendBillingSection(
|
||||
sections: ConfigNavSection[],
|
||||
t: TFunction<"translation", undefined>,
|
||||
): ConfigNavSection[] {
|
||||
const hasPlan = sections.some((section) =>
|
||||
section.items.some((item) => item.key === "plan"),
|
||||
);
|
||||
|
||||
if (hasPlan) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: "Billing",
|
||||
items: [
|
||||
{
|
||||
key: "plan",
|
||||
label: t("config.plan", "Plan"),
|
||||
icon: "credit-card",
|
||||
component: <Plan />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createSaasConfigNavSections(
|
||||
Overview: OverviewComponent,
|
||||
onLogoutClick: () => void,
|
||||
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
|
||||
): ConfigNavSection[] {
|
||||
const baseSections = createCoreConfigNavSections(false, false, false);
|
||||
|
||||
// Create Account section as the first section with Overview and Passwords & Security
|
||||
const accountSection: ConfigNavSection = {
|
||||
title: t("config.account.overview.title", "Account Settings"),
|
||||
items: [
|
||||
{
|
||||
key: "overview",
|
||||
label: t("config.account.overview.label", "Overview"),
|
||||
icon: "account-circle",
|
||||
component: <Overview onLogoutClick={onLogoutClick} />,
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
label: "Passwords & Security",
|
||||
icon: "lock",
|
||||
component: <PasswordSecurity />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let sections = [accountSection, ...baseSections];
|
||||
|
||||
// Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS
|
||||
sections = sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) =>
|
||||
item.key === "general"
|
||||
? {
|
||||
...item,
|
||||
component: <GeneralSection hideUpdateSection hideAdminBanner />,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}));
|
||||
|
||||
sections = ensurePreferencesSection(sections);
|
||||
sections = appendDeveloperSection(sections);
|
||||
|
||||
if (!isAnonymous) {
|
||||
sections = appendBillingSection(sections, t);
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
console.debug("[AppConfigModal] SaaS navigation sections", sections);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Re-export from core for compatibility
|
||||
// Override VALID_NAV_KEYS to include saas-specific keys
|
||||
export const VALID_NAV_KEYS = [
|
||||
"overview",
|
||||
"password-security",
|
||||
"preferences",
|
||||
"notifications",
|
||||
"connections",
|
||||
"general",
|
||||
"people",
|
||||
"teams",
|
||||
"security",
|
||||
"identity",
|
||||
"plan",
|
||||
"payments",
|
||||
"requests",
|
||||
"developer",
|
||||
"api-keys",
|
||||
"hotkeys",
|
||||
"adminGeneral",
|
||||
"adminSecurity",
|
||||
"adminConnections",
|
||||
"adminPrivacy",
|
||||
"adminDatabase",
|
||||
"adminAdvanced",
|
||||
"adminLegal",
|
||||
"adminPremium",
|
||||
"adminFeatures",
|
||||
"adminPlan",
|
||||
"adminAudit",
|
||||
"adminUsage",
|
||||
"adminEndpoints",
|
||||
"help",
|
||||
] as const;
|
||||
|
||||
// Extend NavKey to include saas-specific keys
|
||||
export type NavKey =
|
||||
| "overview"
|
||||
| "password-security"
|
||||
| "preferences"
|
||||
| "notifications"
|
||||
| "connections"
|
||||
| "general"
|
||||
| "people"
|
||||
| "teams"
|
||||
| "security"
|
||||
| "identity"
|
||||
| "plan"
|
||||
| "payments"
|
||||
| "requests"
|
||||
| "developer"
|
||||
| "api-keys"
|
||||
| "hotkeys"
|
||||
| "adminGeneral"
|
||||
| "adminSecurity"
|
||||
| "adminConnections"
|
||||
| "adminPrivacy"
|
||||
| "adminDatabase"
|
||||
| "adminAdvanced"
|
||||
| "adminLegal"
|
||||
| "adminPremium"
|
||||
| "adminFeatures"
|
||||
| "adminPlan"
|
||||
| "adminAudit"
|
||||
| "adminUsage"
|
||||
| "adminEndpoints"
|
||||
| "help";
|
||||
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
@@ -0,0 +1,12 @@
|
||||
export function formatUTC(iso: string, withTime: boolean): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
const formatted = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
...(withTime ? { hour: "2-digit", minute: "2-digit", hour12: false } : {}),
|
||||
timeZone: "UTC",
|
||||
}).format(date);
|
||||
return withTime ? `${formatted} UTC` : formatted;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/* Toast Container Styles */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-container--top-left {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--top-right {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--bottom-left {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toast-container--bottom-right {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* Toast Item Styles */
|
||||
.toast-item {
|
||||
min-width: 320px;
|
||||
max-width: 560px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Toast Alert Type Colors */
|
||||
.toast-item--success {
|
||||
background: var(--color-green-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-item--error {
|
||||
background: var(--color-red-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-item--warning {
|
||||
background: var(--color-orange-100); /* deeper orange background */
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-orange-300); /* deeper orange border */
|
||||
}
|
||||
|
||||
.toast-item--neutral {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Toast Header Row */
|
||||
.toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-title-container {
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-expand-button {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.toast-expand-button--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.toast-progress-container {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
background: var(--bg-muted);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
height: 100%;
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.toast-progress-bar--success {
|
||||
background: var(--color-green-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--error {
|
||||
background: var(--color-red-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--warning {
|
||||
background: var(--color-orange-400); /* deeper orange for progress */
|
||||
}
|
||||
|
||||
.toast-progress-bar--neutral {
|
||||
background: var(--color-gray-500);
|
||||
}
|
||||
|
||||
/* Toast Body */
|
||||
.toast-body {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Toast Action Button */
|
||||
.toast-action-container {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toast-action-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toast-action-button--success {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-action-button--error {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-action-button--warning {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-orange-300);
|
||||
}
|
||||
|
||||
.toast-action-button--neutral {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
// Re-export all constants from core
|
||||
export * from "@core/constants/app";
|
||||
|
||||
// SaaS-specific overrides
|
||||
// Get base URL with fallback (for use outside React components)
|
||||
export const getBaseUrl = (): string => {
|
||||
// Try to get from window object if set by app config
|
||||
return window.__STIRLING_PDF_BASE_URL__ || window.location.origin;
|
||||
};
|
||||
|
||||
// Helper to set base URL (to be called when app config loads)
|
||||
export const setBaseUrl = (url: string): void => {
|
||||
window.__STIRLING_PDF_BASE_URL__ = url;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export type OAuthProviderId = "google" | "apple" | "github" | "azure";
|
||||
|
||||
export type OAuthProviderMeta = {
|
||||
id: OAuthProviderId;
|
||||
label: string;
|
||||
file: string;
|
||||
isDisabled: boolean;
|
||||
};
|
||||
|
||||
export const oauthProviders: readonly OAuthProviderMeta[] = [
|
||||
{ id: "google", label: "Google", file: "google.svg", isDisabled: false },
|
||||
{ id: "github", label: "GitHub", file: "github.svg", isDisabled: false },
|
||||
] as const;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const devApiLink =
|
||||
"https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/";
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* SaaS stub — core onboarding is suppressed in SaaS builds.
|
||||
* OnboardingBootstrap calls these to clear any pending core tour state.
|
||||
*/
|
||||
export function useOnboarding() {
|
||||
return {
|
||||
clearPendingTourRequest: () => {},
|
||||
setStartAfterToolModeSelection: (_value: boolean) => {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { signInAnonymously, supabase } from "@app/auth/supabase";
|
||||
import { isAuthRoute, isHomeRoute, isToolRoute } from "@app/utils/pathUtils";
|
||||
|
||||
interface AutoAnonymousAuthState {
|
||||
isAutoAuthenticating: boolean;
|
||||
autoAuthError: string | null;
|
||||
shouldTriggerAutoAuth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically signs in users anonymously on direct tool access when not authenticated.
|
||||
*/
|
||||
export function useAutoAnonymousAuth() {
|
||||
const { session, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
const [state, setState] = useState<AutoAnonymousAuthState>({
|
||||
isAutoAuthenticating: false,
|
||||
autoAuthError: null,
|
||||
shouldTriggerAutoAuth: false,
|
||||
});
|
||||
|
||||
const shouldAutoAuthenticate = useCallback((): boolean => {
|
||||
const currentPath = location.pathname;
|
||||
if (isAuthRoute(currentPath)) return false;
|
||||
if (isHomeRoute(currentPath)) return false;
|
||||
return isToolRoute(currentPath);
|
||||
}, [location.pathname]);
|
||||
|
||||
const waitForToken = useCallback(async (timeoutMs = 7000) => {
|
||||
// Prefer event-driven approach; fallback to polling if needed
|
||||
let resolved = false;
|
||||
const done = (ok: boolean) => {
|
||||
resolved = true;
|
||||
return ok;
|
||||
};
|
||||
|
||||
const hasToken = async () => {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
return !!session?.access_token;
|
||||
};
|
||||
|
||||
if (await hasToken()) return true;
|
||||
|
||||
const unsub = supabase.auth.onAuthStateChange(async (_evt, _session) => {
|
||||
if (!resolved && (await hasToken())) {
|
||||
unsub.data.subscription.unsubscribe();
|
||||
done(true);
|
||||
}
|
||||
});
|
||||
|
||||
const started = Date.now();
|
||||
while (!resolved && Date.now() - started < timeoutMs) {
|
||||
if (await hasToken()) {
|
||||
unsub.data.subscription.unsubscribe();
|
||||
return done(true);
|
||||
}
|
||||
// gentle backoff
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
}
|
||||
|
||||
try {
|
||||
unsub.data.subscription.unsubscribe();
|
||||
} catch (err) {
|
||||
// Ignore unsubscribe errors during cleanup
|
||||
console.debug("[useAutoAnonymousAuth] Unsubscribe cleanup error:", err);
|
||||
}
|
||||
return done(false);
|
||||
}, []);
|
||||
|
||||
const triggerAnonymousAuth = useCallback(async () => {
|
||||
if (state.isAutoAuthenticating) return;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAutoAuthenticating: true,
|
||||
autoAuthError: null,
|
||||
}));
|
||||
try {
|
||||
console.log("[useAutoAnonymousAuth] anonymous auth starting");
|
||||
|
||||
const { error } = await signInAnonymously();
|
||||
if (error) throw error;
|
||||
|
||||
// Wait for a usable token so first API calls won't 401/redirect
|
||||
const ok = await waitForToken(7000);
|
||||
if (!ok) {
|
||||
throw new Error("Timed out waiting for anonymous session token");
|
||||
}
|
||||
|
||||
console.log("[useAutoAnonymousAuth] anonymous auth complete");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAutoAuthenticating: false,
|
||||
shouldTriggerAutoAuth: false,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("[useAutoAnonymousAuth] anonymous auth failed", e);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAutoAuthenticating: false,
|
||||
autoAuthError:
|
||||
e instanceof Error ? e.message : "Anonymous authentication failed",
|
||||
}));
|
||||
}
|
||||
}, [state.isAutoAuthenticating, waitForToken]);
|
||||
|
||||
// Decide whether to auto-auth on mount & whenever location/auth changes
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (session) return;
|
||||
if (state.isAutoAuthenticating) return;
|
||||
|
||||
const shouldAuth = shouldAutoAuthenticate();
|
||||
if (state.shouldTriggerAutoAuth !== shouldAuth) {
|
||||
setState((prev) => ({ ...prev, shouldTriggerAutoAuth: shouldAuth }));
|
||||
}
|
||||
|
||||
if (shouldAuth) {
|
||||
console.log("[useAutoAnonymousAuth] tool route detected, auto-auth");
|
||||
triggerAnonymousAuth();
|
||||
}
|
||||
}, [
|
||||
loading,
|
||||
session,
|
||||
state.isAutoAuthenticating,
|
||||
state.shouldTriggerAutoAuth,
|
||||
shouldAutoAuthenticate,
|
||||
triggerAnonymousAuth,
|
||||
]);
|
||||
|
||||
// Clear error if route is no longer a tool route, or once authenticated
|
||||
useEffect(() => {
|
||||
if (session || !shouldAutoAuthenticate()) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
autoAuthError: null,
|
||||
shouldTriggerAutoAuth: false,
|
||||
}));
|
||||
}
|
||||
}, [session, shouldAutoAuthenticate]);
|
||||
|
||||
return {
|
||||
isAutoAuthenticating: state.isAutoAuthenticating,
|
||||
autoAuthError: state.autoAuthError,
|
||||
shouldTriggerAutoAuth: state.shouldTriggerAutoAuth,
|
||||
triggerAnonymousAuth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Avatar } from "@mantine/core";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
export function useConfigButtonIcon(): React.ReactNode {
|
||||
const { profilePictureUrl } = useAuth();
|
||||
return profilePictureUrl ? (
|
||||
<Avatar src={profilePictureUrl} radius="xl" size={24} />
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCredits } from "@app/hooks/useCredits";
|
||||
import { getToolCreditCost } from "@app/utils/creditCosts";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
export function useCreditCheck(operationType?: string, _endpoint?: string) {
|
||||
const { hasSufficientCredits, isPro, creditBalance, refreshCredits } =
|
||||
useCredits();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const checkCredits = useCallback(
|
||||
async (_runtimeEndpoint?: string): Promise<string | null> => {
|
||||
const requiredCredits = getToolCreditCost(operationType as ToolId);
|
||||
const creditCheck = hasSufficientCredits(requiredCredits);
|
||||
|
||||
if (creditBalance === null) {
|
||||
try {
|
||||
await refreshCredits();
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
return t("loadingCredits", "Checking credits...");
|
||||
}
|
||||
|
||||
if (isPro === null) {
|
||||
return t("loadingProStatus", "Checking subscription status...");
|
||||
}
|
||||
|
||||
if (!isPro && !creditCheck.hasSufficientCredits) {
|
||||
const shortfall = creditCheck.shortfall || 0;
|
||||
const error = t(
|
||||
"insufficientCredits",
|
||||
"Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}",
|
||||
{
|
||||
requiredCredits,
|
||||
currentBalance: creditCheck.currentBalance,
|
||||
shortfall,
|
||||
},
|
||||
);
|
||||
const notice = t(
|
||||
"noticeTopUpOrPlan",
|
||||
"Not enough credits, please top up or upgrade to a plan",
|
||||
);
|
||||
openPlanSettings(notice);
|
||||
return error;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[
|
||||
hasSufficientCredits,
|
||||
isPro,
|
||||
creditBalance,
|
||||
refreshCredits,
|
||||
operationType,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return { checkCredits };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
/**
|
||||
* Hook for credit management and checking in tools.
|
||||
* Provides easy access to credit balance, subscription info, and validation functions.
|
||||
*/
|
||||
export const useCredits = () => {
|
||||
const {
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
hasSufficientCredits,
|
||||
updateCredits,
|
||||
refreshCredits,
|
||||
} = useAuth();
|
||||
|
||||
/**
|
||||
* Get user-friendly credit status message
|
||||
*/
|
||||
const getCreditStatusMessage = (): string => {
|
||||
if (creditBalance === 0) {
|
||||
return "No credits remaining";
|
||||
}
|
||||
if (creditBalance === null) {
|
||||
return "Credits loading...";
|
||||
}
|
||||
return `${creditBalance} credits available`;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
|
||||
// Actions
|
||||
refreshCredits,
|
||||
updateCredits,
|
||||
|
||||
// Utilities
|
||||
getCreditStatusMessage,
|
||||
hasSufficientCredits,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
|
||||
|
||||
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
||||
const globalFetchedSets = new Set<string>();
|
||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||
/**
|
||||
* Hook to check if a specific endpoint is enabled
|
||||
* This wraps the context for single endpoint checks
|
||||
*/
|
||||
export function useEndpointEnabled(endpoint: string): {
|
||||
enabled: boolean | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEndpointStatus = async () => {
|
||||
if (!endpoint) {
|
||||
setEnabled(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get<boolean>(
|
||||
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
|
||||
);
|
||||
|
||||
const isEnabled = response.data;
|
||||
setEnabled(isEnabled);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEndpointStatus();
|
||||
}, [endpoint]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchEndpointStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check multiple endpoints at once using batch API
|
||||
* Returns a map of endpoint -> enabled status
|
||||
*/
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [endpointDetails, setEndpointDetails] = useState<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchAllEndpointStatuses = async (force = false) => {
|
||||
const endpointsKey = [...endpoints].sort().join(",");
|
||||
|
||||
// Skip if we already fetched these exact endpoints globally
|
||||
if (!force && globalFetchedSets.has(endpointsKey)) {
|
||||
console.debug(
|
||||
"[useEndpointConfig] Already fetched these endpoints globally, using cache",
|
||||
);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...cached.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check which endpoints we haven't fetched yet
|
||||
const newEndpoints = endpoints.filter(
|
||||
(ep) => !(ep in globalEndpointCache),
|
||||
);
|
||||
if (newEndpoints.length === 0) {
|
||||
console.debug(
|
||||
"[useEndpointConfig] All endpoints already in global cache",
|
||||
);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...cached.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Use batch API for efficiency - only fetch new endpoints
|
||||
const endpointsParam = newEndpoints.join(",");
|
||||
|
||||
const response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(
|
||||
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
|
||||
);
|
||||
const statusMap = response.data;
|
||||
|
||||
// Update global cache with new results
|
||||
Object.entries(statusMap).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
// Get all requested endpoints from cache (including previously cached ones)
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
} catch (err: unknown) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (isAxiosError(err) && err.response?.status === 401) {
|
||||
console.warn(
|
||||
"[useEndpointConfig] 401 error - using optimistic fallback",
|
||||
);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = {
|
||||
enabled: true,
|
||||
reason: null,
|
||||
};
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
globalEndpointCache[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails((prev) => ({
|
||||
...prev,
|
||||
...optimisticStatus.details,
|
||||
}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
setError(errorMessage);
|
||||
console.error(
|
||||
"[EndpointConfig] Failed to check multiple endpoints:",
|
||||
err,
|
||||
);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = {
|
||||
enabled: true,
|
||||
reason: null,
|
||||
};
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...optimisticStatus.details }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllEndpointStatuses();
|
||||
}, [endpoints.join(",")]); // Re-run when endpoints array changes
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: () => fetchAllEndpointStatuses(true),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* SaaS no-op: Supabase handles auth, no JWT event listener needed,
|
||||
* and config is fetched on all pages (401 handling covers unauthenticated state).
|
||||
*/
|
||||
export function useJwtConfigSync(_fetchConfig: (force?: boolean) => void): {
|
||||
isAuthPage: boolean;
|
||||
} {
|
||||
return { isAuthPage: false };
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
// Currency mapping
|
||||
const getCurrencySymbol = (currency: string): string => {
|
||||
const currencySymbols: { [key: string]: string } = {
|
||||
gbp: "£",
|
||||
usd: "$",
|
||||
eur: "€",
|
||||
cny: "¥",
|
||||
inr: "₹",
|
||||
brl: "R$",
|
||||
idr: "Rp",
|
||||
};
|
||||
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
|
||||
};
|
||||
|
||||
export interface PlanFeature {
|
||||
name: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
export interface PlanTier {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
popular?: boolean;
|
||||
features: PlanFeature[];
|
||||
highlights: string[];
|
||||
isContactOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
credits: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PlansData {
|
||||
plans: Map<string, PlanTier>;
|
||||
apiPackages: ApiPackage[];
|
||||
currentPlan: PlanTier;
|
||||
nextBillingDate?: string;
|
||||
activeSince?: string;
|
||||
}
|
||||
|
||||
export const usePlans = (currency: string = "gbp") => {
|
||||
const { t } = useTranslation();
|
||||
const { isPro, refreshProStatus } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPlanId, setCurrentPlanId] = useState<string>("free");
|
||||
const [dynamicPrices, setDynamicPrices] = useState<
|
||||
Map<string, { unit_amount: number; currency: string }>
|
||||
>(new Map());
|
||||
|
||||
const fetchPricing = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const lookupKeys = [
|
||||
"plan:pro",
|
||||
"api:xsmall",
|
||||
"api:small",
|
||||
"api:medium",
|
||||
"api:large",
|
||||
];
|
||||
|
||||
const { data, error } = await supabase.functions.invoke<{
|
||||
prices: Record<string, { unit_amount: number; currency: string }>;
|
||||
missing: string[];
|
||||
}>("stripe-price-lookup", {
|
||||
body: { lookup_keys: lookupKeys, currency },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || !data.prices || !data.missing)
|
||||
throw new Error("No pricing data returned");
|
||||
console.log("Fetched pricing data:", data);
|
||||
|
||||
const priceMap = new Map<
|
||||
string,
|
||||
{ unit_amount: number; currency: string }
|
||||
>();
|
||||
// map your UI keys to lookup keys (if names differ)
|
||||
const keyMap: Record<string, string> = {
|
||||
pro: "plan:pro",
|
||||
xsmall: "api:xsmall",
|
||||
small: "api:small",
|
||||
medium: "api:medium",
|
||||
large: "api:large",
|
||||
};
|
||||
|
||||
for (const [uiKey, lookupKey] of Object.entries(keyMap)) {
|
||||
const p = data?.prices?.[lookupKey];
|
||||
if (p) {
|
||||
priceMap.set(uiKey, {
|
||||
unit_amount: p.unit_amount ?? 0,
|
||||
currency: p.currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.missing.length) {
|
||||
console.warn("Missing prices for", data.missing, "in", currency);
|
||||
// Optionally re-request with a fallback currency (e.g., 'usd')
|
||||
}
|
||||
|
||||
setDynamicPrices(priceMap);
|
||||
} catch (err) {
|
||||
console.error("Error fetching pricing:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to fetch pricing data",
|
||||
);
|
||||
// continue with static prices if needed
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Memoize static plan and package data to prevent recreation on every render
|
||||
const staticPlansData = useMemo(() => {
|
||||
const plans: PlanTier[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: t("plan.free.name", "Free"),
|
||||
price: 0,
|
||||
currency: getCurrencySymbol(currency),
|
||||
period: t("plan.period.month", "/month"),
|
||||
highlights: [
|
||||
t("plan.free.highlight1", "Limited Tool Usage Per week"),
|
||||
t("plan.free.highlight2", "Access to all tools"),
|
||||
t("plan.free.highlight3", "Community support"),
|
||||
],
|
||||
features: [
|
||||
{
|
||||
name: t("plan.feature.pdfTools", "Basic PDF Tools"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.fileSize", "File Size Limit"),
|
||||
included: false,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.automation", "automate tool workflows"),
|
||||
included: false,
|
||||
},
|
||||
{ name: t("plan.feature.api", "API Access"), included: false },
|
||||
{
|
||||
name: t("plan.feature.priority", "Priority Support"),
|
||||
included: false,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.customPricing", "Custom Pricing"),
|
||||
included: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: t("plan.pro.name", "Pro"),
|
||||
price: dynamicPrices.get("pro")
|
||||
? dynamicPrices.get("pro")!.unit_amount / 100
|
||||
: 8,
|
||||
currency: dynamicPrices.get("pro")
|
||||
? getCurrencySymbol(dynamicPrices.get("pro")!.currency)
|
||||
: getCurrencySymbol(currency),
|
||||
period: t("plan.period.month", "/month"),
|
||||
popular: true,
|
||||
highlights: [
|
||||
t("plan.pro.highlight1", "Unlimited Tool Usage"),
|
||||
t("plan.pro.highlight2", "Advanced PDF tools"),
|
||||
t("plan.pro.highlight3", "No watermarks"),
|
||||
],
|
||||
features: [
|
||||
{
|
||||
name: t("plan.feature.pdfTools", "Basic PDF Tools"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.fileSize", "File Size Limit"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.automation", "automate tool workflows"),
|
||||
included: true,
|
||||
},
|
||||
{ name: t("plan.feature.api", "Weekly API Credits"), included: true },
|
||||
{
|
||||
name: t("plan.feature.priority", "Priority Support"),
|
||||
included: false,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.customPricing", "Custom Pricing"),
|
||||
included: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: t("plan.enterprise.name", "Enterprise"),
|
||||
price: 0,
|
||||
currency: getCurrencySymbol(currency),
|
||||
period: "",
|
||||
isContactOnly: true,
|
||||
highlights: [
|
||||
t("plan.enterprise.highlight1", "Custom pricing"),
|
||||
t("plan.enterprise.highlight2", "Dedicated support"),
|
||||
t("plan.enterprise.highlight3", "Latest features"),
|
||||
],
|
||||
features: [
|
||||
{
|
||||
name: t("plan.feature.pdfTools", "Basic PDF Tools"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.fileSize", "File Size Limit"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.automation", "automate tool workflows"),
|
||||
included: true,
|
||||
},
|
||||
{ name: t("plan.feature.api", "Weekly API Credits"), included: true },
|
||||
{
|
||||
name: t("plan.feature.priority", "Priority Support"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.customPricing", "Custom Pricing"),
|
||||
included: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get price info
|
||||
const getPriceInfo = (key: string, fallbackPrice: number) => {
|
||||
const priceObj = dynamicPrices.get(key);
|
||||
const price = priceObj ? priceObj.unit_amount / 100 : fallbackPrice;
|
||||
const currencySymbol = priceObj
|
||||
? getCurrencySymbol(priceObj.currency)
|
||||
: getCurrencySymbol(currency);
|
||||
return { price, currencySymbol };
|
||||
};
|
||||
|
||||
const xsmallPrice = getPriceInfo("xsmall", 4);
|
||||
const smallPrice = getPriceInfo("small", 15);
|
||||
const mediumPrice = getPriceInfo("medium", 25);
|
||||
const largePrice = getPriceInfo("large", 90);
|
||||
|
||||
// Calculate dynamic discounts based on per-credit cost (using xsmall as baseline)
|
||||
const xsmallPerCredit = xsmallPrice.price / 100;
|
||||
const smallPerCredit = smallPrice.price / 500;
|
||||
const mediumPerCredit = mediumPrice.price / 1000;
|
||||
const largePerCredit = largePrice.price / 5000;
|
||||
|
||||
const smallDiscount = Math.round(
|
||||
(1 - smallPerCredit / xsmallPerCredit) * 100,
|
||||
);
|
||||
const mediumDiscount = Math.round(
|
||||
(1 - mediumPerCredit / xsmallPerCredit) * 100,
|
||||
);
|
||||
const largeDiscount = Math.round(
|
||||
(1 - largePerCredit / xsmallPerCredit) * 100,
|
||||
);
|
||||
|
||||
const apiPackages: ApiPackage[] = [
|
||||
{
|
||||
id: "xsmall",
|
||||
name: t("plan.api.xsmall", "100 Credits"),
|
||||
price: xsmallPrice.price,
|
||||
currency: xsmallPrice.currencySymbol,
|
||||
credits: 100,
|
||||
description: `${xsmallPrice.currencySymbol}${(xsmallPrice.price / 100).toFixed(3)} per credit`,
|
||||
},
|
||||
{
|
||||
id: "small",
|
||||
name: t("plan.api.small", "500 Credits"),
|
||||
price: smallPrice.price,
|
||||
currency: smallPrice.currencySymbol,
|
||||
credits: 500,
|
||||
description: `${smallPrice.currencySymbol}${(smallPrice.price / 500).toFixed(3)} per credit${smallDiscount > 0 ? ` • ${smallDiscount}% discount` : ""}`,
|
||||
},
|
||||
{
|
||||
id: "medium",
|
||||
name: t("plan.api.medium", "1,000 Credits"),
|
||||
price: mediumPrice.price,
|
||||
currency: mediumPrice.currencySymbol,
|
||||
credits: 1000,
|
||||
description: `${mediumPrice.currencySymbol}${(mediumPrice.price / 1000).toFixed(3)} per credit${mediumDiscount > 0 ? ` • ${mediumDiscount}% discount` : ""}`,
|
||||
},
|
||||
{
|
||||
id: "large",
|
||||
name: t("plan.api.large", "5,000 Credits"),
|
||||
price: largePrice.price,
|
||||
currency: largePrice.currencySymbol,
|
||||
credits: 5000,
|
||||
description: `${largePrice.currencySymbol}${(largePrice.price / 5000).toFixed(3)} per credit${largeDiscount > 0 ? ` • ${largeDiscount}% discount` : ""}`,
|
||||
},
|
||||
];
|
||||
|
||||
const plansMap = new Map(plans.map((plan) => [plan.id, plan]));
|
||||
return { plans: plansMap, apiPackages };
|
||||
}, [t, dynamicPrices]);
|
||||
|
||||
// Create final data object with current plan info
|
||||
const data = useMemo<PlansData | null>(() => {
|
||||
if (!staticPlansData) return null;
|
||||
|
||||
const currentPlan = staticPlansData.plans.get(currentPlanId);
|
||||
if (!currentPlan) return null;
|
||||
|
||||
return {
|
||||
plans: staticPlansData.plans,
|
||||
apiPackages: staticPlansData.apiPackages,
|
||||
currentPlan,
|
||||
nextBillingDate: "Feb 15, 2025",
|
||||
activeSince: "January 2025",
|
||||
};
|
||||
}, [staticPlansData, currentPlanId]);
|
||||
|
||||
// Update currentPlanId when isPro changes
|
||||
useEffect(() => {
|
||||
if (isPro !== null) {
|
||||
setCurrentPlanId(isPro ? "pro" : "free");
|
||||
}
|
||||
}, [isPro]);
|
||||
|
||||
// Initial load - fetch pricing data
|
||||
useEffect(() => {
|
||||
fetchPricing();
|
||||
}, [currency]); // Re-fetch when currency changes
|
||||
|
||||
const updateCurrentPlan = (newPlanId: string) => {
|
||||
setCurrentPlanId(newPlanId);
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
plans: data ? Array.from(data.plans.values()) : [], // Convert Map to array for compatibility with proprietary code
|
||||
loading,
|
||||
error,
|
||||
refetch: refreshProStatus, // Refetch pro status from auth context
|
||||
updateCurrentPlan, // Add local plan update function
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { Button } from "@mantine/core";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
interface CallbackState {
|
||||
status: "processing" | "success" | "error";
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const [state, setState] = useState<CallbackState>({
|
||||
status: "processing",
|
||||
message: "Processing authentication...",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get("code");
|
||||
const error = url.searchParams.get("error");
|
||||
const errorDescription = url.searchParams.get("error_description");
|
||||
const next = url.searchParams.get("next") || "/";
|
||||
|
||||
console.log("[Auth Callback Debug] URL parameters:", {
|
||||
hasCode: !!code,
|
||||
hasError: !!error,
|
||||
error,
|
||||
errorDescription,
|
||||
next,
|
||||
fullUrl: window.location.href,
|
||||
});
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error;
|
||||
console.error("[Auth Callback Debug] OAuth error:", {
|
||||
error,
|
||||
errorDescription,
|
||||
});
|
||||
|
||||
setState({
|
||||
status: "error",
|
||||
message: `Authentication failed: ${errorMsg}`,
|
||||
details: { error, errorDescription },
|
||||
});
|
||||
|
||||
// Redirect to login page after 3 seconds
|
||||
setTimeout(() => navigate("/login", { replace: true }), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// If PKCE/SSR-style code is present, exchange it for a session
|
||||
if (code) {
|
||||
console.log("[Auth Callback Debug] Exchanging code for session...");
|
||||
|
||||
setState({
|
||||
status: "processing",
|
||||
message: "Exchanging authorization code...",
|
||||
});
|
||||
|
||||
const { data, error: exchangeError } =
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
|
||||
if (exchangeError) {
|
||||
console.error(
|
||||
"[Auth Callback Debug] Code exchange error:",
|
||||
exchangeError,
|
||||
);
|
||||
|
||||
setState({
|
||||
status: "error",
|
||||
message: `Failed to complete sign in: ${exchangeError.message}`,
|
||||
details: { exchangeError },
|
||||
});
|
||||
|
||||
setTimeout(() => navigate("/login", { replace: true }), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Auth Callback Debug] Code exchange successful:", {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
});
|
||||
|
||||
setState({
|
||||
status: "success",
|
||||
message: "Sign in successful! Redirecting...",
|
||||
details: {
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
provider: data.session?.user?.app_metadata?.provider,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// No code present - might already be authenticated
|
||||
console.log(
|
||||
"[Auth Callback Debug] No code present, checking existing session...",
|
||||
);
|
||||
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
|
||||
if (sessionData.session) {
|
||||
console.log("[Auth Callback Debug] Existing session found");
|
||||
setState({
|
||||
status: "success",
|
||||
message: "Already signed in! Redirecting...",
|
||||
});
|
||||
} else {
|
||||
console.log("[Auth Callback Debug] No session found");
|
||||
setState({
|
||||
status: "error",
|
||||
message: "No authentication data found",
|
||||
});
|
||||
setTimeout(() => navigate("/login", { replace: true }), 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to the intended destination
|
||||
const destination = next.startsWith("/") ? next : "/";
|
||||
console.log("[Auth Callback Debug] Redirecting to:", destination);
|
||||
|
||||
setTimeout(() => navigate(destination, { replace: true }), 1500);
|
||||
} catch (err) {
|
||||
console.error("[Auth Callback Debug] Unexpected error:", err);
|
||||
|
||||
setState({
|
||||
status: "error",
|
||||
message: `Unexpected error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
details: { error: err },
|
||||
});
|
||||
|
||||
setTimeout(() => navigate("/login", { replace: true }), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate]);
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (state.status) {
|
||||
case "processing":
|
||||
return "text-blue-600";
|
||||
case "success":
|
||||
return "text-green-600";
|
||||
case "error":
|
||||
return "text-red-600";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
switch (state.status) {
|
||||
case "processing":
|
||||
return "Signing you in";
|
||||
case "success":
|
||||
return "You're all set!";
|
||||
case "error":
|
||||
return "Authentication failed";
|
||||
default:
|
||||
return "Authentication";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute -top-32 -left-32 h-96 w-96 rounded-full bg-blue-200/40 blur-3xl"></div>
|
||||
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-emerald-200/40 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`w-full max-w-md rounded-2xl bg-white/80 backdrop-blur shadow-xl p-8`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/StirlingPDFLogoNoTextDark.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="mx-auto mb-5 h-8 opacity-80"
|
||||
/>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{getTitle()}
|
||||
</h1>
|
||||
<p className={`text-base ${getStatusColor()}`}>{state.message}</p>
|
||||
|
||||
{state.status === "processing" && (
|
||||
<div className="mt-6">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div className="mt-3 h-1 overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="h-full w-1/2 animate-pulse rounded-full bg-blue-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action button - only show if error */}
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{(() => {
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => navigate("/login", { replace: true })}
|
||||
className="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{import.meta.env.DEV && state.details && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Debug Information
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(state.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth";
|
||||
import { isToolRoute } from "@app/utils/pathUtils";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import Login from "@app/routes/Login";
|
||||
import GuestUserBanner from "@app/components/auth/GuestUserBanner";
|
||||
import { TrialStatusBanner } from "@app/components/shared/TrialStatusBanner";
|
||||
|
||||
export default function Landing() {
|
||||
const { session, loading } = useAuth();
|
||||
const { isAutoAuthenticating, autoAuthError, shouldTriggerAutoAuth } =
|
||||
useAutoAnonymousAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Check if current path is a tool (prevents premature navigation on first render)
|
||||
const isCurrentPathTool = useMemo(
|
||||
() => isToolRoute(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
// Match the same guarded bypass used in RequireAuth
|
||||
const isLocalhost =
|
||||
typeof window !== "undefined" &&
|
||||
/^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname);
|
||||
const devBypassEnabled = Boolean(
|
||||
import.meta.env.DEV &&
|
||||
isLocalhost &&
|
||||
import.meta.env.VITE_DEV_BYPASS_AUTH === "true",
|
||||
);
|
||||
|
||||
console.log("[Landing] State:", {
|
||||
pathname: location.pathname,
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
isAutoAuthenticating,
|
||||
shouldTriggerAutoAuth,
|
||||
isCurrentPathTool,
|
||||
autoAuthError,
|
||||
});
|
||||
|
||||
// Show loading while checking auth, while auto-authenticating, OR while preparing to auto-authenticate
|
||||
// CRITICAL: Also wait if shouldTriggerAutoAuth is true OR if we're on a tool route (prevents navigation before hook evaluates)
|
||||
if (
|
||||
loading ||
|
||||
isAutoAuthenticating ||
|
||||
(!session && (shouldTriggerAutoAuth || isCurrentPathTool) && !autoAuthError)
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||
<div className="text-gray-600">
|
||||
{isAutoAuthenticating ? "Setting up your session..." : "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If we have a session or dev bypass is enabled, show the main app
|
||||
if (session || devBypassEnabled) {
|
||||
return (
|
||||
<>
|
||||
<GuestUserBanner />
|
||||
<TrialStatusBanner />
|
||||
<HomePage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// If auto-authentication failed, navigate to login with error state
|
||||
if (autoAuthError && shouldTriggerAutoAuth) {
|
||||
return (
|
||||
<Navigate to="/login" replace state={{ autoAuthError, from: location }} />
|
||||
);
|
||||
}
|
||||
|
||||
// If we're at home route ("/"), show login directly (marketing/landing page)
|
||||
// Otherwise navigate to login (fixes URL mismatch for tool routes)
|
||||
const isHome = location.pathname === "/" || location.pathname === "";
|
||||
if (isHome) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
// For non-home routes without auth, navigate to login (preserves from location)
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { supabase, signInAnonymously } from "@app/auth/supabase";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
|
||||
|
||||
// Import login components
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import MagicLinkForm from "@app/routes/login/MagicLinkForm";
|
||||
import OAuthButtons from "@app/routes/login/OAuthButtons";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import LoggedInState from "@app/routes/login/LoggedInState";
|
||||
import { absoluteWithBasePath, getBaseUrl } from "@app/constants/app";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { session, loading, refreshSession } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showMagicLink, setShowMagicLink] = useState(false);
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [magicLinkEmail, setMagicLinkEmail] = useState("");
|
||||
// Prefill email from query param (e.g. after password reset)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const emailFromQuery = url.searchParams.get("email");
|
||||
if (emailFromQuery) {
|
||||
setEmail(emailFromQuery);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t("login.title", "Sign in")} - Stirling PDF`,
|
||||
description: t(
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogTitle: `${t("login.title", "Sign in")} - Stirling PDF`,
|
||||
ogDescription: t(
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`,
|
||||
});
|
||||
|
||||
// Show logged in state if authenticated
|
||||
if (session && !loading) {
|
||||
return <LoggedInState />;
|
||||
}
|
||||
|
||||
const signInWithProvider = async (
|
||||
provider: "github" | "google" | "apple" | "azure",
|
||||
) => {
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
|
||||
const redirectTo = absoluteWithBasePath("/auth/callback");
|
||||
console.log(`[Login] Signing in with ${provider}`);
|
||||
|
||||
const oauthOptions: {
|
||||
redirectTo: string;
|
||||
queryParams?: Record<string, string>;
|
||||
} = { redirectTo };
|
||||
if (provider === "apple") {
|
||||
oauthOptions.queryParams = { scope: "email name" };
|
||||
} else if (provider === "azure") {
|
||||
oauthOptions.queryParams = { scope: "openid profile email" };
|
||||
} else {
|
||||
oauthOptions.queryParams = {
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
};
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: oauthOptions,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`[Login] ${provider} error:`, error);
|
||||
setError(
|
||||
t("login.failedToSignIn", { provider, message: error.message }),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Login] Unexpected error:`, err);
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithEmail = async () => {
|
||||
if (!email || !password) {
|
||||
setError(t("login.pleaseEnterBoth"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
|
||||
console.log("[Login] Signing in with email:", email);
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[Login] Email sign in error:", error);
|
||||
setError(error.message);
|
||||
} else if (data.user) {
|
||||
console.log("[Login] Email sign in successful");
|
||||
// User will be redirected by the auth state change
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Unexpected error]:", err);
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithMagicLink = async () => {
|
||||
if (!magicLinkEmail) {
|
||||
setError(t("login.pleaseEnterEmail"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
|
||||
console.log("[Login] Sending magic link to:", magicLinkEmail);
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email: magicLinkEmail.trim(),
|
||||
options: {
|
||||
emailRedirectTo: absoluteWithBasePath("/auth/callback"),
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[Login] Magic link error:", error);
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError(null);
|
||||
alert(t("login.magicLinkSent", { email: magicLinkEmail }));
|
||||
setMagicLinkEmail("");
|
||||
setShowMagicLink(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Unexpected error:", err);
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
navigate("/auth/reset");
|
||||
};
|
||||
|
||||
const handleAnonymousSignIn = async () => {
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
console.log("[Login] Signing in anonymously");
|
||||
|
||||
const { data } = await signInAnonymously();
|
||||
|
||||
if (data.user) {
|
||||
console.log(
|
||||
"[Login] Anonymous sign in successful, refreshing session...",
|
||||
);
|
||||
|
||||
// Refresh session to ensure backend endpoints are properly synchronized
|
||||
await refreshSession();
|
||||
|
||||
console.log(
|
||||
"[Login] Session refreshed, user will be redirected by auth state change",
|
||||
);
|
||||
// User will be redirected by the auth state change after session refresh
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Unexpected error:", err);
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm}>
|
||||
<LoginHeader
|
||||
title={t("login.login")}
|
||||
subtitle={t("login.subtitle", "Sign back in to Stirling PDF")}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="fullwidth"
|
||||
/>
|
||||
|
||||
{/* Divider between OAuth and Email */}
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
|
||||
{/* Sign in with email button (primary color to match signup CTA) */}
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
disabled={isSigningIn}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{t("login.useEmailInstead", "Sign in with email")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={
|
||||
isSigningIn ? t("login.loggingIn") : t("login.login")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEmailForm && (
|
||||
<div className="auth-section-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t("login.forgotPassword", "Forgot your password?")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider then Guest */}
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
|
||||
<GuestSignInButton
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningIn}
|
||||
label={
|
||||
isSigningIn
|
||||
? t("login.signingIn", "Signing in...")
|
||||
: t("login.signInAnonymously", "Sign in as a Guest")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="auth-bottom-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMagicLink(true)}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t("login.useMagicLink", "Sign in with magic link")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/signup")}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t("signup.signUp", "Sign up")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Magic link form renders on demand */}
|
||||
{showMagicLink && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<MagicLinkForm
|
||||
showMagicLink={showMagicLink}
|
||||
magicLinkEmail={magicLinkEmail}
|
||||
setMagicLinkEmail={setMagicLinkEmail}
|
||||
setShowMagicLink={setShowMagicLink}
|
||||
onSubmit={signInWithMagicLink}
|
||||
isSubmitting={isSigningIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import SuccessMessage from "@app/routes/login/SuccessMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import NavigationLink from "@app/routes/login/NavigationLink";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { absoluteWithBasePath } from "@app/constants/app";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
|
||||
export default function ResetPassword() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [isRecovery, setIsRecovery] = useState(false);
|
||||
const [didUpdate, setDidUpdate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const type = url.searchParams.get("type");
|
||||
const code = url.searchParams.get("code");
|
||||
|
||||
// Also parse hash params (Supabase puts tokens & type in the hash)
|
||||
const hash = url.hash || "";
|
||||
const hashParams = new URLSearchParams(
|
||||
hash.startsWith("#") ? hash.substring(1) : hash,
|
||||
);
|
||||
const hashType = hashParams.get("type");
|
||||
const hashError = hashParams.get("error");
|
||||
const hashErrorDescription = hashParams.get("error_description");
|
||||
|
||||
if (hashError) {
|
||||
// Show a human-readable error and fall back to email-entry form
|
||||
setError(hashErrorDescription || hashError);
|
||||
setIsRecovery(false);
|
||||
}
|
||||
|
||||
// Consider either source (query or hash) to decide if we're in recovery mode
|
||||
const inRecovery = type === "recovery" || hashType === "recovery";
|
||||
setIsRecovery(inRecovery);
|
||||
|
||||
// If a PKCE-style code is present, exchange it for a session immediately
|
||||
const tryExchange = async () => {
|
||||
if (code) {
|
||||
try {
|
||||
const { data, error } =
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
setIsRecovery(false);
|
||||
} else if (data.session) {
|
||||
setIsRecovery(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setIsRecovery(false);
|
||||
}
|
||||
} else {
|
||||
// If no code, see if Supabase already set the session from hash
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (data.session && inRecovery) {
|
||||
setIsRecovery(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
void tryExchange();
|
||||
|
||||
// Clear sensitive tokens from the URL hash
|
||||
if (hash.includes("access_token") || hashError) {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname + (inRecovery ? "?type=recovery" : ""),
|
||||
);
|
||||
}
|
||||
|
||||
// Listen for Supabase auth state changes to confirm recovery state
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((event) => {
|
||||
if (event === "PASSWORD_RECOVERY") {
|
||||
setIsRecovery(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!email) {
|
||||
setError(t("login.pleaseEnterEmail"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const redirectTo = absoluteWithBasePath("/auth/reset?type=recovery");
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(
|
||||
email.trim(),
|
||||
{
|
||||
redirectTo,
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setSuccess(t("login.passwordResetSent", { email }));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePassword = async () => {
|
||||
if (!password || !confirmPassword) {
|
||||
setError(t("signup.pleaseFillAllFields"));
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError(t("signup.passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError(t("signup.passwordsDoNotMatch"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const { data, error } = await supabase.auth.updateUser({ password });
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
if (data.user) {
|
||||
setSuccess(
|
||||
t(
|
||||
"login.passwordUpdatedSuccess",
|
||||
"Your password has been updated successfully.",
|
||||
),
|
||||
);
|
||||
// Clear the form fields
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
// Show success-only state and then redirect after a short delay
|
||||
setDidUpdate(true);
|
||||
setTimeout(async () => {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const derivedEmail = userData.user?.email || email;
|
||||
if (sessionData.session) {
|
||||
navigate("/");
|
||||
} else {
|
||||
const query = derivedEmail
|
||||
? `?email=${encodeURIComponent(derivedEmail)}`
|
||||
: "";
|
||||
navigate(`/login${query}`);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={
|
||||
isRecovery
|
||||
? t("login.resetYourPassword", "Reset your password")
|
||||
: t("login.forgotPassword", "Forgot your password?")
|
||||
}
|
||||
/>
|
||||
{!didUpdate && <SuccessMessage success={success} />}
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{didUpdate ? (
|
||||
<>
|
||||
<SuccessMessage
|
||||
success={
|
||||
success ||
|
||||
t(
|
||||
"login.passwordUpdatedSuccess",
|
||||
"Your password has been updated successfully.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavigationLink
|
||||
onClick={() => navigate("/login")}
|
||||
text={t("login.backToSignIn", "Back to sign in")}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
) : isRecovery ? (
|
||||
<>
|
||||
<div className="auth-fields">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="password" className="auth-label">
|
||||
{t("signup.password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="new-password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("signup.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label htmlFor="confirmPassword" className="auth-label">
|
||||
{t("signup.confirmPassword")}
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="new-password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("signup.confirmPasswordPlaceholder")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdatePassword}
|
||||
disabled={isSubmitting || !password || !confirmPassword}
|
||||
className="auth-button"
|
||||
>
|
||||
{isSubmitting
|
||||
? t("login.sending", "Sending…")
|
||||
: t("login.updatePassword", "Update password")}
|
||||
</button>
|
||||
<NavigationLink
|
||||
onClick={() => navigate("/login")}
|
||||
text={t("login.backToSignIn", "Back to sign in")}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={""}
|
||||
setEmail={setEmail}
|
||||
setPassword={() => {}}
|
||||
onSubmit={handleSendEmail}
|
||||
isSubmitting={isSubmitting}
|
||||
submitButtonText={t("login.sendResetLink", "Send reset link")}
|
||||
showPasswordField={false}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-3">
|
||||
{t(
|
||||
"login.resetHelp",
|
||||
"Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one.",
|
||||
)}
|
||||
</p>
|
||||
<NavigationLink
|
||||
onClick={() => navigate("/login")}
|
||||
text={t("login.backToSignIn", "Back to sign in")}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { signInAnonymously } from "@app/auth/supabase";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { getBaseUrl } from "@app/constants/app";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
// Import signup components
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import OAuthButtons from "@app/routes/login/OAuthButtons";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import SignupForm from "@app/routes/signup/SignupForm";
|
||||
import {
|
||||
useSignupFormValidation,
|
||||
SignupFieldErrors,
|
||||
} from "@app/routes/signup/SignupFormValidation";
|
||||
import { useAuthService } from "@app/routes/signup/AuthService";
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { session, loading, refreshSession } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [isSigningUp, setIsSigningUp] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [name, setName] = useState(undefined as string | undefined);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [agree, setAgree] = useState(true);
|
||||
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
|
||||
|
||||
// Check if we were redirected here with an auto-auth error
|
||||
useEffect(() => {
|
||||
const state = location.state as { autoAuthError?: string } | null;
|
||||
if (state?.autoAuthError) {
|
||||
setError(`Unable to access tool: ${state.autoAuthError}`);
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
// Redirect back to original tool URL once session appears (after auto-anon completes)
|
||||
useEffect(() => {
|
||||
if (!loading && session) {
|
||||
const state = location.state as {
|
||||
from?: { pathname?: string; search?: string; hash?: string };
|
||||
} | null;
|
||||
const from = state?.from;
|
||||
if (
|
||||
from?.pathname &&
|
||||
from.pathname !== "/signup" &&
|
||||
from.pathname !== "/login"
|
||||
) {
|
||||
const target = `${from.pathname}${from.search ?? ""}${from.hash ?? ""}`;
|
||||
console.log("[Signup] Session detected, redirecting back to:", target);
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [loading, session, location.state, navigate]);
|
||||
|
||||
const handleAnonymousSignIn = async () => {
|
||||
try {
|
||||
setIsSigningUp(true);
|
||||
setError(null);
|
||||
|
||||
console.log("[Signup] Initiating anonymous sign-in...");
|
||||
const { data } = await signInAnonymously();
|
||||
|
||||
if (data.user) {
|
||||
console.log(
|
||||
"[Signup] Anonymous sign-in successful, refreshing session...",
|
||||
);
|
||||
|
||||
// Refresh session to ensure backend endpoints are properly synchronized
|
||||
await refreshSession();
|
||||
|
||||
console.log("[Signup] Session refreshed, redirecting to home page");
|
||||
// Redirect to home page after successful anonymous login and session refresh
|
||||
navigate("/");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Signup] Anonymous sign-in unexpected error:", err);
|
||||
setError(
|
||||
`Unexpected error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsSigningUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t("signup.title", "Create an account")} - Stirling PDF`,
|
||||
description: t(
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogTitle: `${t("signup.title", "Create an account")} - Stirling PDF`,
|
||||
ogDescription: t(
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`,
|
||||
});
|
||||
|
||||
const { validateSignupForm } = useSignupFormValidation();
|
||||
const { signUp, signInWithProvider } = useAuthService();
|
||||
|
||||
const handleSignUp = async () => {
|
||||
const validation = validateSignupForm(
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
name,
|
||||
);
|
||||
if (!validation.isValid) {
|
||||
setError(validation.error);
|
||||
setFieldErrors(validation.fieldErrors || {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningUp(true);
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
const result = await signUp(email, password, name);
|
||||
|
||||
if (result.requiresEmailConfirmation) {
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("signup.checkEmailConfirmation"),
|
||||
location: "top-right",
|
||||
isPersistentPopup: true,
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("signup.accountCreatedSuccessfully"),
|
||||
location: "top-right",
|
||||
durationMs: 3000,
|
||||
});
|
||||
setTimeout(() => navigate("/login"), 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Signup] Unexpected error:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("signup.unexpectedError", { message: "Unknown error" }),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderSignIn = async (
|
||||
provider: "github" | "google" | "apple" | "azure",
|
||||
) => {
|
||||
try {
|
||||
setIsSigningUp(true);
|
||||
setError(null);
|
||||
await signInWithProvider(provider);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("signup.unexpectedError", { message: "Unknown error" }),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm}>
|
||||
<LoginHeader title={t("signup.title")} subtitle={t("signup.subtitle")} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<OAuthButtons
|
||||
onProviderClick={handleProviderSignIn}
|
||||
isSubmitting={isSigningUp}
|
||||
layout="fullwidth"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider between OAuth and Email */}
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Use Email Instead button (toggles email form) */}
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSigningUp}
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{t("signup.useEmailInstead", "Use Email Instead")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<SignupForm
|
||||
name={name}
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
agree={agree}
|
||||
setName={setName}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
setAgree={setAgree}
|
||||
onSubmit={handleSignUp}
|
||||
isSubmitting={isSigningUp}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="auth-section-sm">
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GuestSignInButton
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningUp}
|
||||
label={
|
||||
isSigningUp
|
||||
? t("login.signingIn", "Signing in...")
|
||||
: t("login.signInAnonymously", "Sign in as a Guest")
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="auth-bottom-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t("login.logIn", "Log In")}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.authContainer {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--auth-bg-color-light-only);
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Main area above footer: keep card centered even with footer visible */
|
||||
.authMain {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 95vh;
|
||||
}
|
||||
|
||||
.authCard {
|
||||
width: min(45rem, 96vw);
|
||||
height: min(50.875rem, 96vh);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
background-color: var(--auth-card-bg);
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
max-height: 96vh;
|
||||
}
|
||||
|
||||
.authCardTwoColumns {
|
||||
width: min(73.75rem, 96vw);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.authLeftPanel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.authLeftPanelCentered {
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.authLeftPanelScrollable {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.authLeftPanel::-webkit-scrollbar {
|
||||
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
|
||||
}
|
||||
|
||||
.authContent {
|
||||
max-width: 26.25rem; /* 420px */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.authLeftPanelScrollable .authContent {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
|
||||
import buildLoginSlides from "@app/components/shared/loginSlides";
|
||||
import styles from "@app/routes/authShared/AuthLayout.module.css";
|
||||
import { useLogoVariant } from "@app/hooks/useLogoVariant";
|
||||
import { useIsOverflowing } from "@app/hooks/useIsOverflowing";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
isEmailFormExpanded?: boolean;
|
||||
}
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
isEmailFormExpanded = false,
|
||||
}: AuthLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const leftPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
const logoVariant = useLogoVariant();
|
||||
const imageSlides = useMemo(
|
||||
() => buildLoginSlides(logoVariant, t),
|
||||
[logoVariant, t],
|
||||
);
|
||||
const isOverflowing = useIsOverflowing(leftPanelRef);
|
||||
|
||||
// Use either overflow detection or email form expansion to determine scrollable state
|
||||
const shouldBeScrollable = isOverflowing || isEmailFormExpanded;
|
||||
|
||||
// Force light mode on auth pages
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
const previousColorScheme = htmlElement.getAttribute(
|
||||
"data-mantine-color-scheme",
|
||||
);
|
||||
|
||||
// Set light mode
|
||||
htmlElement.setAttribute("data-mantine-color-scheme", "light");
|
||||
|
||||
// Cleanup: restore previous theme when leaving auth pages
|
||||
return () => {
|
||||
if (previousColorScheme) {
|
||||
htmlElement.setAttribute(
|
||||
"data-mantine-color-scheme",
|
||||
previousColorScheme,
|
||||
);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
// Use viewport to avoid hysteresis when the card is already in single-column mode
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
|
||||
const columnWidth = cardWidthIfTwoCols / 2;
|
||||
const tooNarrow = columnWidth < 470;
|
||||
const tooShort = viewportHeight < 740;
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
window.addEventListener("orientationchange", update);
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("orientationchange", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div className={styles.authMain}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}
|
||||
>
|
||||
<div
|
||||
ref={leftPanelRef}
|
||||
className={`${styles.authLeftPanel} ${shouldBeScrollable ? styles.authLeftPanelScrollable : styles.authLeftPanelCentered}`}
|
||||
>
|
||||
<div className={styles.authContent}>{children}</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel
|
||||
imageSlides={imageSlides}
|
||||
initialSeconds={5}
|
||||
slideSeconds={8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100vw",
|
||||
marginTop: "auto",
|
||||
marginLeft: "-1.5rem",
|
||||
marginRight: "-1.5rem",
|
||||
}}
|
||||
>
|
||||
<Footer forceLightMode={true} analyticsEnabled />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
|
||||
interface GuestSignInButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function GuestSignInButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: GuestSignInButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed auth-guest-button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* SaaS-specific auth styles — imported alongside the base auth.css */
|
||||
|
||||
.oauth-container-fullwidth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.625rem;
|
||||
background-color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auth-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-dropdown-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.auth-dropdown {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
margin-top: 0.5rem;
|
||||
min-width: 16rem;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.auth-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-guest-button {
|
||||
background-color: #ffffff;
|
||||
color: #9c2f30;
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
|
||||
interface EmailPasswordFormProps {
|
||||
email: string;
|
||||
password: string;
|
||||
setEmail: (email: string) => void;
|
||||
setPassword: (password: string) => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
submitButtonText: string;
|
||||
showPasswordField?: boolean;
|
||||
fieldErrors?: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EmailPasswordForm({
|
||||
email,
|
||||
password,
|
||||
setEmail,
|
||||
setPassword,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
submitButtonText,
|
||||
showPasswordField = true,
|
||||
fieldErrors = {},
|
||||
}: EmailPasswordFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="auth-fields">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="email" className="auth-label">
|
||||
{t("login.email", "Email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username email"
|
||||
placeholder={t("login.enterEmail", "Enter email")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`auth-input ${fieldErrors.email ? "auth-input-error" : ""}`}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<div className="auth-field-error">{fieldErrors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPasswordField && (
|
||||
<div className="auth-field">
|
||||
<label htmlFor="password" className="auth-label">
|
||||
{t("login.password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="current-password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t("login.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`auth-input ${fieldErrors.password ? "auth-input-error" : ""}`}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<div className="auth-field-error">{fieldErrors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email || (showPasswordField && !password)}
|
||||
className="auth-button"
|
||||
>
|
||||
{submitButtonText}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
|
||||
export default function LoadingState() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f3f4f6",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: "32px", marginBottom: "16px" }}>⏳</div>
|
||||
<p style={{ color: "#6b7280" }}>{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
|
||||
interface MagicLinkFormProps {
|
||||
showMagicLink: boolean;
|
||||
magicLinkEmail: string;
|
||||
setMagicLinkEmail: (email: string) => void;
|
||||
setShowMagicLink: (show: boolean) => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function MagicLinkForm({
|
||||
showMagicLink,
|
||||
magicLinkEmail,
|
||||
setMagicLinkEmail,
|
||||
setShowMagicLink,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: MagicLinkFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showMagicLink) {
|
||||
return (
|
||||
<div className="auth-toggle-wrapper">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMagicLink(true);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="auth-toggle-link"
|
||||
>
|
||||
{t("login.useMagicLink")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-magic-row">
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t("login.enterEmailForMagicLink")}
|
||||
value={magicLinkEmail}
|
||||
onChange={(e) => setMagicLinkEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && !isSubmitting && onSubmit()}
|
||||
className="auth-input"
|
||||
/>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !magicLinkEmail}
|
||||
className="auth-magic-button"
|
||||
>
|
||||
{isSubmitting ? t("login.sending") : t("login.sendMagicLink")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { oauthProviders } from "@app/constants/authProviders";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
// Exports for compatibility with proprietary code
|
||||
export const DEBUG_SHOW_ALL_PROVIDERS = false;
|
||||
export const oauthProviderConfig = {
|
||||
google: { label: "Google", file: "google.svg" },
|
||||
github: { label: "GitHub", file: "github.svg" },
|
||||
apple: { label: "Apple", file: "apple.svg" },
|
||||
azure: { label: "Microsoft", file: "microsoft.svg" },
|
||||
};
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
onProviderClick: (provider: "github" | "google") => void;
|
||||
isSubmitting: boolean;
|
||||
layout?: "vertical" | "grid" | "icons" | "fullwidth";
|
||||
enabledProviders?: string[]; // List of enabled provider IDs from backend
|
||||
}
|
||||
|
||||
export default function OAuthButtons({
|
||||
onProviderClick,
|
||||
isSubmitting,
|
||||
layout = "vertical",
|
||||
enabledProviders: _enabledProviders = [],
|
||||
}: OAuthButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (layout === "icons") {
|
||||
return (
|
||||
<div className="oauth-container-icons">
|
||||
{oauthProviders.map((p) => (
|
||||
<Tooltip
|
||||
key={p.id}
|
||||
content={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as "github" | "google")}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-icon"
|
||||
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
||||
>
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-small ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === "grid") {
|
||||
return (
|
||||
<div className="oauth-container-grid">
|
||||
{oauthProviders.map((p) => (
|
||||
<Tooltip
|
||||
key={p.id}
|
||||
content={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as "github" | "google")}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-grid"
|
||||
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
||||
>
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === "fullwidth") {
|
||||
return (
|
||||
<div className="oauth-container-fullwidth">
|
||||
{oauthProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as "github" | "google")}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-fullwidth"
|
||||
title={p.label}
|
||||
>
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
/>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{oauthProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as "github" | "google")}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-vertical"
|
||||
title={p.label}
|
||||
>
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-tiny ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
/>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface SuccessMessageProps {
|
||||
success: string | null;
|
||||
}
|
||||
|
||||
export default function SuccessMessage({ success }: SuccessMessageProps) {
|
||||
if (!success) return null;
|
||||
|
||||
return (
|
||||
<div className="success-message">
|
||||
<p className="success-message-text">{success}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { absoluteWithBasePath } from "@app/constants/app";
|
||||
|
||||
export const useAuthService = () => {
|
||||
const signUp = async (email: string, password: string, name?: string) => {
|
||||
console.log("[Signup] Creating account for:", email);
|
||||
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
options: {
|
||||
emailRedirectTo: absoluteWithBasePath("/auth/callback"),
|
||||
data: { full_name: name },
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[Signup] Sign up error:", error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
console.log("[Signup] Sign up successful:", data.user);
|
||||
return {
|
||||
user: data.user,
|
||||
session: data.session,
|
||||
requiresEmailConfirmation: data.user && !data.session,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unknown error occurred during signup");
|
||||
};
|
||||
|
||||
const signInWithProvider = async (
|
||||
provider: "github" | "google" | "apple" | "azure",
|
||||
) => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: { redirectTo: absoluteWithBasePath("/auth/callback") },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
signUp,
|
||||
signInWithProvider,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
|
||||
interface DeleteAccountOptions {
|
||||
notifyUser?: boolean;
|
||||
}
|
||||
|
||||
interface DeleteUserResponse {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
deleted_supabase_id?: string;
|
||||
stripe_redaction_job_id?: string | null;
|
||||
}
|
||||
|
||||
export async function deleteCurrentAccount(
|
||||
options?: DeleteAccountOptions,
|
||||
): Promise<void> {
|
||||
const { data, error } = await supabase.functions.invoke<DeleteUserResponse>(
|
||||
"delete-user",
|
||||
{
|
||||
body: {
|
||||
notify_user: options?.notifyUser ?? true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !data?.success) {
|
||||
const serverMessage = data?.error;
|
||||
const errorMessage =
|
||||
serverMessage || error?.message || "Failed to delete account";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, AuthError } from "@supabase/supabase-js";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
|
||||
// Mock supabase
|
||||
vi.mock("@app/auth/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("apiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset modules to get fresh instance of apiClient
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("should add JWT token to request headers when session exists", async () => {
|
||||
const mockToken = "test-jwt-token-12345";
|
||||
const mockSession = {
|
||||
access_token: mockToken,
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
token_type: "bearer",
|
||||
user: { id: "user-123" },
|
||||
} as unknown as Session;
|
||||
|
||||
// Mock getSession to return a session with token
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: mockSession },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import("@app/services/apiClient");
|
||||
|
||||
// Create a mock adapter to intercept the request
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Verify the Authorization header is set correctly
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${mockToken}`);
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request
|
||||
await apiClient.get("/api/v1/test");
|
||||
|
||||
// Verify the request was made with the token
|
||||
expect(mockAdapter).toHaveBeenCalled();
|
||||
expect(supabase.auth.getSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle requests when no session exists", async () => {
|
||||
// Mock getSession to return no session
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: null },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import("@app/services/apiClient");
|
||||
|
||||
// Create a mock adapter to intercept the request
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Verify no Authorization header is set
|
||||
expect(config.headers.Authorization).toBeUndefined();
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request
|
||||
await apiClient.get("/api/v1/test");
|
||||
|
||||
// Verify the request was made without a token
|
||||
expect(mockAdapter).toHaveBeenCalled();
|
||||
expect(supabase.auth.getSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should refresh token on 401 response", async () => {
|
||||
const oldToken = "old-token";
|
||||
const newToken = "new-refreshed-token";
|
||||
|
||||
const oldSession = {
|
||||
access_token: oldToken,
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
token_type: "bearer",
|
||||
user: { id: "user-123" },
|
||||
} as unknown as Session;
|
||||
|
||||
const newSession = {
|
||||
access_token: newToken,
|
||||
refresh_token: "new-refresh-token",
|
||||
expires_in: 3600,
|
||||
token_type: "bearer",
|
||||
user: { id: "user-123" },
|
||||
} as unknown as Session;
|
||||
|
||||
// Mock initial session for first request
|
||||
let getSessionCallCount = 0;
|
||||
vi.mocked(supabase.auth.getSession).mockImplementation(async () => {
|
||||
getSessionCallCount++;
|
||||
// First call returns old session, subsequent calls return new session
|
||||
if (getSessionCallCount === 1) {
|
||||
return { data: { session: oldSession }, error: null };
|
||||
}
|
||||
return { data: { session: newSession }, error: null };
|
||||
});
|
||||
|
||||
// Mock refresh to return new session
|
||||
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
|
||||
data: { user: null, session: newSession },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import("@app/services/apiClient");
|
||||
|
||||
let requestCount = 0;
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
requestCount++;
|
||||
|
||||
// First request returns 401
|
||||
if (requestCount === 1) {
|
||||
// Verify first request has old token
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${oldToken}`);
|
||||
const error = Object.assign(new Error("Unauthorized"), {
|
||||
response: { status: 401, data: { error: "Unauthorized" } },
|
||||
config,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Second request (after refresh) should have new token
|
||||
// The interceptor will call getSession again, which now returns the new session
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${newToken}`);
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request that will trigger 401 and retry
|
||||
const response = await apiClient.get("/api/v1/test");
|
||||
|
||||
// Verify the token was refreshed and request retried
|
||||
expect(response.data).toEqual({ success: true });
|
||||
expect(supabase.auth.refreshSession).toHaveBeenCalled();
|
||||
expect(mockAdapter).toHaveBeenCalledTimes(2);
|
||||
expect(getSessionCallCount).toBe(3); // Called for initial request, for checking if refresh is possible, and for retry
|
||||
});
|
||||
|
||||
it("should handle refresh token failure", async () => {
|
||||
const oldToken = "old-token";
|
||||
|
||||
const oldSession = {
|
||||
access_token: oldToken,
|
||||
user: { id: "user-123" },
|
||||
};
|
||||
|
||||
// Mock initial session
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: oldSession as unknown as Session },
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: oldSession as unknown as Session },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Mock refresh to fail
|
||||
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
|
||||
data: { user: null, session: null },
|
||||
error: {
|
||||
name: "AuthError",
|
||||
message: "Refresh failed",
|
||||
status: 400,
|
||||
code: "auth_error",
|
||||
} as unknown as AuthError,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import("@app/services/apiClient");
|
||||
|
||||
// Mock window.location for redirect test
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { href: "" },
|
||||
});
|
||||
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Always return 401 to trigger refresh
|
||||
const error = Object.assign(new Error("Unauthorized"), {
|
||||
response: { status: 401, data: { error: "Unauthorized" } },
|
||||
config,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request that will trigger 401
|
||||
try {
|
||||
await apiClient.get("/api/v1/test");
|
||||
// Should not reach here
|
||||
expect(true).toBe(false);
|
||||
} catch (_) {
|
||||
// Verify refresh was attempted
|
||||
expect(supabase.auth.refreshSession).toHaveBeenCalled();
|
||||
// Verify redirect to login
|
||||
expect(window.location.href).toBe("/login");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import axios from "axios";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { handleHttpError } from "@app/services/httpErrorHandler";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
|
||||
// Global credit update callback - will be set by the AuthProvider
|
||||
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
|
||||
|
||||
// Function to set the global credit update callback
|
||||
export const setGlobalCreditUpdateCallback = (
|
||||
callback: (credits: number) => void,
|
||||
) => {
|
||||
globalCreditUpdateCallback = callback;
|
||||
};
|
||||
|
||||
// Helper: decode base64url JWT payload safely
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(
|
||||
base64.length + ((4 - (base64.length % 4)) % 4),
|
||||
"=",
|
||||
);
|
||||
const json =
|
||||
typeof atob !== "undefined"
|
||||
? atob(padded)
|
||||
: Buffer.from(padded, "base64").toString("binary");
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
console.warn("[API Client] Failed to decode JWT payload:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create axios instance with default config
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
responseType: "json",
|
||||
});
|
||||
|
||||
const LOW_CREDIT_THRESHOLD = 10;
|
||||
function notifyLowCredits(credits: number) {
|
||||
const title = "Credit balance low";
|
||||
const body = `You have ${credits} credits remaining.`;
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title,
|
||||
body,
|
||||
buttonText: "Top up",
|
||||
buttonCallback: () => openPlanSettings(),
|
||||
isPersistentPopup: true,
|
||||
location: "bottom-right",
|
||||
});
|
||||
}
|
||||
// Request interceptor to add JWT token to all requests
|
||||
apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
try {
|
||||
// Get the current session from Supabase
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error("[API Client] Error getting session:", error);
|
||||
}
|
||||
|
||||
// If we have a session with an access token, add it to the Authorization header
|
||||
if (session?.access_token) {
|
||||
config.headers.Authorization = `Bearer ${session.access_token}`;
|
||||
const payload = decodeJwtPayload(session.access_token);
|
||||
const role =
|
||||
(payload?.["role"] as string) ||
|
||||
(payload?.["user_role"] as string) ||
|
||||
undefined;
|
||||
const aud = payload?.["aud"] as string | undefined;
|
||||
const isAnon = role === "anon" || aud === "anon";
|
||||
|
||||
// Debug logs for visibility during integration
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug("[API Client] Added JWT token to request:", config.url);
|
||||
console.debug("[API Client] JWT payload:", payload);
|
||||
console.debug(
|
||||
"[API Client] Token role:",
|
||||
role,
|
||||
"| aud:",
|
||||
aud,
|
||||
"| isAnon:",
|
||||
isAnon,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.debug(
|
||||
"[API Client] No JWT token available for request:",
|
||||
config.url,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[API Client] Error in request interceptor:", error);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// List of endpoints that don't require authentication
|
||||
const publicEndpoints = [
|
||||
"/api/v1/config/app-config",
|
||||
"/api/v1/info/status",
|
||||
"/api/v1/config/public-config",
|
||||
"/api/v1/config/endpoints-enabled",
|
||||
];
|
||||
|
||||
// Response interceptor for handling token refresh and credit updates
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Check for X-Credits-Remaining header and update credits automatically
|
||||
const creditsRemaining = response.headers["x-credits-remaining"];
|
||||
if (creditsRemaining && globalCreditUpdateCallback) {
|
||||
const credits = parseInt(creditsRemaining, 10);
|
||||
if (!isNaN(credits) && credits >= 0) {
|
||||
console.debug(
|
||||
"[API Client] Updating credits from response header:",
|
||||
credits,
|
||||
"for URL:",
|
||||
response.config?.url,
|
||||
);
|
||||
globalCreditUpdateCallback(credits);
|
||||
// Show low-credit toast with top-up button when below threshold
|
||||
if (credits < LOW_CREDIT_THRESHOLD) {
|
||||
notifyLowCredits(credits);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"[API Client] Invalid credits value in response header:",
|
||||
creditsRemaining,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.config?.url?.includes("/api/v1/credits")) {
|
||||
console.debug(
|
||||
"[API Client] Credits endpoint response headers:",
|
||||
response.headers,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
const isPublicEndpoint = publicEndpoints.some((endpoint) =>
|
||||
originalRequest.url?.includes(endpoint),
|
||||
);
|
||||
|
||||
// If we get a 401 and haven't already tried to refresh, and it's not a public endpoint
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!isPublicEndpoint
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Check if we have a session to refresh
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
// Only try to refresh if we actually have a session
|
||||
if (session) {
|
||||
const {
|
||||
data: { session: refreshedSession },
|
||||
error: refreshError,
|
||||
} = await supabase.auth.refreshSession();
|
||||
|
||||
if (refreshError) {
|
||||
console.error("[API Client] Token refresh failed:", refreshError);
|
||||
|
||||
// Only redirect to login for protected endpoints, not public ones
|
||||
const isPublicEndpoint =
|
||||
originalRequest.url?.includes("/api/v1/config/") ||
|
||||
originalRequest.url?.includes("/api/v1/info/");
|
||||
|
||||
if (!isPublicEndpoint) {
|
||||
// Redirect to login only for protected endpoints
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (refreshedSession?.access_token) {
|
||||
// Update the Authorization header with the new token
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${refreshedSession.access_token}`;
|
||||
console.debug("[API Client] Retrying request with refreshed token");
|
||||
|
||||
// Retry the original request with the new token
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} else {
|
||||
// No session exists, only redirect if not already on login page
|
||||
console.debug(
|
||||
"[API Client] No session to refresh, 401 on protected endpoint",
|
||||
);
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error("[API Client] Error during token refresh:", refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// For public endpoints with 401, just log and continue (don't redirect)
|
||||
if (isPublicEndpoint && error.response?.status === 401) {
|
||||
console.debug(
|
||||
"[API Client] 401 on public endpoint, continuing without auth:",
|
||||
originalRequest.url,
|
||||
);
|
||||
}
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
const method = error.config?.method?.toUpperCase();
|
||||
|
||||
console.error("[API Client] HTTP Error", {
|
||||
status,
|
||||
method,
|
||||
url,
|
||||
error: error.message,
|
||||
data: error.response?.data,
|
||||
});
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Avatar sync service for OAuth provider profile pictures
|
||||
* Downloads, optimizes, and syncs profile pictures from OAuth providers
|
||||
*/
|
||||
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
|
||||
const PROFILE_BUCKET = "profile-pictures";
|
||||
const AVATAR_SIZE = 256; // 256x256 pixels
|
||||
const MAX_AVATAR_SIZE = 500 * 1024; // 500KB max file size after optimization
|
||||
const SYNC_INTERVAL_DAYS = 7; // Resync every 7 days
|
||||
|
||||
// Client-side cache to prevent repeated sync attempts in same browser session
|
||||
const sessionSyncCache = new Map<
|
||||
string,
|
||||
{ timestamp: number; success: boolean }
|
||||
>();
|
||||
|
||||
export interface ProfilePictureMetadata {
|
||||
user_id: string;
|
||||
source: "oauth" | "upload";
|
||||
provider: "google" | "github" | "apple" | "azure" | null;
|
||||
last_synced_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract avatar URL from OAuth provider user metadata
|
||||
* @param user Supabase User object
|
||||
* @returns Avatar URL or null if not available
|
||||
*/
|
||||
export function getProviderAvatarUrl(user: User): string | null {
|
||||
const provider = user.app_metadata?.provider;
|
||||
const metadata = user.user_metadata;
|
||||
|
||||
if (!provider || !metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "google":
|
||||
case "azure":
|
||||
// Google and Azure use 'picture' field
|
||||
return metadata.picture || null;
|
||||
case "github":
|
||||
// GitHub uses 'avatar_url' field
|
||||
return metadata.avatar_url || null;
|
||||
case "apple":
|
||||
// Apple doesn't provide profile pictures via OAuth
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and optimize an avatar image
|
||||
* Resizes to 256x256 and converts to PNG format
|
||||
* @param url Avatar URL from OAuth provider
|
||||
* @returns Optimized image blob
|
||||
*/
|
||||
export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
|
||||
try {
|
||||
// 1. Fetch image from provider URL
|
||||
const response = await fetch(url, {
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download avatar: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// 2. Create image bitmap
|
||||
const img = await createImageBitmap(blob);
|
||||
|
||||
// 3. Create canvas and draw scaled image
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = AVATAR_SIZE;
|
||||
canvas.height = AVATAR_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("Failed to get canvas context");
|
||||
}
|
||||
|
||||
// Draw image scaled to fit (maintains aspect ratio, centered)
|
||||
const scale = Math.min(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height);
|
||||
const x = (AVATAR_SIZE - img.width * scale) / 2;
|
||||
const y = (AVATAR_SIZE - img.height * scale) / 2;
|
||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
|
||||
|
||||
// 4. Convert to PNG blob with quality optimization
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(optimizedBlob) => {
|
||||
if (!optimizedBlob) {
|
||||
reject(new Error("Failed to create optimized blob"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (optimizedBlob.size > MAX_AVATAR_SIZE) {
|
||||
console.warn(
|
||||
"[Avatar Sync] Optimized avatar exceeds max size:",
|
||||
optimizedBlob.size,
|
||||
);
|
||||
// Try with lower quality
|
||||
canvas.toBlob(
|
||||
(lowerQualityBlob) => {
|
||||
if (lowerQualityBlob) {
|
||||
resolve(lowerQualityBlob);
|
||||
} else {
|
||||
reject(new Error("Failed to create lower quality blob"));
|
||||
}
|
||||
},
|
||||
"image/png",
|
||||
0.7,
|
||||
);
|
||||
} else {
|
||||
resolve(optimizedBlob);
|
||||
}
|
||||
},
|
||||
"image/png",
|
||||
0.9,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Avatar Sync] Failed to download and optimize avatar:",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload avatar blob to Supabase Storage
|
||||
* @param userId User ID
|
||||
* @param blob Optimized avatar blob
|
||||
*/
|
||||
export async function uploadAvatarToStorage(
|
||||
userId: string,
|
||||
blob: Blob,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const profilePath = `${userId}/avatar`;
|
||||
|
||||
console.debug("[Avatar Sync] Uploading avatar to storage:", profilePath);
|
||||
|
||||
// Upload to Supabase Storage (overwrites existing file)
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.upload(profilePath, blob, {
|
||||
upsert: true, // Overwrite existing file
|
||||
contentType: "image/png",
|
||||
cacheControl: "3600", // Cache for 1 hour
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
console.debug("[Avatar Sync] Avatar uploaded successfully");
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Failed to upload avatar to storage:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profile picture metadata for a user
|
||||
* @param userId User ID
|
||||
* @returns Metadata or null if not found
|
||||
*/
|
||||
export async function getProfilePictureMetadata(
|
||||
userId: string,
|
||||
): Promise<ProfilePictureMetadata | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("profile_picture_metadata")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
// If table doesn't exist, that's expected before migration runs
|
||||
if (
|
||||
error.code === "PGRST116" ||
|
||||
error.message?.includes("does not exist")
|
||||
) {
|
||||
console.debug(
|
||||
"[Avatar Sync] Metadata table not found - migration may not be applied yet",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
console.error(
|
||||
"[Avatar Sync] Failed to fetch profile picture metadata:",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Unexpected error fetching metadata:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or insert profile picture metadata
|
||||
* @param userId User ID
|
||||
* @param data Partial metadata to update
|
||||
*/
|
||||
export async function updateProfilePictureMetadata(
|
||||
userId: string,
|
||||
data: Partial<
|
||||
Omit<ProfilePictureMetadata, "user_id" | "created_at" | "updated_at">
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { error } = await supabase.from("profile_picture_metadata").upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
onConflict: "user_id",
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
// If table doesn't exist, log but don't crash
|
||||
if (
|
||||
error.code === "PGRST116" ||
|
||||
error.message?.includes("does not exist")
|
||||
) {
|
||||
console.warn(
|
||||
"[Avatar Sync] Cannot update metadata - table does not exist. Run migration first.",
|
||||
);
|
||||
return; // Don't throw, allow feature to work without metadata tracking
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.debug("[Avatar Sync] Metadata updated successfully");
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Failed to update metadata:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to sync OAuth avatar for a user
|
||||
* Downloads avatar from OAuth provider and uploads to Supabase Storage
|
||||
* Only syncs if:
|
||||
* - User is authenticated via OAuth provider that supports avatars
|
||||
* - User hasn't manually uploaded a picture (source !== 'upload')
|
||||
* - Last sync was more than SYNC_INTERVAL_DAYS ago (or never synced)
|
||||
*
|
||||
* @param user Supabase User object
|
||||
* @returns true if sync was performed, false if skipped
|
||||
*/
|
||||
export async function syncOAuthAvatar(user: User): Promise<boolean> {
|
||||
const cacheKey = user.id;
|
||||
|
||||
try {
|
||||
// 0. Check client-side session cache first (prevent repeated attempts)
|
||||
const cached = sessionSyncCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const minutesSinceLastAttempt =
|
||||
(Date.now() - cached.timestamp) / (1000 * 60);
|
||||
if (minutesSinceLastAttempt < 60) {
|
||||
console.debug(
|
||||
"[Avatar Sync] Skipping sync - already attempted in this session:",
|
||||
{
|
||||
minutesAgo: minutesSinceLastAttempt.toFixed(1),
|
||||
lastSuccess: cached.success,
|
||||
},
|
||||
);
|
||||
return cached.success;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Check if user is OAuth authenticated
|
||||
const provider = user.app_metadata?.provider;
|
||||
console.debug("[Avatar Sync] Checking user for sync:", {
|
||||
provider,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
hasUserMetadata: !!user.user_metadata,
|
||||
userMetadataKeys: user.user_metadata
|
||||
? Object.keys(user.user_metadata)
|
||||
: [],
|
||||
});
|
||||
|
||||
if (!provider || !["google", "github", "azure"].includes(provider)) {
|
||||
console.debug(
|
||||
"[Avatar Sync] Skipping sync - not an OAuth provider with avatar support",
|
||||
);
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Get metadata to check if sync is needed
|
||||
const metadata = await getProfilePictureMetadata(user.id);
|
||||
|
||||
// Skip if user has manually uploaded a picture
|
||||
if (metadata?.source === "upload") {
|
||||
console.debug("[Avatar Sync] Skipping sync - user has manual upload");
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if synced recently (within SYNC_INTERVAL_DAYS)
|
||||
if (metadata?.last_synced_at) {
|
||||
const lastSync = new Date(metadata.last_synced_at);
|
||||
const daysSinceSync =
|
||||
(Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceSync < SYNC_INTERVAL_DAYS) {
|
||||
console.debug("[Avatar Sync] Skipping sync - synced recently:", {
|
||||
daysSinceSync: daysSinceSync.toFixed(1),
|
||||
threshold: SYNC_INTERVAL_DAYS,
|
||||
});
|
||||
sessionSyncCache.set(cacheKey, {
|
||||
timestamp: Date.now(),
|
||||
success: true,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extract provider avatar URL
|
||||
const avatarUrl = getProviderAvatarUrl(user);
|
||||
console.debug("[Avatar Sync] Avatar URL extraction:", {
|
||||
provider,
|
||||
avatarUrl,
|
||||
hasAvatarUrl: !!avatarUrl,
|
||||
});
|
||||
|
||||
if (!avatarUrl) {
|
||||
console.debug("[Avatar Sync] No avatar URL available from provider");
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
"[Avatar Sync] Starting sync for provider:",
|
||||
provider,
|
||||
"with URL:",
|
||||
avatarUrl,
|
||||
);
|
||||
|
||||
// 4. Download and optimize avatar
|
||||
const optimizedBlob = await downloadAndOptimizeAvatar(avatarUrl);
|
||||
|
||||
// 5. Upload to Supabase Storage
|
||||
await uploadAvatarToStorage(user.id, optimizedBlob);
|
||||
|
||||
// 6. Update metadata
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: "oauth",
|
||||
provider: provider as ProfilePictureMetadata["provider"],
|
||||
last_synced_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.debug("[Avatar Sync] Sync completed successfully");
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Failed to sync OAuth avatar:", error);
|
||||
// Cache the failure to prevent repeated attempts
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
// Don't throw - gracefully degrade to existing picture or initials
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { SavedSignature } from "@app/hooks/tools/sign/useSavedSignatures";
|
||||
|
||||
export type StorageType = "backend" | "localStorage";
|
||||
|
||||
interface SignatureStorageCapabilities {
|
||||
supportsBackend: boolean;
|
||||
storageType: StorageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS-specific signature storage service that always uses localStorage.
|
||||
*
|
||||
* In SaaS mode, the proprietary backend signature API is not available
|
||||
* (requires Spring Security JWT, not Supabase JWT), so we skip detection
|
||||
* and force localStorage-only mode to avoid unnecessary 401/403 errors.
|
||||
*/
|
||||
class SignatureStorageService {
|
||||
private capabilities: SignatureStorageCapabilities | null = null;
|
||||
private blobUrls: Set<string> = new Set();
|
||||
private readonly STORAGE_KEY = "stirling:saved-signatures:v1";
|
||||
|
||||
/**
|
||||
* Detect capabilities - in SaaS mode, always returns localStorage
|
||||
*/
|
||||
async detectCapabilities(): Promise<SignatureStorageCapabilities> {
|
||||
if (this.capabilities) {
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
// SaaS mode always uses localStorage (no backend signature API available)
|
||||
console.log(
|
||||
"[SignatureStorage] SaaS mode - using localStorage (backend not available)",
|
||||
);
|
||||
this.capabilities = {
|
||||
supportsBackend: false,
|
||||
storageType: "localStorage",
|
||||
};
|
||||
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current storage type
|
||||
*/
|
||||
async getStorageType(): Promise<StorageType> {
|
||||
const capabilities = await this.detectCapabilities();
|
||||
return capabilities.storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all signatures
|
||||
*/
|
||||
async loadSignatures(): Promise<SavedSignature[]> {
|
||||
// Clean up old blob URLs before loading new ones
|
||||
this.cleanup();
|
||||
|
||||
// Always use localStorage in SaaS mode
|
||||
return this._loadFromLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a signature
|
||||
*/
|
||||
async saveSignature(signature: SavedSignature): Promise<void> {
|
||||
// Force scope to localStorage for SaaS mode
|
||||
signature.scope = "localStorage";
|
||||
this._saveToLocalStorage(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a signature
|
||||
*/
|
||||
async deleteSignature(id: string): Promise<void> {
|
||||
this._deleteFromLocalStorage(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update signature label
|
||||
*/
|
||||
async updateSignatureLabel(id: string, label: string): Promise<void> {
|
||||
this._updateLabelInLocalStorage(id, label);
|
||||
}
|
||||
|
||||
// LocalStorage methods
|
||||
private _loadFromLocalStorage(): SavedSignature[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const signatures = JSON.parse(raw);
|
||||
// Ensure all localStorage signatures have the correct scope
|
||||
return signatures.map((sig: SavedSignature) => ({
|
||||
...sig,
|
||||
scope: "localStorage" as const,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private _saveToLocalStorage(signature: SavedSignature): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const index = signatures.findIndex((s) => s.id === signature.id);
|
||||
|
||||
if (index >= 0) {
|
||||
signatures[index] = signature;
|
||||
} else {
|
||||
signatures.unshift(signature);
|
||||
}
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
|
||||
}
|
||||
|
||||
private _deleteFromLocalStorage(id: string): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const filtered = signatures.filter((s) => s.id !== id);
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
private _updateLabelInLocalStorage(id: string, label: string): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const signature = signatures.find((s) => s.id === id);
|
||||
if (signature) {
|
||||
signature.label = label;
|
||||
signature.updatedAt = Date.now();
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate signatures from localStorage to backend
|
||||
* In SaaS mode, this is a no-op since we don't support backend storage
|
||||
*/
|
||||
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
|
||||
console.log("[SignatureStorage] Migration not supported in SaaS mode");
|
||||
return { migrated: 0, failed: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up blob URLs to prevent memory leaks
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.blobUrls.forEach((url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
this.blobUrls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const signatureStorageService = new SignatureStorageService();
|
||||
@@ -0,0 +1,289 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
supabaseId?: string | null;
|
||||
roleName: string; // Translation key like "adminUserSettings.admin"
|
||||
rolesAsString?: string; // Actual role ID like "ROLE_ADMIN"
|
||||
enabled: boolean;
|
||||
isFirstLogin?: boolean;
|
||||
authenticationType?: string;
|
||||
team?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
// Enriched client-side fields
|
||||
isActive?: boolean;
|
||||
lastRequest?: number; // timestamp in milliseconds
|
||||
}
|
||||
|
||||
export interface AdminSettingsData {
|
||||
users: User[];
|
||||
userSessions: Record<string, boolean>;
|
||||
userLastRequest: Record<string, number>; // username -> timestamp in milliseconds
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
disabledUsers: number;
|
||||
currentUsername?: string;
|
||||
roleDetails?: Record<string, string>;
|
||||
teams?: Array<{ id: number; name: string; [key: string]: unknown }>;
|
||||
maxPaidUsers?: number;
|
||||
// License information
|
||||
maxAllowedUsers: number;
|
||||
availableSlots: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
password?: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
authType: "password" | "SSO";
|
||||
forceChange?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleRequest {
|
||||
username: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export interface InviteUsersRequest {
|
||||
emails: string; // Comma-separated email addresses
|
||||
role: string;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export interface InviteUsersResponse {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
message?: string;
|
||||
errors?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InviteLinkRequest {
|
||||
email?: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
expiryHours?: number;
|
||||
sendEmail?: boolean;
|
||||
}
|
||||
|
||||
export interface InviteLinkResponse {
|
||||
token: string;
|
||||
inviteUrl: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
expiryHours: number;
|
||||
emailSent?: boolean;
|
||||
emailError?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InviteToken {
|
||||
id: number;
|
||||
email: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Management Service
|
||||
* Provides functions to interact with user management backend APIs
|
||||
*/
|
||||
export const userManagementService = {
|
||||
/**
|
||||
* Get all users with session data (admin only)
|
||||
*/
|
||||
async getUsers(): Promise<AdminSettingsData> {
|
||||
const response = await apiClient.get<AdminSettingsData>(
|
||||
"/api/v1/proprietary/ui-data/admin-settings",
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get users without a team
|
||||
*/
|
||||
async getUsersWithoutTeam(): Promise<User[]> {
|
||||
const response = await apiClient.get<User[]>("/api/v1/users/without-team");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user (admin only)
|
||||
*/
|
||||
async createUser(data: CreateUserRequest): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append("username", data.username);
|
||||
if (data.password) {
|
||||
formData.append("password", data.password);
|
||||
}
|
||||
formData.append("role", data.role);
|
||||
if (data.teamId) {
|
||||
formData.append("teamId", data.teamId.toString());
|
||||
}
|
||||
formData.append("authType", data.authType);
|
||||
if (data.forceChange !== undefined) {
|
||||
formData.append("forceChange", data.forceChange.toString());
|
||||
}
|
||||
await apiClient.post("/api/v1/user/admin/saveUser", formData, {
|
||||
suppressErrorToast: true, // Component will handle error display
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update user role and/or team (admin only)
|
||||
*/
|
||||
async updateUserRole(data: UpdateUserRoleRequest): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append("username", data.username);
|
||||
formData.append("role", data.role);
|
||||
if (data.teamId) {
|
||||
formData.append("teamId", data.teamId.toString());
|
||||
}
|
||||
await apiClient.post("/api/v1/user/admin/changeRole", formData, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable or disable a user (admin only)
|
||||
*/
|
||||
async toggleUserEnabled(username: string, enabled: boolean): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append("enabled", enabled.toString());
|
||||
await apiClient.post(
|
||||
`/api/v1/user/admin/changeUserEnabled/${username}`,
|
||||
formData,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a user (admin only)
|
||||
*/
|
||||
async deleteUser(
|
||||
user: User,
|
||||
options?: { notifyUser?: boolean },
|
||||
): Promise<void> {
|
||||
if (isSupabaseConfigured && supabase) {
|
||||
if (!user.email) {
|
||||
throw new Error(
|
||||
"Email missing for this user. Please contact support for manual removal.",
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await supabase.functions.invoke("delete-user", {
|
||||
body: {
|
||||
target_email: user.email,
|
||||
notify_user: options?.notifyUser ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Supabase deletion failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Invite users via email (admin only)
|
||||
* Sends comma-separated email addresses, creates accounts with random passwords,
|
||||
* and sends invitation emails
|
||||
*/
|
||||
async inviteUsers(data: InviteUsersRequest): Promise<InviteUsersResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("emails", data.emails);
|
||||
formData.append("role", data.role);
|
||||
if (data.teamId) {
|
||||
formData.append("teamId", data.teamId.toString());
|
||||
}
|
||||
|
||||
const response = await apiClient.post<InviteUsersResponse>(
|
||||
"/api/v1/user/admin/inviteUsers",
|
||||
formData,
|
||||
{
|
||||
suppressErrorToast: true, // Component will handle error display
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an invite link (admin only)
|
||||
*/
|
||||
async generateInviteLink(
|
||||
data: InviteLinkRequest,
|
||||
): Promise<InviteLinkResponse> {
|
||||
const formData = new FormData();
|
||||
// Only append email if it's provided and not empty
|
||||
if (data.email && data.email.trim()) {
|
||||
formData.append("email", data.email);
|
||||
}
|
||||
formData.append("role", data.role);
|
||||
if (data.teamId) {
|
||||
formData.append("teamId", data.teamId.toString());
|
||||
}
|
||||
if (data.expiryHours) {
|
||||
formData.append("expiryHours", data.expiryHours.toString());
|
||||
}
|
||||
if (data.sendEmail !== undefined) {
|
||||
formData.append("sendEmail", data.sendEmail.toString());
|
||||
}
|
||||
|
||||
const response = await apiClient.post<InviteLinkResponse>(
|
||||
"/api/v1/invite/generate",
|
||||
formData,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of active invite links (admin only)
|
||||
*/
|
||||
async getInviteLinks(): Promise<InviteToken[]> {
|
||||
const response = await apiClient.get<{ invites: InviteToken[] }>(
|
||||
"/api/v1/invite/list",
|
||||
);
|
||||
return response.data.invites;
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoke an invite link (admin only)
|
||||
*/
|
||||
async revokeInviteLink(inviteId: number): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clean up expired invite links (admin only)
|
||||
*/
|
||||
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
|
||||
const response = await apiClient.post<{ deletedCount: number }>(
|
||||
"/api/v1/invite/cleanup",
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* User service for handling user-related API calls
|
||||
*/
|
||||
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
/**
|
||||
* Synchronizes user upgrade from anonymous to authenticated status with the backend.
|
||||
* This should be called after Supabase has successfully upgraded the user.
|
||||
* Only the current user can upgrade their own account - the backend determines
|
||||
* the user from the security context and derives email from SupabaseUser.
|
||||
*
|
||||
* @param authMethod - The authentication method used (e.g., "email", "google", "github", "apple", "azure")
|
||||
* @returns Promise with the synchronization result
|
||||
*/
|
||||
export const synchronizeUserUpgrade = async (
|
||||
authMethod?: string,
|
||||
): Promise<{
|
||||
message: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}> => {
|
||||
const formData = new URLSearchParams();
|
||||
if (authMethod) {
|
||||
formData.append("authMethod", authMethod);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/user-role/promptToAuthUser`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
credentials: "include", // Include cookies for authentication
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "Failed to synchronize user upgrade" }));
|
||||
throw new Error(errorData.error || "Failed to synchronize user upgrade");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock localStorage for tests
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] || null),
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(global, "localStorage", { value: localStorageMock });
|
||||
|
||||
// Mock Supabase for tests
|
||||
vi.mock("@app/auth/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { session: null }, error: null }),
|
||||
refreshSession: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { session: null }, error: null }),
|
||||
onAuthStateChange: vi
|
||||
.fn()
|
||||
.mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } }),
|
||||
},
|
||||
},
|
||||
debugAuthEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock i18next for tests
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: "3rdParty",
|
||||
init: vi.fn(),
|
||||
},
|
||||
I18nextProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Mock i18next-http-backend
|
||||
vi.mock("i18next-http-backend", () => ({
|
||||
default: {
|
||||
type: "backend",
|
||||
init: vi.fn(),
|
||||
read: vi.fn(),
|
||||
save: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock window.URL.createObjectURL and revokeObjectURL for tests
|
||||
global.URL.createObjectURL = vi.fn(() => "mocked-url");
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
// Mock File and Blob API methods that aren't available in jsdom
|
||||
if (!globalThis.File.prototype.arrayBuffer) {
|
||||
globalThis.File.prototype.arrayBuffer = function () {
|
||||
// Return a simple ArrayBuffer with some mock data
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const view = new Uint8Array(buffer);
|
||||
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
return Promise.resolve(buffer);
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.Blob.prototype.arrayBuffer) {
|
||||
globalThis.Blob.prototype.arrayBuffer = function () {
|
||||
// Return a simple ArrayBuffer with some mock data
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const view = new Uint8Array(buffer);
|
||||
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
return Promise.resolve(buffer);
|
||||
};
|
||||
}
|
||||
|
||||
// Mock crypto.subtle for hashing in tests - force override even if exists
|
||||
const mockHashBuffer = new ArrayBuffer(32);
|
||||
const mockHashView = new Uint8Array(mockHashBuffer);
|
||||
// Fill with predictable mock hash data
|
||||
for (let i = 0; i < 32; i++) {
|
||||
mockHashView[i] = i;
|
||||
}
|
||||
|
||||
// Force override crypto.subtle to avoid Node.js native implementation
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value: {
|
||||
subtle: {
|
||||
digest: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_algorithm: string, _data: BufferSource) => {
|
||||
// Always return the mock hash buffer regardless of input
|
||||
return mockHashBuffer.slice();
|
||||
}),
|
||||
},
|
||||
getRandomValues: vi
|
||||
.fn()
|
||||
.mockImplementation(<T extends ArrayBufferView>(array: T): T => {
|
||||
// Mock getRandomValues if needed
|
||||
const view = new Uint8Array(
|
||||
array.buffer,
|
||||
array.byteOffset,
|
||||
array.byteLength,
|
||||
);
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
view[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return array;
|
||||
}),
|
||||
} as unknown as Crypto,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock Worker for tests (Web Workers not available in test environment)
|
||||
global.Worker = vi.fn().mockImplementation(() => ({
|
||||
postMessage: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver for Mantine components
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock IntersectionObserver for components that might use it
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock matchMedia for responsive components
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock DOMMatrix for PDF.js tests
|
||||
Object.defineProperty(global, "DOMMatrix", {
|
||||
value: class MockDOMMatrix {
|
||||
a = 1;
|
||||
b = 0;
|
||||
c = 0;
|
||||
d = 1;
|
||||
e = 0;
|
||||
f = 0;
|
||||
m11 = 1;
|
||||
m12 = 0;
|
||||
m13 = 0;
|
||||
m14 = 0;
|
||||
m21 = 0;
|
||||
m22 = 1;
|
||||
m23 = 0;
|
||||
m24 = 0;
|
||||
m31 = 0;
|
||||
m32 = 0;
|
||||
m33 = 1;
|
||||
m34 = 0;
|
||||
m41 = 0;
|
||||
m42 = 0;
|
||||
m43 = 0;
|
||||
m44 = 1;
|
||||
is2D = true;
|
||||
isIdentity = true;
|
||||
|
||||
toString() {
|
||||
return "matrix(1, 0, 0, 1, 0, 0)";
|
||||
}
|
||||
scale() {
|
||||
return this;
|
||||
}
|
||||
translate() {
|
||||
return this;
|
||||
}
|
||||
rotate() {
|
||||
return this;
|
||||
}
|
||||
inverse() {
|
||||
return this;
|
||||
}
|
||||
multiply() {
|
||||
return this;
|
||||
}
|
||||
|
||||
static fromFloat32Array() {
|
||||
return new MockDOMMatrix();
|
||||
}
|
||||
static fromFloat64Array() {
|
||||
return new MockDOMMatrix();
|
||||
}
|
||||
static fromMatrix() {
|
||||
return new MockDOMMatrix();
|
||||
}
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Set global test timeout to prevent hangs
|
||||
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 });
|
||||
@@ -0,0 +1,150 @@
|
||||
/* SaaS-specific CSS variables — imported alongside core theme.css */
|
||||
|
||||
:root {
|
||||
/* Orange scale (used for warning toasts) */
|
||||
--color-orange-50: #fff4ed;
|
||||
--color-orange-100: #ffe1cc;
|
||||
--color-orange-200: #ffb089;
|
||||
--color-orange-300: #ff7a45;
|
||||
--color-orange-400: #d84a1b;
|
||||
|
||||
/* Amber scale (trial/warning emphasis) */
|
||||
--color-amber-50: #fffbeb;
|
||||
--color-amber-100: #fef3c7;
|
||||
--color-amber-200: #fde68a;
|
||||
--color-amber-300: #fcd34d;
|
||||
--color-amber-400: #fbbf24;
|
||||
--color-amber-500: #f59e0b;
|
||||
--color-amber-600: #d97706;
|
||||
--color-amber-700: #b45309;
|
||||
--color-amber-800: #92400e;
|
||||
--color-amber-900: #78350f;
|
||||
|
||||
/* Subcategory / divider vars (light) */
|
||||
--tool-subcategory-text-color-light: #9ca3af;
|
||||
--tool-subcategory-rule-color-light: #e5e7eb;
|
||||
--text-divider-rule-color: var(--color-gray-200);
|
||||
--text-divider-label-color: var(--color-gray-400);
|
||||
--text-divider-rule-color-light: var(--color-gray-200);
|
||||
--text-divider-label-color-light: var(--color-gray-400);
|
||||
|
||||
/* Auth color vars */
|
||||
--auth-input-bg: #f9fafb;
|
||||
--auth-input-border: #e5e7eb;
|
||||
--auth-input-text: #1f2937;
|
||||
--auth-label-text: #2b3230;
|
||||
--auth-button-bg: #af3434;
|
||||
--auth-button-text: #ffffff;
|
||||
--auth-magic-button-bg: #8b5cf6;
|
||||
--auth-magic-button-text: #ffffff;
|
||||
|
||||
/* Light-only auth colors (no dark mode equivalents) used for login/signup */
|
||||
--auth-input-bg-light-only: #f9fafb;
|
||||
--auth-input-border-light-only: #e5e7eb;
|
||||
--auth-input-text-light-only: #1f2937;
|
||||
--auth-label-text-light-only: #2b3230;
|
||||
--auth-button-bg-light-only: #af3434;
|
||||
--auth-button-text-light-only: #ffffff;
|
||||
--auth-magic-button-bg-light-only: #8b5cf6;
|
||||
--auth-magic-button-text-light-only: #ffffff;
|
||||
--auth-bg-color-light-only: #ffffff;
|
||||
--auth-card-bg-light-only: #ffffff;
|
||||
--auth-text-primary-light-only: #2b3230;
|
||||
--auth-text-secondary-light-only: #1f2937;
|
||||
--auth-border-color-light-only: #e5e7eb;
|
||||
--auth-border-focus-light-only: #cbd5e1;
|
||||
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.15);
|
||||
|
||||
/* App Config Modal colors (light mode) */
|
||||
--modal-nav-bg: #f5f6f8;
|
||||
--modal-nav-section-title: #6b7280;
|
||||
--modal-nav-item: #374151;
|
||||
--modal-nav-item-active: #0a8bff;
|
||||
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
|
||||
--modal-content-bg: #ffffff;
|
||||
--modal-header-border: rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* API usage progress bar colors (light mode) */
|
||||
--usage-weekly-active: #3b82f6;
|
||||
--usage-bought-active: #14b8a6;
|
||||
--usage-total-used: #3b82f6;
|
||||
--usage-inactive: #e5e7eb;
|
||||
|
||||
/* API Keys section colors (light mode) */
|
||||
--api-keys-card-bg: #ffffff;
|
||||
--api-keys-card-border: #e0e0e0;
|
||||
--api-keys-card-shadow: rgba(0, 0, 0, 0.06);
|
||||
--api-keys-input-bg: #f8f8f8;
|
||||
--api-keys-input-border: #e0e0e0;
|
||||
--api-keys-button-bg: #f5f5f5;
|
||||
--api-keys-button-color: #333333;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
/* Compare highlight colors (dark mode) */
|
||||
--spdf-compare-removed-bg: rgba(255, 107, 107, 0.45);
|
||||
--spdf-compare-added-bg: rgba(81, 207, 102, 0.35);
|
||||
--spdf-compare-removed-badge-bg: rgba(255, 59, 48, 0.15);
|
||||
--spdf-compare-removed-badge-fg: var(--color-red-500);
|
||||
--spdf-compare-added-badge-bg: rgba(52, 199, 89, 0.18);
|
||||
--spdf-compare-added-badge-fg: var(--color-green-500);
|
||||
--spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25);
|
||||
--spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25);
|
||||
--compare-page-label-bg: #2a2f36;
|
||||
--compare-page-label-fg: #d0d6dc;
|
||||
|
||||
/* Orange scale (dark mode mirrors light values to match UI) */
|
||||
--color-orange-50: #fff4ed;
|
||||
--color-orange-100: #ffe1cc;
|
||||
--color-orange-200: #ffb089;
|
||||
--color-orange-300: #ff7a45;
|
||||
--color-orange-400: #d84a1b;
|
||||
|
||||
/* Auth page colors (light mode only - auth pages force light mode) */
|
||||
--auth-bg-color-light-only: #f3f4f6;
|
||||
--auth-card-bg: #ffffff;
|
||||
--auth-card-bg-light-only: #ffffff;
|
||||
--auth-label-text-light-only: #374151;
|
||||
--auth-input-border-light-only: #d1d5db;
|
||||
--auth-input-bg-light-only: #ffffff;
|
||||
--auth-input-text-light-only: #111827;
|
||||
--auth-border-focus-light-only: #3b82f6;
|
||||
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.1);
|
||||
--auth-button-bg-light-only: #af3434;
|
||||
--auth-button-text-light-only: #ffffff;
|
||||
--auth-magic-button-bg-light-only: #e5e7eb;
|
||||
--auth-magic-button-text-light-only: #374151;
|
||||
--auth-text-primary-light-only: #111827;
|
||||
--auth-text-secondary-light-only: #6b7280;
|
||||
--text-divider-rule-rgb-light: 229, 231, 235;
|
||||
--text-divider-label-rgb-light: 156, 163, 175;
|
||||
--tool-subcategory-rule-color-light: #e5e7eb;
|
||||
--tool-subcategory-text-color-light: #9ca3af;
|
||||
|
||||
/* API usage progress bar colors (dark mode) */
|
||||
--usage-weekly-active: #60a5fa;
|
||||
--usage-bought-active: #34d399;
|
||||
--usage-total-used: #ffffff;
|
||||
--usage-inactive: #43464b;
|
||||
|
||||
/* API Keys section colors (dark mode) */
|
||||
--api-keys-card-bg: #2a2f36;
|
||||
--api-keys-card-border: #3a4047;
|
||||
--api-keys-card-shadow: none;
|
||||
--api-keys-input-bg: #1f2329;
|
||||
--api-keys-input-border: #3a4047;
|
||||
--api-keys-button-bg: #3a4047;
|
||||
--api-keys-button-color: #d0d6dc;
|
||||
|
||||
--text-divider-rule-color: var(--tool-subcategory-rule-color);
|
||||
--text-divider-label-color: var(--text-muted);
|
||||
|
||||
/* App Config Modal colors (dark mode) */
|
||||
--modal-nav-bg: #1f2329;
|
||||
--modal-nav-section-title: #9ca3af;
|
||||
--modal-nav-item: #d0d6dc;
|
||||
--modal-nav-item-active: #0a8bff;
|
||||
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
|
||||
--modal-content-bg: #2a2f36;
|
||||
--modal-header-border: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Centralized z-index constants for new usages added in this branch.
|
||||
// Keep values identical to their original inline usages.
|
||||
|
||||
// Re-export all core z-index constants
|
||||
export * from "@core/styles/zIndex";
|
||||
|
||||
// SaaS-specific z-index constants
|
||||
export const Z_ANALYTICS_MODAL = 1301;
|
||||
export const Z_INDEX_OVER_SETTINGS_MODAL = 1400;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"],
|
||||
"@proprietary/*": ["src/proprietary/*"],
|
||||
"@core/*": ["src/core/*"]
|
||||
}
|
||||
},
|
||||
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface FractionData {
|
||||
name: string;
|
||||
numerator: number;
|
||||
denominator: number;
|
||||
numeratorLabel: string;
|
||||
denominatorLabel: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type TooltipPosition = "top" | "bottom" | "left" | "right";
|
||||
|
||||
export interface StackedBarChartProps {
|
||||
fractions: FractionData[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
className?: string;
|
||||
tooltipPosition?: TooltipPosition;
|
||||
loading?: boolean;
|
||||
animate?: boolean;
|
||||
animationDurationMs?: number;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export interface TooltipData {
|
||||
fractions: FractionData[];
|
||||
isDark: boolean;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface ApiCredits {
|
||||
weeklyCreditsRemaining: number;
|
||||
weeklyCreditsAllocated: number;
|
||||
boughtCreditsRemaining: number;
|
||||
totalBoughtCredits: number;
|
||||
totalAvailableCredits: number;
|
||||
weeklyResetDate: string;
|
||||
lastApiUsage: string;
|
||||
}
|
||||
|
||||
export interface CreditSummary {
|
||||
currentCredits: number;
|
||||
maxCredits: number;
|
||||
creditsUsed: number;
|
||||
creditsRemaining: number;
|
||||
resetDate: string; // ISO date string
|
||||
weeklyAllowance: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
id?: string;
|
||||
status: "active" | "inactive" | "cancelled" | "expired";
|
||||
tier: "free" | "basic" | "premium" | "enterprise";
|
||||
startDate?: string; // ISO date string
|
||||
endDate?: string; // ISO date string
|
||||
creditsPerWeek?: number;
|
||||
maxCredits?: number;
|
||||
}
|
||||
|
||||
export interface CreditCheckResult {
|
||||
hasSufficientCredits: boolean;
|
||||
currentBalance: number;
|
||||
requiredCredits: number;
|
||||
shortfall?: number;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Stripe API Types
|
||||
|
||||
export interface CreateCheckoutSessionRequest {
|
||||
planId: string;
|
||||
planName: string;
|
||||
planPrice: number;
|
||||
currency: string;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
}
|
||||
|
||||
export interface CreateCheckoutSessionResponse {
|
||||
clientSecret: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface StripeWebhookEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
object: {
|
||||
id: string;
|
||||
payment_status: "paid" | "unpaid";
|
||||
customer_details?: {
|
||||
email: string;
|
||||
name?: string;
|
||||
};
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaymentSuccessData {
|
||||
sessionId: string;
|
||||
planId: string;
|
||||
customerId: string;
|
||||
paymentIntentId: string;
|
||||
amountTotal: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ApiPackagePurchaseRequest {
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
packagePrice: number;
|
||||
credits: number;
|
||||
currency: string;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
}
|
||||
|
||||
// Error responses
|
||||
export interface StripeApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
// Webhook event types that the backend should handle
|
||||
export type StripeWebhookEventType =
|
||||
| "checkout.session.completed"
|
||||
| "checkout.session.expired"
|
||||
| "payment_intent.succeeded"
|
||||
| "payment_intent.payment_failed"
|
||||
| "customer.subscription.created"
|
||||
| "customer.subscription.updated"
|
||||
| "customer.subscription.deleted";
|
||||
@@ -0,0 +1,26 @@
|
||||
// Utility helpers to open the settings/config modal programmatically
|
||||
// and optionally navigate to a specific section (e.g., 'plan').
|
||||
|
||||
import type { NavKey } from "@app/components/shared/config/types";
|
||||
|
||||
export function openAppSettings(targetKey?: NavKey, notice?: string) {
|
||||
try {
|
||||
const detail: { key?: NavKey; notice?: string } = {};
|
||||
if (targetKey) detail.key = targetKey;
|
||||
if (notice) detail.notice = notice;
|
||||
// Ask the UI to open the App Config modal
|
||||
window.dispatchEvent(new CustomEvent("appConfig:open", { detail }));
|
||||
// If a specific section is requested, navigate there once modal mounts
|
||||
if (targetKey) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("appConfig:navigate", { detail: { key: targetKey } }),
|
||||
);
|
||||
}
|
||||
} catch (_e) {
|
||||
// no-op on SSR or test environments
|
||||
}
|
||||
}
|
||||
|
||||
export function openPlanSettings(notice?: string) {
|
||||
openAppSettings("plan", notice);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Crops an image based on the provided pixel crop area using HTML5 Canvas API.
|
||||
* Returns a PNG blob ready for upload.
|
||||
*/
|
||||
|
||||
export interface Area {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cropped image blob from the source image and crop area.
|
||||
*
|
||||
* @param imageSrc - Data URL or blob URL of the source image
|
||||
* @param pixelCrop - Pixel coordinates and dimensions of the crop area
|
||||
* @returns Promise that resolves to a PNG Blob of the cropped image
|
||||
*/
|
||||
export async function getCroppedImage(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.onload = () => {
|
||||
try {
|
||||
// Create canvas with crop dimensions
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error("Failed to get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to crop dimensions
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
// Draw the cropped region
|
||||
// drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
|
||||
// sx, sy: source x, y coordinates
|
||||
// sw, sh: source width, height
|
||||
// dx, dy: destination x, y coordinates (0, 0 for top-left)
|
||||
// dw, dh: destination width, height
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
);
|
||||
|
||||
// Convert canvas to PNG blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to create blob from canvas"));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
},
|
||||
"image/png",
|
||||
1.0, // Maximum quality
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
|
||||
// Start loading the image
|
||||
image.src = imageSrc;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { URL_TO_TOOL_MAP } from "@app/utils/urlMapping";
|
||||
|
||||
const SUBPATH = import.meta.env.VITE_RUN_SUBPATH.replace(/^\/|\/$/g, ""); // "app" or ""
|
||||
|
||||
/**
|
||||
* Normalize pathname by stripping subpath prefix and trailing slashes
|
||||
*/
|
||||
export function normalizePath(pathname: string): string {
|
||||
// Ensure leading slash, strip subpath prefix if configured
|
||||
let p = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
if (SUBPATH && p.startsWith(`/${SUBPATH}/`)) {
|
||||
p = p.slice(SUBPATH.length + 1); // remove "/app"
|
||||
} else if (SUBPATH && p === `/${SUBPATH}`) {
|
||||
p = "/";
|
||||
}
|
||||
// Strip trailing slash except root
|
||||
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pathname is an auth route
|
||||
*/
|
||||
export function isAuthRoute(pathname: string): boolean {
|
||||
const p = normalizePath(pathname);
|
||||
return p === "/login" || p === "/signup" || p === "/auth/callback";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pathname is home route
|
||||
*/
|
||||
export function isHomeRoute(pathname: string): boolean {
|
||||
return normalizePath(pathname) === "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pathname is a tool route
|
||||
*/
|
||||
export function isToolRoute(pathname: string): boolean {
|
||||
const p = normalizePath(pathname);
|
||||
// direct match or try without trailing slash variants if your map uses them
|
||||
if (URL_TO_TOOL_MAP[p] !== undefined) return true;
|
||||
// Fallback: try adding/removing trailing slash
|
||||
if (URL_TO_TOOL_MAP[`${p}/`] !== undefined) return true;
|
||||
if (p.endsWith("/") && URL_TO_TOOL_MAP[p.slice(0, -1)] !== undefined)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user