mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Add frontend autoformatting and set CI to require formatted code for all languages (#6052)
# Description of Changes Changes the strategy for autoformatting to reject PRs if they are not formatted correctly instead of allowing them to merge and then spawning a new PR to fix the formatting. The old strategy just caused more work for us because we'd have to manually approve the followup PR and get it merged, which required 2 reviewers so in practice it rarely got done and just meant everyone's PRs ended up containing reformatting for unrelated files, which makes code review unnecessarily difficult. If the PR's code is not formatted correctly after this PR, a comment will be added automatically to tell the author how to run the formatter script to fix their code so it can go in. This also enables autoformatting for the frontend code, using Prettier. I've enabled it for pretty much everything in the frontend folder, other than 3rd party files and files it doesn't make sense for. I also excluded Markdown because it sounds likely to be more annoying to have to autoformat the Markdown in the frontend folder but nowhere else. Open to changing this though if people disagree. > [!note] > > Advice to reviewers: The first commit contains all of the actual logic I've introduced (CI changes, Prettier config, etc.) > The second commit is just the reformatting of the entire frontend folder. > The first commit needs proper review, the second one just give it a spot-check that it's doing what you'd expect.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
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';
|
||||
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,
|
||||
@@ -21,10 +21,10 @@ export default function OnboardingBootstrap() {
|
||||
|
||||
// Start polling when user logs in
|
||||
useEffect(() => {
|
||||
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
|
||||
if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
|
||||
console.debug('[Onboarding] Starting poll for trial data');
|
||||
console.debug("[Onboarding] Starting poll for trial data");
|
||||
setIsPolling(true);
|
||||
setPollAttempts(0);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function OnboardingBootstrap() {
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const newAttempts = pollAttempts + 1;
|
||||
console.debug('[Onboarding] Polling for trial data, attempt:', newAttempts);
|
||||
console.debug("[Onboarding] Polling for trial data, attempt:", newAttempts);
|
||||
|
||||
await refreshTrialStatus();
|
||||
setPollAttempts(newAttempts);
|
||||
@@ -58,12 +58,12 @@ export default function OnboardingBootstrap() {
|
||||
const maxAttempts = 10;
|
||||
|
||||
if (hasData || pollAttempts >= maxAttempts) {
|
||||
console.debug('[Onboarding] Trial data ready or timeout, showing modal', {
|
||||
console.debug("[Onboarding] Trial data ready or timeout, showing modal", {
|
||||
hasData,
|
||||
hasProStatus,
|
||||
attempts: pollAttempts,
|
||||
trialStatus,
|
||||
isPro
|
||||
isPro,
|
||||
});
|
||||
setIsPolling(false);
|
||||
setShowModal(true);
|
||||
@@ -71,7 +71,7 @@ export default function OnboardingBootstrap() {
|
||||
}, [isPolling, trialStatus, isPro, pollAttempts]);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
@@ -79,8 +79,8 @@ export default function OnboardingBootstrap() {
|
||||
useEffect(() => {
|
||||
// Ensure tool panel preference is set so tours are never deferred.
|
||||
if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
|
||||
updatePreference('toolPanelModePromptSeen', true);
|
||||
updatePreference('hasSelectedToolPanelMode', true);
|
||||
updatePreference("toolPanelModePromptSeen", true);
|
||||
updatePreference("hasSelectedToolPanelMode", true);
|
||||
}
|
||||
|
||||
// Clear any lingering deferred tour requests.
|
||||
@@ -89,15 +89,15 @@ export default function OnboardingBootstrap() {
|
||||
|
||||
// In SaaS, skip the core intro onboarding entirely.
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
updatePreference("hasSeenIntroOnboarding", true);
|
||||
}
|
||||
// Also mark completed to avoid follow-up banners/modals.
|
||||
if (!preferences.hasCompletedOnboarding) {
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
updatePreference("hasCompletedOnboarding", true);
|
||||
}
|
||||
|
||||
// Also clear any session flag that might mark onboarding as active.
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
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
|
||||
@@ -16,7 +16,7 @@ export default function TrialExpiredBootstrap() {
|
||||
// Close modal if user logs out or session expires
|
||||
if (!user) {
|
||||
if (showModal) {
|
||||
console.debug('[TrialExpired] User logged out, closing modal');
|
||||
console.debug("[TrialExpired] User logged out, closing modal");
|
||||
setShowModal(false);
|
||||
}
|
||||
if (checkoutOpened) {
|
||||
@@ -32,30 +32,28 @@ export default function TrialExpiredBootstrap() {
|
||||
|
||||
// Build localStorage key unique to this user
|
||||
const storageKey = `trialExpiredModalShown_${user.id}`;
|
||||
const hasSeenModal = localStorage.getItem(storageKey) === 'true';
|
||||
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');
|
||||
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 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;
|
||||
const shouldShowModal = isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal;
|
||||
|
||||
if (shouldShowModal) {
|
||||
console.debug('[TrialExpired] Showing trial expired modal', {
|
||||
console.debug("[TrialExpired] Showing trial expired modal", {
|
||||
status: trialStatus.status,
|
||||
daysRemaining: trialStatus.daysRemaining,
|
||||
hasPaymentMethod: trialStatus.hasPaymentMethod,
|
||||
@@ -69,36 +67,32 @@ export default function TrialExpiredBootstrap() {
|
||||
const handleClose = () => {
|
||||
if (user) {
|
||||
const storageKey = `trialExpiredModalShown_${user.id}`;
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
console.debug('[TrialExpired] Modal dismissed, marking as seen');
|
||||
localStorage.setItem(storageKey, "true");
|
||||
console.debug("[TrialExpired] Modal dismissed, marking as seen");
|
||||
}
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleSubscribe = () => {
|
||||
console.debug('[TrialExpired] User clicked Subscribe to Pro');
|
||||
console.debug("[TrialExpired] User clicked Subscribe to Pro");
|
||||
setCheckoutOpened(true);
|
||||
};
|
||||
|
||||
const handleCheckoutSuccess = () => {
|
||||
console.debug('[TrialExpired] Subscription successful, refreshing page');
|
||||
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');
|
||||
console.debug("[TrialExpired] Checkout closed");
|
||||
setCheckoutOpened(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrialExpiredModal
|
||||
opened={showModal && !checkoutOpened}
|
||||
onClose={handleClose}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
<TrialExpiredModal opened={showModal && !checkoutOpened} onClose={handleClose} onSubscribe={handleSubscribe} />
|
||||
|
||||
{user && (
|
||||
<StripeCheckout
|
||||
@@ -109,7 +103,7 @@ export default function TrialExpiredBootstrap() {
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) => console.error('[TrialExpired] Checkout error:', error)}
|
||||
onError={(error) => console.error("[TrialExpired] Checkout error:", error)}
|
||||
isTrialConversion={false} // Trial already ended, so this is not a conversion
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
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: 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);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
padding: 12px 16px;
|
||||
max-width: 30rem;
|
||||
width: 30rem;
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
|
||||
.guest-banner-dismiss:hover {
|
||||
background: var(--mantine-color-gray-1, rgba(255,255,255,0.05));
|
||||
background: var(--mantine-color-gray-1, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.guest-banner-signup {
|
||||
|
||||
@@ -1,88 +1,82 @@
|
||||
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'
|
||||
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
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Ensure the toast only appears once per full page load, not on re-hydration
|
||||
let hasShownThisLoad = false
|
||||
let hasShownThisLoad = false;
|
||||
|
||||
/**
|
||||
* Guest user toast encouraging account creation.
|
||||
* Appears 2s after load, top-right of the viewport, offset by right rail.
|
||||
*/
|
||||
export function GuestUserBanner({ className = '' }: GuestUserBannerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { session } = useAuth()
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
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))
|
||||
const isAnon = Boolean(session?.user && isUserAnonymous(session.user));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnon || hasShownThisLoad) return
|
||||
if (!isAnon || hasShownThisLoad) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(true)
|
||||
hasShownThisLoad = true
|
||||
}, 2000)
|
||||
setVisible(true);
|
||||
hasShownThisLoad = true;
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [isAnon])
|
||||
return () => clearTimeout(timer);
|
||||
}, [isAnon]);
|
||||
|
||||
if (!isAnon || isDismissed || !visible) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSignUp = () => {
|
||||
window.location.href = withBasePath('/signup')
|
||||
}
|
||||
window.location.href = withBasePath("/signup");
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true)
|
||||
}
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`guest-banner ${className || ''}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<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-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.')}
|
||||
{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')}
|
||||
aria-label={t("guestBanner.dismiss", "Dismiss banner")}
|
||||
className="guest-banner-dismiss"
|
||||
>
|
||||
<CloseIcon className="guest-banner-icon" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignUp}
|
||||
className="guest-banner-signup"
|
||||
>
|
||||
<button onClick={handleSignUp} className="guest-banner-signup">
|
||||
<PersonAddIcon className="guest-banner-signup-icon" />
|
||||
{t('guestBanner.signUp', 'Sign Up Free')}
|
||||
{t("guestBanner.signUp", "Sign Up Free")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default GuestUserBanner
|
||||
export default GuestUserBanner;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth";
|
||||
|
||||
interface RequireAuthProps {
|
||||
fallbackPath?: string
|
||||
fallbackPath?: string;
|
||||
}
|
||||
|
||||
export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
|
||||
const { session, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
const { isAutoAuthenticating } = useAutoAnonymousAuth()
|
||||
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')
|
||||
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 />
|
||||
console.warn("[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost");
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// Wait for both auth bootstrap and auto-anon to finish
|
||||
@@ -29,16 +29,16 @@ export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
|
||||
<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 }} />
|
||||
return <Navigate to={fallbackPath} replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
// Render protected routes
|
||||
return <Outlet />
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export default RequireAuth
|
||||
export default RequireAuth;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Userback from '@userback/widget';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useEffect, useRef } from "react";
|
||||
import Userback from "@userback/widget";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
interface UserbackWidgetProps {
|
||||
token: string;
|
||||
@@ -30,14 +30,13 @@ export default function UserbackWidget({ token }: UserbackWidgetProps) {
|
||||
const options = {
|
||||
user_data: {
|
||||
id: user.id,
|
||||
info: userInfo
|
||||
}
|
||||
info: userInfo,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize Userback
|
||||
userbackRef.current = await Userback(token, options);
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
};
|
||||
@@ -46,7 +45,7 @@ export default function UserbackWidget({ token }: UserbackWidgetProps) {
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (userbackRef.current && typeof userbackRef.current.destroy === 'function') {
|
||||
if (userbackRef.current && typeof userbackRef.current.destroy === "function") {
|
||||
userbackRef.current.destroy();
|
||||
}
|
||||
initializingRef.current = false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import UserbackWidget from '@app/components/feedback/UserbackWidget';
|
||||
import UserbackWidget from "@app/components/feedback/UserbackWidget";
|
||||
|
||||
export function HomePageExtensions() {
|
||||
const userbackToken = import.meta.env.VITE_USERBACK_TOKEN;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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';
|
||||
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;
|
||||
@@ -24,17 +24,10 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
flowState,
|
||||
handleButtonAction,
|
||||
} = flow;
|
||||
const { currentStep, totalSteps, currentSlide, slideDefinition, flowState, handleButtonAction } = flow;
|
||||
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === 'dual-icon') {
|
||||
if (slideDefinition.hero.type === "dual-icon") {
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
@@ -46,10 +39,10 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === 'rocket' && (
|
||||
{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' }} />}
|
||||
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -66,17 +59,21 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
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',
|
||||
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' }}>
|
||||
<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}
|
||||
@@ -89,20 +86,17 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
<div key={`title-${currentSlide.key}`} className={`${styles.title} ${styles.titleText}`}>
|
||||
{currentSlide.title}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
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: {
|
||||
@@ -15,39 +15,39 @@ interface RenderButtonsProps {
|
||||
}
|
||||
|
||||
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 leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition['variant']) =>
|
||||
variant === 'primary'
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
? {
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
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)',
|
||||
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 '';
|
||||
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;
|
||||
const fallback = label.split(".").pop() || label;
|
||||
return t(label, fallback);
|
||||
};
|
||||
|
||||
const renderButton = (button: ButtonDefinition) => {
|
||||
const disabled = button.disabledWhen?.(flowState) ?? false;
|
||||
|
||||
if (button.type === 'icon') {
|
||||
if (button.type === "icon") {
|
||||
return (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
@@ -57,18 +57,18 @@ export function renderButtons({ slideDefinition, flowState, onAction, t }: Rende
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
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" />}
|
||||
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
const variant = button.variant ?? 'secondary';
|
||||
const variant = button.variant ?? "secondary";
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TrialStatus } from '@app/auth/UseSession';
|
||||
import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig';
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
import { FLOW_SEQUENCES, SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
export interface FlowConfig {
|
||||
type: 'saas-trial' | 'saas-paid';
|
||||
type: "saas-trial" | "saas-paid";
|
||||
ids: SlideId[];
|
||||
}
|
||||
|
||||
@@ -13,28 +13,23 @@ export interface FlowConfig {
|
||||
* @param _isPro - Whether user has Pro subscription
|
||||
* @returns FlowConfig with the appropriate slide sequence
|
||||
*/
|
||||
export function resolveSaasFlow(
|
||||
trialStatus: TrialStatus | null,
|
||||
_isPro: boolean | null
|
||||
): FlowConfig {
|
||||
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;
|
||||
const hasActiveTrial = trialStatus?.isTrialing === true && trialStatus.daysRemaining > 0;
|
||||
|
||||
if (hasActiveTrial) {
|
||||
return {
|
||||
type: 'saas-trial',
|
||||
type: "saas-trial",
|
||||
ids: FLOW_SEQUENCES.saasTrialUser,
|
||||
};
|
||||
}
|
||||
|
||||
// For paid users, expired trials, or no trial info
|
||||
return {
|
||||
type: 'saas-paid',
|
||||
type: "saas-paid",
|
||||
ids: FLOW_SEQUENCES.saasPaidUser,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
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';
|
||||
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 SlideId = "welcome" | "free-trial" | "desktop-install";
|
||||
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'diamond';
|
||||
export type HeroType = "rocket" | "dual-icon" | "diamond";
|
||||
|
||||
export type ButtonAction =
|
||||
| 'next'
|
||||
| 'prev'
|
||||
| 'close'
|
||||
| 'download-selected';
|
||||
export type ButtonAction = "next" | "prev" | "close" | "download-selected";
|
||||
|
||||
export type FlowState = Record<string, never>;
|
||||
|
||||
@@ -36,11 +32,11 @@ export interface HeroDefinition {
|
||||
|
||||
export interface ButtonDefinition {
|
||||
key: string;
|
||||
type: 'button' | 'icon';
|
||||
type: "button" | "icon";
|
||||
label?: string;
|
||||
icon?: 'chevron-left';
|
||||
variant?: 'primary' | 'secondary' | 'default';
|
||||
group: 'left' | 'right';
|
||||
icon?: "chevron-left";
|
||||
variant?: "primary" | "secondary" | "default";
|
||||
group: "left" | "right";
|
||||
action: ButtonAction;
|
||||
disabledWhen?: (state: FlowState) => boolean;
|
||||
}
|
||||
@@ -53,82 +49,82 @@ export interface SlideDefinition {
|
||||
}
|
||||
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
'welcome': {
|
||||
id: 'welcome',
|
||||
welcome: {
|
||||
id: "welcome",
|
||||
createSlide: () => WelcomeSlide(),
|
||||
hero: { type: 'rocket' },
|
||||
hero: { type: "rocket" },
|
||||
buttons: [
|
||||
{
|
||||
key: 'welcome-next',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.next',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'next',
|
||||
key: "welcome-next",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.next",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "next",
|
||||
},
|
||||
],
|
||||
},
|
||||
'free-trial': {
|
||||
id: 'free-trial',
|
||||
"free-trial": {
|
||||
id: "free-trial",
|
||||
createSlide: ({ trialStatus }) => {
|
||||
if (!trialStatus) {
|
||||
throw new Error('Trial status is required for free-trial slide');
|
||||
throw new Error("Trial status is required for free-trial slide");
|
||||
}
|
||||
return FreeTrialSlide({ trialStatus });
|
||||
},
|
||||
hero: { type: 'diamond' },
|
||||
hero: { type: "diamond" },
|
||||
buttons: [
|
||||
{
|
||||
key: 'trial-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
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',
|
||||
key: "trial-next",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.next",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "next",
|
||||
},
|
||||
],
|
||||
},
|
||||
'desktop-install': {
|
||||
id: 'desktop-install',
|
||||
"desktop-install": {
|
||||
id: "desktop-install",
|
||||
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
|
||||
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
||||
hero: { type: 'dual-icon' },
|
||||
hero: { type: "dual-icon" },
|
||||
buttons: [
|
||||
{
|
||||
key: 'desktop-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
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-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',
|
||||
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[],
|
||||
saasTrialUser: ["welcome", "free-trial", "desktop-install"] as SlideId[],
|
||||
saasPaidUser: ["welcome", "desktop-install"] as SlideId[],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
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;
|
||||
@@ -11,11 +11,7 @@ interface FreeTrialSlideProps {
|
||||
function FreeTrialSlideTitle() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span>
|
||||
{t('onboarding.freeTrial.title', 'Your 30-Day Pro Trial')}
|
||||
</span>
|
||||
);
|
||||
return <span>{t("onboarding.freeTrial.title", "Your 30-Day Pro Trial")}</span>;
|
||||
}
|
||||
|
||||
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
@@ -23,58 +19,60 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
|
||||
// Format the trial end date
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
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.')
|
||||
? 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.');
|
||||
? 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 });
|
||||
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' }}>
|
||||
<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.'
|
||||
"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
|
||||
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>
|
||||
<p style={{ fontSize: "0.9rem", opacity: 0.9 }}>{afterTrialMessage}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
|
||||
return {
|
||||
key: 'free-trial',
|
||||
key: "free-trial",
|
||||
title: <FreeTrialSlideTitle />,
|
||||
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
|
||||
background: {
|
||||
gradientStops: ['#10B981', '#06B6D4'],
|
||||
gradientStops: ["#10B981", "#06B6D4"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
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';
|
||||
} 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']>;
|
||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]["createSlide"]>;
|
||||
flowState: FlowState;
|
||||
handleButtonAction: (action: ButtonAction) => void;
|
||||
}
|
||||
@@ -24,13 +24,10 @@ interface UseSaasOnboardingStateProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useSaasOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
|
||||
export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
|
||||
const { trialStatus, isPro, loading } = useAuth();
|
||||
const osType = useOs();
|
||||
const selectedDownloadUrlRef = useRef<string>('');
|
||||
const selectedDownloadUrlRef = useRef<string>("");
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<number>(0);
|
||||
|
||||
@@ -44,28 +41,28 @@ export function useSaasOnboardingState({
|
||||
// Determine OS details for desktop download
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
case "windows":
|
||||
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||
case "mac-apple":
|
||||
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
case "mac-intel":
|
||||
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case "linux-x64":
|
||||
case "linux-arm64":
|
||||
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
return { label: "", url: "" };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
|
||||
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
|
||||
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
|
||||
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
|
||||
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
|
||||
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
|
||||
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
|
||||
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
|
||||
];
|
||||
return options.filter(opt => opt.url);
|
||||
return options.filter((opt) => opt.url);
|
||||
}, []);
|
||||
|
||||
// Store selected download URL
|
||||
@@ -74,10 +71,7 @@ export function useSaasOnboardingState({
|
||||
}, []);
|
||||
|
||||
// Resolve flow based on trial status
|
||||
const resolvedFlow = useMemo(
|
||||
() => resolveSaasFlow(trialStatus, isPro),
|
||||
[trialStatus, isPro]
|
||||
);
|
||||
const resolvedFlow = useMemo(() => resolveSaasFlow(trialStatus, isPro), [trialStatus, isPro]);
|
||||
|
||||
const flowSlideIds = resolvedFlow.ids;
|
||||
const totalSteps = flowSlideIds.length;
|
||||
@@ -118,7 +112,7 @@ export function useSaasOnboardingState({
|
||||
const handleButtonAction = useCallback(
|
||||
(action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case 'next':
|
||||
case "next":
|
||||
// If on last slide, close modal
|
||||
if (currentStep === maxIndex) {
|
||||
onClose();
|
||||
@@ -126,17 +120,17 @@ export function useSaasOnboardingState({
|
||||
goNext();
|
||||
}
|
||||
return;
|
||||
case 'prev':
|
||||
case "prev":
|
||||
goPrev();
|
||||
return;
|
||||
case 'close':
|
||||
case "close":
|
||||
onClose();
|
||||
return;
|
||||
case 'download-selected': {
|
||||
case "download-selected": {
|
||||
// Open download URL in new tab
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
|
||||
window.open(downloadUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
// Then advance to next slide or close if last
|
||||
if (currentStep === maxIndex) {
|
||||
@@ -151,7 +145,7 @@ export function useSaasOnboardingState({
|
||||
return;
|
||||
}
|
||||
},
|
||||
[currentStep, maxIndex, goNext, goPrev, onClose, os.url]
|
||||
[currentStep, maxIndex, goNext, goPrev, onClose, os.url],
|
||||
);
|
||||
|
||||
const flowState: FlowState = {};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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';
|
||||
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;
|
||||
@@ -23,7 +23,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const { signOut, user, creditBalance, refreshCredits } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const [active, setActive] = useState<NavKey>("overview");
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// Check if user can access billing features (non-anonymous users only)
|
||||
@@ -35,52 +35,55 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
setActive(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
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)) {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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 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), []);
|
||||
|
||||
@@ -97,15 +100,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
const found = section.items.find((i) => i.key === active);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return '';
|
||||
return "";
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const activeComponent = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
const found = section.items.find((i) => i.key === active);
|
||||
if (found) return found.component;
|
||||
}
|
||||
return null;
|
||||
@@ -114,95 +117,96 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
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>
|
||||
</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}
|
||||
{/* 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>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* Confirm logout modal */}
|
||||
{/* Confirm logout modal */}
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
@@ -213,7 +217,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<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 variant="default" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
@@ -222,7 +228,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
} finally {
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
window.location.href = withBasePath('/login');
|
||||
window.location.href = withBasePath("/login");
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
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';
|
||||
type InfoBannerTone = "info" | "warning";
|
||||
|
||||
const toneStyles: Record<
|
||||
InfoBannerTone,
|
||||
@@ -15,18 +15,18 @@ const toneStyles: Record<
|
||||
}
|
||||
> = {
|
||||
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',
|
||||
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',
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ interface InfoBannerProps {
|
||||
textColor?: string;
|
||||
iconColor?: string;
|
||||
buttonColor?: string;
|
||||
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
|
||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||
buttonTextColor?: string; // SaaS-specific for dark theme buttons
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string; // SaaS-specific for dark theme
|
||||
@@ -62,19 +62,19 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = 'check-circle-rounded',
|
||||
buttonIcon = "check-circle-rounded",
|
||||
onButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = 'info',
|
||||
tone = "info",
|
||||
background,
|
||||
borderColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
buttonColor,
|
||||
buttonVariant = 'light',
|
||||
buttonVariant = "light",
|
||||
buttonTextColor,
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
@@ -96,14 +96,14 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
background: background ?? toneStyle.background,
|
||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
|
||||
<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' ? (
|
||||
{icon &&
|
||||
(typeof icon === "string" ? (
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width="1.2rem"
|
||||
@@ -111,23 +111,15 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{icon}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<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}
|
||||
>
|
||||
<Text fw={title ? 400 : 500} size="sm" style={{ color: textColor ?? toneStyle.text }} lineClamp={2}>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -148,13 +140,13 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
color: buttonTextColor,
|
||||
},
|
||||
}
|
||||
: buttonVariant !== 'white' && buttonVariant !== 'filled'
|
||||
? {
|
||||
label: {
|
||||
color: textColor ?? toneStyle.text,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
: buttonVariant !== "white" && buttonVariant !== "filled"
|
||||
? {
|
||||
label: {
|
||||
color: textColor ?? toneStyle.text,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
@@ -163,7 +155,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={closeIconColor ? undefined : 'gray'}
|
||||
color={closeIconColor ? undefined : "gray"}
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { supabase } from '@app/auth/supabase';
|
||||
import { Button } from '@mantine/core';
|
||||
import { usePlans } from '@app/hooks/usePlans';
|
||||
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;
|
||||
@@ -12,8 +12,8 @@ interface TrialStatus {
|
||||
}
|
||||
|
||||
export function ManageBillingButton({
|
||||
returnUrl = typeof window !== 'undefined' ? window.location.href : '/',
|
||||
children = 'Manage billing',
|
||||
returnUrl = typeof window !== "undefined" ? window.location.href : "/",
|
||||
children = "Manage billing",
|
||||
trialStatus,
|
||||
}: {
|
||||
returnUrl?: string;
|
||||
@@ -25,7 +25,7 @@ export function ManageBillingButton({
|
||||
const { data } = usePlans();
|
||||
|
||||
// Hide for free plan users
|
||||
if (!data || data.currentPlan.id === 'free') {
|
||||
if (!data || data.currentPlan.id === "free") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -38,16 +38,17 @@ export function ManageBillingButton({
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke<{ url: string; error?: string }>('manage-billing', {
|
||||
const { data, error } = await supabase.functions.invoke<{ url: string; error?: string }>("manage-billing", {
|
||||
body: {
|
||||
name: 'Functions',
|
||||
return_url: returnUrl},
|
||||
})
|
||||
name: "Functions",
|
||||
return_url: returnUrl,
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || 'error' in data) throw new Error(data?.error ?? 'No portal URL');
|
||||
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');
|
||||
setErr(e instanceof Error ? e.message : "Could not open billing portal");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -56,7 +57,7 @@ export function ManageBillingButton({
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={onClick} disabled={loading} className="px-4 py-2 rounded bg-black text-white">
|
||||
{loading ? 'Opening…' : children}
|
||||
{loading ? "Opening…" : children}
|
||||
</Button>
|
||||
{err && <div className="mt-2 text-red-600">{err}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
children: React.ReactNode;
|
||||
@@ -9,16 +9,11 @@ interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
* 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';
|
||||
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,
|
||||
display: "contents" as const,
|
||||
...style,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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';
|
||||
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;
|
||||
export type PurchaseType = "subscription" | "credits";
|
||||
export type CreditsPack = "xsmall" | "small" | "medium" | "large" | null;
|
||||
export type PlanID = "pro" | null;
|
||||
|
||||
interface StripeCheckoutProps {
|
||||
opened: boolean;
|
||||
@@ -26,7 +26,7 @@ interface StripeCheckoutProps {
|
||||
// Proprietary-specific props (for compatibility)
|
||||
planGroup?: unknown;
|
||||
minimumSeats?: number;
|
||||
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
|
||||
onLicenseActivated?: (licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => void;
|
||||
hostedCheckoutSuccess?: {
|
||||
isUpgrade: boolean;
|
||||
licenseKey?: string;
|
||||
@@ -37,7 +37,7 @@ interface StripeCheckoutProps {
|
||||
}
|
||||
|
||||
type CheckoutState = {
|
||||
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
|
||||
status: "idle" | "loading" | "ready" | "success" | "error";
|
||||
clientSecret?: string;
|
||||
error?: string;
|
||||
sessionParams?: {
|
||||
@@ -56,71 +56,71 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
planName,
|
||||
isTrialConversion,
|
||||
onSuccess,
|
||||
onError
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
|
||||
const [state, setState] = useState<CheckoutState>({ status: "idle" });
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
try {
|
||||
setState({ status: 'loading' });
|
||||
setState({ status: "loading" });
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('create-checkout', {
|
||||
const { data, error } = await supabase.functions.invoke("create-checkout", {
|
||||
body: {
|
||||
purchase_type: purchaseType,
|
||||
ui_mode: 'embedded',
|
||||
ui_mode: "embedded",
|
||||
plan: planId,
|
||||
credits_pack: creditsPack,
|
||||
callback_base_url: window.location.origin,
|
||||
trial_conversion: isTrialConversion || false
|
||||
}
|
||||
trial_conversion: isTrialConversion || false,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to create checkout session');
|
||||
throw new Error(error.message || "Failed to create checkout session");
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No data received from server');
|
||||
throw new Error("No data received from server");
|
||||
}
|
||||
|
||||
const jsonData = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
const jsonData = typeof data === "string" ? JSON.parse(data) : data;
|
||||
|
||||
if (!jsonData?.clientSecret) {
|
||||
throw new Error('No client secret received from server');
|
||||
throw new Error("No client secret received from server");
|
||||
}
|
||||
|
||||
setState({
|
||||
status: 'ready',
|
||||
status: "ready",
|
||||
clientSecret: jsonData.clientSecret,
|
||||
sessionParams: {
|
||||
purchaseType: purchaseType!,
|
||||
planId: planId!,
|
||||
creditsPack: creditsPack!
|
||||
}
|
||||
creditsPack: creditsPack!,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
|
||||
setState({
|
||||
status: 'error',
|
||||
error: errorMessage
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentComplete = () => {
|
||||
setState({ status: 'success' });
|
||||
setState({ status: "success" });
|
||||
|
||||
// Call success callback immediately - parent will handle timing
|
||||
onSuccess?.('');
|
||||
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 });
|
||||
setState({ status: "idle", clientSecret: undefined, error: undefined, sessionParams: undefined });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -129,35 +129,35 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
if (opened) {
|
||||
// Check if we need a new session (first time or parameters changed)
|
||||
const needsNewSession =
|
||||
state.status === 'idle' ||
|
||||
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 });
|
||||
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 });
|
||||
setState({ status: "idle", clientSecret: undefined, error: undefined, sessionParams: undefined });
|
||||
}
|
||||
}, [opened, purchaseType, planId, creditsPack]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (state.status) {
|
||||
case 'loading':
|
||||
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...')}
|
||||
{t("payment.preparing", "Preparing your checkout...")}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'ready':
|
||||
case "ready":
|
||||
if (!state.clientSecret) return null;
|
||||
|
||||
return (
|
||||
@@ -166,34 +166,37 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: state.clientSecret,
|
||||
onComplete: handlePaymentComplete
|
||||
onComplete: handlePaymentComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
case "success":
|
||||
return (
|
||||
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
|
||||
<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.')}
|
||||
{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...')}
|
||||
{t("payment.autoClose", "This window will close automatically...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
case "error":
|
||||
return (
|
||||
<Alert color="red" title={t('payment.error', 'Payment Error')}>
|
||||
<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')}
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
@@ -211,7 +214,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
title={
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
|
||||
{t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName })}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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;
|
||||
@@ -15,15 +15,15 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
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 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,
|
||||
position: "bottom-left" as const,
|
||||
size: 270, // 16.875rem
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
color: "rgba(255, 255, 255, 0.25)",
|
||||
opacity: 0.9,
|
||||
amplitude: 24, // 1.5rem
|
||||
duration: 4.5,
|
||||
@@ -31,9 +31,9 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
offsetY: 14, // 0.875rem
|
||||
},
|
||||
{
|
||||
position: 'top-right' as const,
|
||||
position: "top-right" as const,
|
||||
size: 300, // 18.75rem
|
||||
color: 'rgba(255, 255, 255, 0.2)',
|
||||
color: "rgba(255, 255, 255, 0.2)",
|
||||
opacity: 0.9,
|
||||
amplitude: 28, // 1.75rem
|
||||
duration: 4.5,
|
||||
@@ -57,26 +57,25 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: {
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
background: 'var(--bg-surface)',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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' }}>
|
||||
<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"
|
||||
/>
|
||||
<AnimatedSlideBackground gradientStops={gradientStops} circles={circles} isActive slideKey="trial-expired" />
|
||||
<div className={styles.heroLogo}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
<DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />
|
||||
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,28 +84,26 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
className={styles.modalBody}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
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.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.'
|
||||
"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.')}
|
||||
{t("plan.trial.freeTierLimitations", "Free tier includes basic PDF tools with usage limits.")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,10 +124,10 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
<div
|
||||
className="trial-button-container"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
justifyContent: 'space-between',
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.75rem",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -139,15 +136,15 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
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'
|
||||
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')}
|
||||
{t("plan.trial.continueWithFree", "Continue with Free")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -155,18 +152,18 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
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',
|
||||
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',
|
||||
padding: "0.75rem 1.5rem",
|
||||
height: "auto",
|
||||
border: "none",
|
||||
minWidth: "10.625rem",
|
||||
flex: "0 1 auto",
|
||||
}}
|
||||
>
|
||||
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
|
||||
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
return sessionStorage.getItem(SESSION_STORAGE_KEY) === "true";
|
||||
});
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function TrialStatusBanner() {
|
||||
!dismissed;
|
||||
|
||||
if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) {
|
||||
console.log('Subscription scheduled - hiding trial banner');
|
||||
console.log("Subscription scheduled - hiding trial banner");
|
||||
}
|
||||
|
||||
const handleOpenCheckout = useCallback(() => {
|
||||
@@ -37,7 +37,7 @@ export function TrialStatusBanner() {
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setDismissed(true);
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, "true");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,15 +46,15 @@ export function TrialStatusBanner() {
|
||||
return;
|
||||
}
|
||||
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString('en-GB', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
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 }
|
||||
"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 = (
|
||||
@@ -62,9 +62,9 @@ export function TrialStatusBanner() {
|
||||
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||
alt="Stirling PDF"
|
||||
style={{
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
objectFit: 'contain'
|
||||
width: "1.5rem",
|
||||
height: "1.5rem",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -74,7 +74,7 @@ export function TrialStatusBanner() {
|
||||
icon={logoIcon}
|
||||
tone="info"
|
||||
message={message}
|
||||
buttonText={t('plan.trial.subscribe', 'Subscribe to Pro')}
|
||||
buttonText={t("plan.trial.subscribe", "Subscribe to Pro")}
|
||||
buttonIcon="credit-card-rounded"
|
||||
onButtonClick={handleOpenCheckout}
|
||||
onDismiss={handleDismiss}
|
||||
@@ -88,7 +88,7 @@ export function TrialStatusBanner() {
|
||||
buttonVariant="white"
|
||||
buttonTextColor="var(--mantine-color-dark-9)"
|
||||
closeIconColor="rgba(255, 255, 255, 0.7)"
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -112,7 +112,7 @@ export function TrialStatusBanner() {
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) => console.error('Checkout error:', error)}
|
||||
onError={(error) => console.error("Checkout error:", error)}
|
||||
isTrialConversion={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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';
|
||||
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,
|
||||
export default function StackedBarChart({
|
||||
fractions,
|
||||
width = 640,
|
||||
height = 22,
|
||||
showLegend = true,
|
||||
className = '',
|
||||
tooltipPosition = 'top',
|
||||
className = "",
|
||||
tooltipPosition = "top",
|
||||
loading = false,
|
||||
animate = true,
|
||||
animationDurationMs = 900,
|
||||
@@ -32,96 +32,102 @@ export default function StackedBarChart({
|
||||
// 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 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 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';
|
||||
tooltip.style.opacity = "0";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
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
|
||||
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);
|
||||
.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');
|
||||
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);
|
||||
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 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);
|
||||
.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})`);
|
||||
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) => {
|
||||
data.forEach((fraction: (typeof data)[number], index: number) => {
|
||||
if (fraction.value <= 0) return;
|
||||
|
||||
const segWidth = x(fraction.value);
|
||||
@@ -133,131 +139,145 @@ export default function StackedBarChart({
|
||||
|
||||
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);
|
||||
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
|
||||
bottomRight: false,
|
||||
});
|
||||
usedGroup.append('path')
|
||||
.attr('d', path)
|
||||
.attr('fill', fraction.color);
|
||||
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
|
||||
bottomRight: true,
|
||||
});
|
||||
usedGroup.append('path')
|
||||
.attr('d', path)
|
||||
.attr('fill', fraction.color);
|
||||
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);
|
||||
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) => {
|
||||
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';
|
||||
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);
|
||||
.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 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);
|
||||
clipRect.transition().duration(animationDurationMs).attr("width", revealTo);
|
||||
hasAnimatedRef.current = true;
|
||||
} else {
|
||||
clipRect.attr('width', revealTo);
|
||||
clipRect.attr("width", revealTo);
|
||||
}
|
||||
}
|
||||
|
||||
return () => { container.innerHTML = ''; };
|
||||
}, [fractions, width, height, tooltipPosition, loading, animate, animationDurationMs, clipId, themeVars, setTooltipContent, hideTooltip, positionTooltip]);
|
||||
return () => {
|
||||
container.innerHTML = "";
|
||||
};
|
||||
}, [
|
||||
fractions,
|
||||
width,
|
||||
height,
|
||||
tooltipPosition,
|
||||
loading,
|
||||
animate,
|
||||
animationDurationMs,
|
||||
clipId,
|
||||
themeVars,
|
||||
setTooltipContent,
|
||||
hideTooltip,
|
||||
positionTooltip,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<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
|
||||
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' }}>
|
||||
<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
|
||||
}}
|
||||
<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}`
|
||||
}}
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { TooltipData } from '@app/types/charts';
|
||||
import React from "react";
|
||||
import { TooltipData } from "@app/types/charts";
|
||||
|
||||
interface StackedBarTooltipProps {
|
||||
data: TooltipData;
|
||||
@@ -9,16 +9,18 @@ export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
|
||||
const { fractions } = data;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', whiteSpace: 'nowrap' }}>
|
||||
<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>
|
||||
<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>
|
||||
@@ -30,14 +32,18 @@ export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
|
||||
|
||||
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 => `
|
||||
${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('')}
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Reusable D3 utility functions for chart creation
|
||||
*/
|
||||
|
||||
import * as d3 from 'd3';
|
||||
import * as d3 from "d3";
|
||||
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
@@ -28,17 +28,17 @@ export interface AnimationConfig {
|
||||
* @returns The created SVG selection
|
||||
*/
|
||||
export function createSVG(
|
||||
container: HTMLElement,
|
||||
container: HTMLElement,
|
||||
dimensions: ChartDimensions,
|
||||
className?: string
|
||||
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 || '');
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", dimensions.height)
|
||||
.attr("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`)
|
||||
.attr("class", className || "");
|
||||
|
||||
return svg;
|
||||
}
|
||||
@@ -53,17 +53,17 @@ export function createSVG(
|
||||
export function createClipPath(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
clipId: string,
|
||||
dimensions: ChartDimensions
|
||||
dimensions: ChartDimensions,
|
||||
): d3.Selection<SVGRectElement, unknown, null, undefined> {
|
||||
const defs = svg.append('defs');
|
||||
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);
|
||||
.append("clipPath")
|
||||
.attr("id", clipId)
|
||||
.append("rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", 0)
|
||||
.attr("height", dimensions.height);
|
||||
|
||||
return clipRect;
|
||||
}
|
||||
@@ -77,13 +77,13 @@ export function createClipPath(
|
||||
export function animateClipReveal(
|
||||
clipRect: d3.Selection<SVGRectElement, unknown, null, undefined>,
|
||||
targetWidth: number,
|
||||
config: AnimationConfig
|
||||
config: AnimationConfig,
|
||||
): void {
|
||||
clipRect
|
||||
.transition()
|
||||
.duration(config.duration)
|
||||
.ease(config.easing || d3.easeCubicInOut)
|
||||
.attr('width', targetWidth);
|
||||
.attr("width", targetWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,48 +102,48 @@ export function createRoundedRectPath(
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
corners: { topLeft?: boolean; topRight?: boolean; bottomLeft?: boolean; bottomRight?: boolean } = {}
|
||||
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 '';
|
||||
|
||||
|
||||
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';
|
||||
|
||||
return path + " Z";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,10 +162,7 @@ export function createScale(domain: [number, number], range: [number, number]) {
|
||||
* @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 {
|
||||
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);
|
||||
|
||||
@@ -13,18 +13,17 @@ export interface ThemeInfo {
|
||||
* @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;
|
||||
|
||||
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
|
||||
prefersDark,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,12 +34,12 @@ export function detectTheme(): ThemeInfo {
|
||||
*/
|
||||
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)'
|
||||
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)",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,13 +50,13 @@ export function getChartThemeVars(isDark: boolean) {
|
||||
*/
|
||||
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';
|
||||
tooltipElement.style.padding = "8px 10px";
|
||||
tooltipElement.style.fontSize = "12px";
|
||||
tooltipElement.style.lineHeight = "1.25";
|
||||
tooltipElement.style.borderRadius = "8px";
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Utility functions for tooltip positioning and management in D3 charts
|
||||
*/
|
||||
|
||||
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
||||
export type TooltipPosition = "top" | "bottom" | "left" | "right";
|
||||
|
||||
export interface TooltipPositioner {
|
||||
positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void;
|
||||
@@ -19,36 +19,35 @@ export function createTooltipPositioner(position: TooltipPosition): TooltipPosit
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
case "left":
|
||||
tooltip.style.left = `${Math.max(10, offsetX - tooltipWidth - gap)}px`;
|
||||
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
|
||||
break;
|
||||
case 'right':
|
||||
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';
|
||||
tooltip.style.opacity = "0";
|
||||
};
|
||||
|
||||
return { positionTooltip, hideTooltip };
|
||||
@@ -60,15 +59,15 @@ export function createTooltipPositioner(position: TooltipPosition): TooltipPosit
|
||||
* @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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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;
|
||||
@@ -12,12 +12,7 @@ interface ProfilePictureCropperProps {
|
||||
onCropComplete: (croppedBlob: Blob) => void;
|
||||
}
|
||||
|
||||
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
file,
|
||||
opened,
|
||||
onClose,
|
||||
onCropComplete,
|
||||
}) => {
|
||||
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ file, opened, onClose, onCropComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State management
|
||||
@@ -41,7 +36,12 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
setError(null);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError(t('config.account.profilePicture.cropper.invalidImage', 'Invalid image file. Please select a valid PNG, JPG, or WebP file.'));
|
||||
setError(
|
||||
t(
|
||||
"config.account.profilePicture.cropper.invalidImage",
|
||||
"Invalid image file. Please select a valid PNG, JPG, or WebP file.",
|
||||
),
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
@@ -75,12 +75,9 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
}, []);
|
||||
|
||||
// Called when crop is complete (stores the crop area in pixels)
|
||||
const onCropCompleteCallback = useCallback(
|
||||
(_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const onCropCompleteCallback = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
}, []);
|
||||
|
||||
// Process and save the cropped image
|
||||
const handleSave = async () => {
|
||||
@@ -100,9 +97,9 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
if (croppedBlob.size > maxSize) {
|
||||
setError(
|
||||
t(
|
||||
'config.account.profilePicture.cropper.sizeErrorAfterCrop',
|
||||
'Cropped image is too large. Please zoom out or crop a smaller area.'
|
||||
)
|
||||
"config.account.profilePicture.cropper.sizeErrorAfterCrop",
|
||||
"Cropped image is too large. Please zoom out or crop a smaller area.",
|
||||
),
|
||||
);
|
||||
setProcessing(false);
|
||||
return;
|
||||
@@ -112,13 +109,8 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
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.'
|
||||
)
|
||||
);
|
||||
console.error("Error cropping image:", err);
|
||||
setError(t("config.account.profilePicture.cropper.cropError", "Failed to crop image. Please try again."));
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@@ -128,7 +120,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('config.account.profilePicture.cropper.title', 'Crop Profile Picture')}
|
||||
title={t("config.account.profilePicture.cropper.title", "Crop Profile Picture")}
|
||||
size="lg"
|
||||
centered
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
@@ -141,7 +133,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
)}
|
||||
|
||||
{/* Cropper area */}
|
||||
<Box style={{ position: 'relative', width: '100%', height: 400 }}>
|
||||
<Box style={{ position: "relative", width: "100%", height: 400 }}>
|
||||
{imageSrc && (
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
@@ -158,27 +150,20 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
{/* Zoom slider */}
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('config.account.profilePicture.cropper.zoom', 'Zoom')}
|
||||
{t("config.account.profilePicture.cropper.zoom", "Zoom")}
|
||||
</Text>
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={setZoom}
|
||||
disabled={processing}
|
||||
/>
|
||||
<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' }}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
|
||||
<Button variant="subtle" onClick={onClose} disabled={processing}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
{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')}
|
||||
? t("config.account.profilePicture.cropper.processing", "Processing crop...")
|
||||
: t("config.account.profilePicture.cropper.save", "Save Cropped Image")}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ApiKeys() {
|
||||
};
|
||||
|
||||
const goToAccount = () => {
|
||||
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'overview' } }));
|
||||
window.dispatchEvent(new CustomEvent("appConfig:navigate", { detail: { key: "overview" } }));
|
||||
};
|
||||
|
||||
const showUsage = Boolean(credits);
|
||||
@@ -48,67 +48,83 @@ export default function ApiKeys() {
|
||||
return (
|
||||
<Stack gap={20} p={0}>
|
||||
{showUsage && (
|
||||
<UsageSection
|
||||
<UsageSection
|
||||
apiUsage={credits!}
|
||||
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
|
||||
overlayMessage={t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
|
||||
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.")} {" "}
|
||||
{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')}
|
||||
{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)" }}>
|
||||
<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 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.')}
|
||||
{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')}
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
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}
|
||||
/>
|
||||
)
|
||||
<ApiKeySection
|
||||
publicKey={apiKey ?? ""}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onRefresh={() => setShowRefreshModal(true)}
|
||||
disabled={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RefreshModal
|
||||
opened={showRefreshModal}
|
||||
onClose={() => setShowRefreshModal(false)}
|
||||
onConfirm={refreshKeys}
|
||||
/>
|
||||
<RefreshModal opened={showRefreshModal} onClose={() => setShowRefreshModal(false)} onConfirm={refreshKeys} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
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';
|
||||
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;
|
||||
@@ -19,12 +32,20 @@ interface OverviewProps {
|
||||
|
||||
const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { user, refreshSession, signOut, profilePictureUrl, profilePictureMetadata, refreshProfilePicture, refreshProfilePictureMetadata } = useAuth();
|
||||
const {
|
||||
user,
|
||||
refreshSession,
|
||||
signOut,
|
||||
profilePictureUrl,
|
||||
profilePictureMetadata,
|
||||
refreshProfilePicture,
|
||||
refreshProfilePictureMetadata,
|
||||
} = useAuth();
|
||||
|
||||
const PROFILE_BUCKET = 'profile-pictures';
|
||||
const PROFILE_BUCKET = "profile-pictures";
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
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);
|
||||
@@ -34,14 +55,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
const [cropperOpen, setCropperOpen] = useState(false);
|
||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [confirmEmail, setConfirmEmail] = useState('');
|
||||
const [confirmEmail, setConfirmEmail] = useState("");
|
||||
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const isOAuthPicture = profilePictureMetadata?.source === 'oauth';
|
||||
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 profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U";
|
||||
|
||||
const handleProfileUpload = async (file: File | null) => {
|
||||
if (!file || !user || !profilePath) {
|
||||
@@ -49,7 +70,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
|
||||
setProfileError(t("config.account.profilePicture.sizeError", "Please select an image smaller than 2MB."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +87,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
// Validate cropped size (2MB limit)
|
||||
if (croppedBlob.size > 2 * 1024 * 1024) {
|
||||
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
|
||||
setProfileError(t("config.account.profilePicture.sizeError", "Please select an image smaller than 2MB."));
|
||||
setCropperOpen(false);
|
||||
setCropperFile(null);
|
||||
return;
|
||||
@@ -75,21 +96,18 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
setProfileUploading(true);
|
||||
setProfileError(null);
|
||||
|
||||
const { error } = await supabase
|
||||
.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.upload(profilePath, croppedBlob, {
|
||||
upsert: true,
|
||||
cacheControl: '3600',
|
||||
contentType: 'image/png'
|
||||
});
|
||||
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');
|
||||
setProfileError(error.message || "Failed to upload profile picture");
|
||||
} else {
|
||||
// Mark as manual upload in metadata
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: 'upload',
|
||||
source: "upload",
|
||||
provider: null,
|
||||
});
|
||||
await refreshProfilePictureMetadata();
|
||||
@@ -109,17 +127,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
setProfileUploading(true);
|
||||
setProfileError(null);
|
||||
|
||||
const { error } = await supabase
|
||||
.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.remove([profilePath]);
|
||||
const { error } = await supabase.storage.from(PROFILE_BUCKET).remove([profilePath]);
|
||||
|
||||
if (error) {
|
||||
setProfileError(error.message || 'Failed to remove profile picture');
|
||||
setProfileError(error.message || "Failed to remove profile picture");
|
||||
} else {
|
||||
// Clear metadata when removing picture
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: 'upload',
|
||||
source: "upload",
|
||||
provider: null,
|
||||
});
|
||||
await refreshProfilePictureMetadata();
|
||||
@@ -140,17 +155,19 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
try {
|
||||
// Update metadata to allow manual uploads
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: 'upload',
|
||||
source: "upload",
|
||||
provider: null,
|
||||
});
|
||||
|
||||
await refreshProfilePictureMetadata();
|
||||
setSuccess(t('config.account.profilePicture.switchedToCustom', 'Switched to custom picture. You can now upload your own.'));
|
||||
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');
|
||||
setProfileError(error instanceof Error ? error.message : "Failed to switch to custom picture");
|
||||
} finally {
|
||||
setProfileUploading(false);
|
||||
}
|
||||
@@ -160,7 +177,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
setUpgradeError('Email is required');
|
||||
setUpgradeError("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,34 +190,34 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
await linkEmailIdentity(email.trim(), password || undefined);
|
||||
|
||||
// Synchronize with backend database (using "email" as auth method for email/password)
|
||||
await synchronizeUserUpgrade('email');
|
||||
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('');
|
||||
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');
|
||||
setUpgradeError(err instanceof Error ? err.message : "Failed to upgrade account");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthUpgrade = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
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);
|
||||
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 redirectUrl = absoluteWithBasePath("/auth/callback?next=/");
|
||||
const result = await linkOAuthIdentity(provider, redirectUrl);
|
||||
|
||||
if (result.data?.url) {
|
||||
@@ -210,32 +227,31 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`;
|
||||
setUpgradeError(errorMessage);
|
||||
setIsLoading(false);
|
||||
sessionStorage.removeItem('pendingUpgrade');
|
||||
sessionStorage.removeItem('upgradeProvider');
|
||||
sessionStorage.removeItem("pendingUpgrade");
|
||||
sessionStorage.removeItem("upgradeProvider");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (isAnonymous) return;
|
||||
try {
|
||||
setIsDeletingAccount(true);
|
||||
await deleteCurrentAccount();
|
||||
setDeleteModalOpen(false);
|
||||
setConfirmEmail('');
|
||||
setConfirmEmail("");
|
||||
await signOut();
|
||||
window.location.href = absoluteWithBasePath('/login');
|
||||
window.location.href = absoluteWithBasePath("/login");
|
||||
} catch (err) {
|
||||
const fallbackMessage = t('config.account.overview.deleteFailed', 'Failed to delete account.');
|
||||
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);
|
||||
console.error("[Overview] Delete account failed:", err);
|
||||
showToast({
|
||||
alertType: 'error',
|
||||
title: t('config.account.overview.deleteFailedTitle', 'Unable to delete account'),
|
||||
alertType: "error",
|
||||
title: t("config.account.overview.deleteFailedTitle", "Unable to delete account"),
|
||||
body: message,
|
||||
expandable: true,
|
||||
location: 'top-right',
|
||||
durationMs: 7000
|
||||
location: "top-right",
|
||||
durationMs: 7000,
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingAccount(false);
|
||||
@@ -244,33 +260,35 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
setDeleteModalOpen(false);
|
||||
setConfirmEmail('');
|
||||
setConfirmEmail("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}>
|
||||
<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 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 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' }}>
|
||||
<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')
|
||||
}
|
||||
? 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 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')}
|
||||
{t("logOut", "Log out")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,11 +296,11 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('config.account.profilePicture.title', 'Profile picture')}
|
||||
<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 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 && (
|
||||
@@ -302,14 +320,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<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'
|
||||
{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')}
|
||||
{t("config.account.profilePicture.useCustom", "Use custom picture")}
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -318,7 +336,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<Group gap="sm">
|
||||
<FileButton
|
||||
onChange={handleProfileUpload}
|
||||
@@ -327,20 +345,16 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
>
|
||||
{(props) => (
|
||||
<Button {...props} loading={profileUploading}>
|
||||
{t('config.account.profilePicture.upload', 'Upload')}
|
||||
{t("config.account.profilePicture.upload", "Upload")}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleProfileRemove}
|
||||
disabled={!profilePictureUrl || profileUploading}
|
||||
>
|
||||
{t('config.account.profilePicture.remove', 'Remove')}
|
||||
<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.')}
|
||||
{t("config.account.profilePicture.help", "PNG, JPG, or WebP up to 2MB.")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -362,11 +376,11 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
{isAnonymous && (
|
||||
<div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('config.account.upgrade.title', 'Upgrade Guest Account')}
|
||||
<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 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>
|
||||
|
||||
@@ -382,17 +396,17 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<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')}
|
||||
{t("config.account.upgrade.socialLogin", "Upgrade with Social Account")}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{oauthProviders
|
||||
.filter(provider => !provider.isDisabled)
|
||||
.filter((provider) => !provider.isDisabled)
|
||||
.map((provider) => (
|
||||
<Tooltip
|
||||
key={provider.id}
|
||||
content={`${t('config.account.upgrade.linkWith', 'Link with')} ${provider.label}`}
|
||||
content={`${t("config.account.upgrade.linkWith", "Link with")} ${provider.label}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -404,7 +418,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleOAuthUpgrade(provider.id as 'github' | 'google' | 'apple' | 'azure')}
|
||||
onClick={() => handleOAuthUpgrade(provider.id as "github" | "google" | "apple" | "azure")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{provider.label}
|
||||
@@ -416,28 +430,28 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
|
||||
{t('config.account.upgrade.emailPassword', 'or enter your email & password')}
|
||||
{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')}
|
||||
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')}
|
||||
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')}
|
||||
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')}
|
||||
{t("config.account.upgrade.upgradeButton", "Upgrade Account")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
@@ -447,19 +461,17 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
{/* 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')}
|
||||
<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>
|
||||
)}
|
||||
@@ -468,34 +480,40 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Modal
|
||||
opened={deleteModalOpen}
|
||||
onClose={closeDeleteModal}
|
||||
title={t('config.account.overview.deleteAccountTitle', 'Delete Account')}
|
||||
title={t("config.account.overview.deleteAccountTitle", "Delete Account")}
|
||||
centered
|
||||
zIndex={10000}
|
||||
>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) {
|
||||
handleDeleteAccount();
|
||||
}
|
||||
}}>
|
||||
<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.')}
|
||||
{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 })}
|
||||
{t(
|
||||
"config.account.overview.enterEmailConfirm",
|
||||
"To confirm deletion, please type your email address ({{email}}) below:",
|
||||
{ email: user?.email },
|
||||
)}
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder={user?.email || ''}
|
||||
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')}
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
@@ -503,7 +521,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
type="submit"
|
||||
loading={isDeletingAccount}
|
||||
>
|
||||
{t('config.account.overview.confirmDelete', 'Delete My Account')}
|
||||
{t("config.account.overview.confirmDelete", "Delete My Account")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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();
|
||||
@@ -11,23 +11,23 @@ const PasswordSecurity: React.FC = () => {
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
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'));
|
||||
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'));
|
||||
setError(t("signup.passwordTooShort", "Password must be at least 6 characters long"));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t('signup.passwordsDoNotMatch', 'Passwords do not match'));
|
||||
setError(t("signup.passwordsDoNotMatch", "Passwords do not match"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ const PasswordSecurity: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'));
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setSuccess(t("login.passwordUpdatedSuccess", "Your password has been updated successfully."));
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setDidUpdate(true);
|
||||
|
||||
// Replace form with success text, then close after 2s
|
||||
@@ -56,48 +56,58 @@ const PasswordSecurity: React.FC = () => {
|
||||
setDidUpdate(false);
|
||||
}, 2000);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to change password');
|
||||
setError(e instanceof Error ? e.message : "Failed to change password");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<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>
|
||||
<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')}
|
||||
{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}>
|
||||
<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>
|
||||
<Alert color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{didUpdate ? (
|
||||
<Alert color="green" mb="md">{success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')}</Alert>
|
||||
<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' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<PasswordInput
|
||||
label={t('account.newPassword', 'New Password')}
|
||||
placeholder={t('account.newPassword', 'New Password')}
|
||||
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')}
|
||||
label={t("account.confirmNewPassword", "Confirm New Password")}
|
||||
placeholder={t("account.confirmNewPassword", "Confirm New Password")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
/>
|
||||
@@ -105,10 +115,10 @@ const PasswordSecurity: React.FC = () => {
|
||||
<Divider my="sm" />
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="default" onClick={() => setOpened(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleChangePassword} loading={isLoading}>
|
||||
{t('config.account.security.update', 'Update password')}
|
||||
{t("config.account.security.update", "Update password")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
@@ -119,5 +129,3 @@ const PasswordSecurity: React.FC = () => {
|
||||
};
|
||||
|
||||
export default PasswordSecurity;
|
||||
|
||||
|
||||
|
||||
@@ -1,76 +1,85 @@
|
||||
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';
|
||||
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 [purchaseType, setPurchaseType] = useState<PurchaseType>("subscription");
|
||||
const [selectedCreditsPack, setSelectedCreditsPack] = useState<CreditsPack>(null);
|
||||
const [currency, setCurrency] = useState<string>('gbp');
|
||||
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, £)' }
|
||||
{ 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;
|
||||
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.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]);
|
||||
if (plan.id !== data.currentPlan.id) {
|
||||
setSelectedPlan(plan);
|
||||
setPurchaseType("subscription");
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const handleCreditPurchaseClick = useCallback((creditsPack: CreditsPack) => {
|
||||
if (!data) return;
|
||||
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);
|
||||
setSelectedCreditsPack(creditsPack);
|
||||
setPurchaseType("credits");
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, 2000);
|
||||
}, [selectedPlan, purchaseType, updateCurrentPlan]);
|
||||
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);
|
||||
console.error("Payment error:", error);
|
||||
// Error is already displayed in the StripeCheckout component
|
||||
}, []);
|
||||
|
||||
@@ -84,11 +93,11 @@ const Plan: React.FC = () => {
|
||||
if (!data) return;
|
||||
|
||||
// Find Pro plan from available plans
|
||||
const proPlan = Array.from(data.plans.values()).find(plan => plan.id === 'pro');
|
||||
const proPlan = Array.from(data.plans.values()).find((plan) => plan.id === "pro");
|
||||
|
||||
if (proPlan) {
|
||||
setSelectedPlan(proPlan);
|
||||
setPurchaseType('subscription');
|
||||
setPurchaseType("subscription");
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
@@ -99,12 +108,12 @@ const Plan: React.FC = () => {
|
||||
if (!data) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('action') === 'add-payment') {
|
||||
if (params.get("action") === "add-payment") {
|
||||
handleAddPaymentClick();
|
||||
// Clean up URL
|
||||
params.delete('action');
|
||||
params.delete("action");
|
||||
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}, [data, handleAddPaymentClick]);
|
||||
|
||||
@@ -137,14 +146,16 @@ const Plan: React.FC = () => {
|
||||
const plansArray = Array.from(plans.values());
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<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>
|
||||
<Text size="lg" fw={600}>
|
||||
Currency
|
||||
</Text>
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={(value) => setCurrency(value || 'gbp')}
|
||||
onChange={(value) => setCurrency(value || "gbp")}
|
||||
data={currencyOptions}
|
||||
searchable
|
||||
clearable={true}
|
||||
@@ -163,11 +174,7 @@ const Plan: React.FC = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plansArray}
|
||||
currentPlan={currentPlan}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
<AvailablePlansSection plans={plansArray} currentPlan={currentPlan} onUpgradeClick={handleUpgradeClick} />
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -183,16 +190,16 @@ const Plan: React.FC = () => {
|
||||
opened={checkoutOpen && (selectedPlan !== null || selectedCreditsPack !== null)}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={purchaseType === 'subscription' ? (selectedPlan?.id as PlanID) : null}
|
||||
creditsPack={purchaseType === 'credits' ? selectedCreditsPack : null}
|
||||
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 || ''
|
||||
purchaseType === "subscription"
|
||||
? selectedPlan?.name || ""
|
||||
: data?.apiPackages.find((pkg) => pkg.id === selectedCreditsPack)?.name || ""
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={trialStatus?.isTrialing && purchaseType === 'subscription'}
|
||||
isTrialConversion={trialStatus?.isTrialing && purchaseType === "subscription"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+37
-37
@@ -1,13 +1,7 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Paper,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
||||
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
|
||||
import { FractionData } from '@app/types/charts';
|
||||
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";
|
||||
@@ -26,7 +20,7 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
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);
|
||||
|
||||
@@ -35,42 +29,49 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
// Prepare data for the stacked bar chart
|
||||
const fractions: FractionData[] = [
|
||||
{
|
||||
name: t('config.apiKeys.includedCredits', 'Included credits'),
|
||||
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)"
|
||||
numeratorLabel: t("common.used", "used"),
|
||||
denominatorLabel: t("common.available", "available"),
|
||||
color: "var(--usage-weekly-active)",
|
||||
},
|
||||
{
|
||||
name: t('config.apiKeys.purchasedCredits', 'Purchased credits'),
|
||||
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)"
|
||||
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}
|
||||
<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"',
|
||||
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
|
||||
)}
|
||||
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
|
||||
{loading ? <SkeletonLoader type="block" width={40} height={14} /> : totalRemaining}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<StackedBarChart
|
||||
|
||||
<StackedBarChart
|
||||
fractions={fractions}
|
||||
width={640}
|
||||
height={22}
|
||||
@@ -79,12 +80,13 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
loading={Boolean(loading)}
|
||||
animate={!loading}
|
||||
animationDurationMs={900}
|
||||
ariaLabel={t('config.apiKeys.chartAriaLabel', {
|
||||
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}}'
|
||||
defaultValue:
|
||||
"Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -93,18 +95,16 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('config.apiKeys.nextReset', 'Next Reset')}: {loading ? (
|
||||
{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)
|
||||
)}
|
||||
{t("config.apiKeys.lastApiUse", "Last API Use")}:{" "}
|
||||
{loading ? <SkeletonLoader type="block" width={160} height={12} /> : formatDate(apiUsage.lastApiUsage, true)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -130,10 +130,10 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{overlayMessage || t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
|
||||
{overlayMessage || t("config.apiKeys.overlayMessage", "Generate a key to see credits and available credits")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,5 +66,3 @@ export function useApiKey() {
|
||||
}
|
||||
|
||||
export default useApiKey;
|
||||
|
||||
|
||||
|
||||
+15
-28
@@ -12,25 +12,13 @@ function coerceNumber(value: unknown, fallback = 0): number {
|
||||
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 ?? ""),
|
||||
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 ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,13 +37,14 @@ export function useCredits() {
|
||||
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;
|
||||
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)));
|
||||
@@ -75,5 +64,3 @@ export function useCredits() {
|
||||
}
|
||||
|
||||
export default useCredits;
|
||||
|
||||
|
||||
|
||||
+25
-26
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
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;
|
||||
@@ -27,23 +27,20 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
_activeSince,
|
||||
_nextBillingDate,
|
||||
trialStatus,
|
||||
onAddPaymentClick
|
||||
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 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}
|
||||
/>
|
||||
<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 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 */}
|
||||
@@ -53,22 +50,23 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
|
||||
mt="md"
|
||||
mb="md"
|
||||
title={t('plan.trial.title', 'Free Trial Active')}
|
||||
title={t("plan.trial.title", "Free Trial Active")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('plan.trial.daysRemaining', 'Your trial ends in {{days}} days', {
|
||||
days: trialStatus.daysRemaining
|
||||
{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()
|
||||
{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()
|
||||
✓{" "}
|
||||
{t("plan.trial.subscriptionScheduled", "Subscription scheduled - starts {{date}}", {
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -80,7 +78,7 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
onClick={onAddPaymentClick}
|
||||
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
|
||||
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -96,7 +94,7 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
</Text>
|
||||
{trialStatus?.isTrialing && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t('plan.trial.badge', 'Trial')}
|
||||
{t("plan.trial.badge", "Trial")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
@@ -108,7 +106,8 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Text size="xl" fw={700}>
|
||||
{currentPlan.currency}{currentPlan.price}/month
|
||||
{currentPlan.currency}
|
||||
{currentPlan.price}/month
|
||||
</Text>
|
||||
{/* {nextBillingDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
+19
-18
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
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;
|
||||
@@ -23,17 +23,17 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
apiPackages,
|
||||
selectedCredits,
|
||||
onSelectedCreditsChange,
|
||||
onCreditPurchaseClick
|
||||
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 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 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">
|
||||
@@ -41,7 +41,7 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
{/* Credits Selection */}
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t('plan.selectCredits', 'Select Credit Amount')}
|
||||
{t("plan.selectCredits", "Select Credit Amount")}
|
||||
</Text>
|
||||
|
||||
<div className="px-4">
|
||||
@@ -53,10 +53,10 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
max={3}
|
||||
step={0.01}
|
||||
marks={[
|
||||
{ value: 0, label: '100' },
|
||||
{ value: 1, label: '500' },
|
||||
{ value: 2, label: '1K' },
|
||||
{ value: 3, label: '5K' }
|
||||
{ value: 0, label: "100" },
|
||||
{ value: 1, label: "500" },
|
||||
{ value: 2, label: "1K" },
|
||||
{ value: 3, label: "5K" },
|
||||
]}
|
||||
size="lg"
|
||||
className="mb-6"
|
||||
@@ -78,10 +78,11 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
|
||||
<div className="">
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[Math.round(selectedCredits)].currency}{apiPackages[Math.round(selectedCredits)].price}
|
||||
{apiPackages[Math.round(selectedCredits)].currency}
|
||||
{apiPackages[Math.round(selectedCredits)].price}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.totalCost', 'Total Cost')}
|
||||
{t("plan.totalCost", "Total Cost")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +90,7 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
size="lg"
|
||||
onClick={() => onCreditPurchaseClick(apiPackages[Math.round(selectedCredits)].id as CreditsPack)}
|
||||
>
|
||||
{t('plan.purchase', 'Purchase')}
|
||||
{t("plan.purchase", "Purchase")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
@@ -98,4 +99,4 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiPackagesSection;
|
||||
export default ApiPackagesSection;
|
||||
|
||||
+32
-40
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
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[];
|
||||
@@ -16,27 +16,23 @@ interface AvailablePlansSectionProps {
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentPlan,
|
||||
onUpgradeClick
|
||||
}) => {
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, currentPlan, onUpgradeClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
const isUserProOrAbove = currentPlan?.id === 'pro' || currentPlan?.id === 'enterprise';
|
||||
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 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 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 => (
|
||||
<div className="flex h-[20rem] mb-4 " style={{ gap: "1rem", overflowX: "auto" }}>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
@@ -48,29 +44,25 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowComparison(!showComparison)}
|
||||
>
|
||||
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
|
||||
{showComparison
|
||||
? t('plan.hideComparison', 'Hide Feature Comparison')
|
||||
: t('plan.showComparison', 'Compare All Features')
|
||||
}
|
||||
? 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')}
|
||||
{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 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 && (
|
||||
@@ -78,16 +70,16 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
color="blue"
|
||||
variant="filled"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0rem',
|
||||
right: '-2rem',
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: '500',
|
||||
height: '1rem',
|
||||
padding: '0 0.1rem'
|
||||
position: "absolute",
|
||||
top: "0rem",
|
||||
right: "-2rem",
|
||||
fontSize: "0.5rem",
|
||||
fontWeight: "500",
|
||||
height: "1rem",
|
||||
padding: "0 0.1rem",
|
||||
}}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
@@ -97,13 +89,13 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
<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 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="green" fw={600}>
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="gray">-</Text>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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';
|
||||
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
|
||||
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
|
||||
currentTier?: string | null; // Accept null for proprietary compatibility
|
||||
onUpgradeClick?: (plan: PlanTier) => void;
|
||||
onManageClick?: (plan: PlanTier) => void;
|
||||
loginEnabled?: boolean;
|
||||
@@ -28,7 +28,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
currentTier: _currentTier,
|
||||
onUpgradeClick,
|
||||
onManageClick: _onManageClick,
|
||||
loginEnabled: _loginEnabled
|
||||
loginEnabled: _loginEnabled,
|
||||
}) => {
|
||||
// Use plan from props, or extract from planGroup if proprietary is using it
|
||||
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
|
||||
@@ -36,26 +36,24 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
|
||||
if (!plan) return null; // Safety check
|
||||
|
||||
const shouldHideUpgrade = plan.id === 'free' && isUserProOrAbove;
|
||||
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 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>
|
||||
<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}`}
|
||||
{plan.isContactOnly ? t("plan.customPricing", "Custom") : `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{!plan.isContactOnly && (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -83,11 +81,10 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
onClick={() => onUpgradeClick?.(plan)}
|
||||
>
|
||||
{isCurrentPlan
|
||||
? t('plan.current', 'Current Plan')
|
||||
? t("plan.current", "Current Plan")
|
||||
: plan.isContactOnly
|
||||
? t('plan.contact', 'Get in Touch')
|
||||
: t('plan.upgrade', 'Upgrade')
|
||||
}
|
||||
? t("plan.contact", "Get in Touch")
|
||||
: t("plan.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import React from 'react';
|
||||
import { type TFunction } from 'i18next';
|
||||
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';
|
||||
} 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>;
|
||||
t: TFunction<"translation", undefined>;
|
||||
}
|
||||
|
||||
function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSection[] {
|
||||
const preferencesIndex = sections.findIndex(section => section.title === 'Preferences');
|
||||
const preferencesIndex = sections.findIndex((section) => section.title === "Preferences");
|
||||
|
||||
if (preferencesIndex === -1) {
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: 'Preferences',
|
||||
title: "Preferences",
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
icon: 'settings-rounded',
|
||||
key: "general",
|
||||
label: "General",
|
||||
icon: "settings-rounded",
|
||||
component: <GeneralSection />,
|
||||
},
|
||||
{
|
||||
key: 'hotkeys',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'keyboard-rounded',
|
||||
key: "hotkeys",
|
||||
label: "Keyboard Shortcuts",
|
||||
icon: "keyboard-rounded",
|
||||
component: <HotkeysSection />,
|
||||
},
|
||||
],
|
||||
@@ -48,8 +48,8 @@ function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSectio
|
||||
}
|
||||
|
||||
function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[] {
|
||||
const hasDeveloper = sections.some(section =>
|
||||
section.items.some(item => item.key === 'developer' || item.key === 'api-keys'),
|
||||
const hasDeveloper = sections.some((section) =>
|
||||
section.items.some((item) => item.key === "developer" || item.key === "api-keys"),
|
||||
);
|
||||
|
||||
if (hasDeveloper) {
|
||||
@@ -59,12 +59,12 @@ function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: 'Developer',
|
||||
title: "Developer",
|
||||
items: [
|
||||
{
|
||||
key: 'api-keys',
|
||||
label: 'API Keys',
|
||||
icon: 'key-rounded',
|
||||
key: "api-keys",
|
||||
label: "API Keys",
|
||||
icon: "key-rounded",
|
||||
component: <ApiKeys />,
|
||||
},
|
||||
],
|
||||
@@ -72,13 +72,8 @@ function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[
|
||||
];
|
||||
}
|
||||
|
||||
function appendBillingSection(
|
||||
sections: ConfigNavSection[],
|
||||
t: TFunction<'translation', undefined>,
|
||||
): ConfigNavSection[] {
|
||||
const hasPlan = sections.some(section =>
|
||||
section.items.some(item => item.key === 'plan'),
|
||||
);
|
||||
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;
|
||||
@@ -87,12 +82,12 @@ function appendBillingSection(
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: 'Billing',
|
||||
title: "Billing",
|
||||
items: [
|
||||
{
|
||||
key: 'plan',
|
||||
label: t('config.plan', 'Plan'),
|
||||
icon: 'credit-card',
|
||||
key: "plan",
|
||||
label: t("config.plan", "Plan"),
|
||||
icon: "credit-card",
|
||||
component: <Plan />,
|
||||
},
|
||||
],
|
||||
@@ -109,18 +104,18 @@ export function createSaasConfigNavSections(
|
||||
|
||||
// Create Account section as the first section with Overview and Passwords & Security
|
||||
const accountSection: ConfigNavSection = {
|
||||
title: t('config.account.overview.title', 'Account Settings'),
|
||||
title: t("config.account.overview.title", "Account Settings"),
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
label: t('config.account.overview.label', 'Overview'),
|
||||
icon: 'account-circle',
|
||||
key: "overview",
|
||||
label: t("config.account.overview.label", "Overview"),
|
||||
icon: "account-circle",
|
||||
component: <Overview onLogoutClick={onLogoutClick} />,
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
label: 'Passwords & Security',
|
||||
icon: 'lock',
|
||||
key: "security",
|
||||
label: "Passwords & Security",
|
||||
icon: "lock",
|
||||
component: <PasswordSecurity />,
|
||||
},
|
||||
],
|
||||
@@ -129,12 +124,10 @@ export function createSaasConfigNavSections(
|
||||
let sections = [accountSection, ...baseSections];
|
||||
|
||||
// Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS
|
||||
sections = sections.map(section => ({
|
||||
sections = sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map(item =>
|
||||
item.key === 'general'
|
||||
? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> }
|
||||
: item
|
||||
items: section.items.map((item) =>
|
||||
item.key === "general" ? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> } : item,
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -146,7 +139,7 @@ export function createSaasConfigNavSections(
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
console.debug('[AppConfigModal] SaaS navigation sections', sections);
|
||||
console.debug("[AppConfigModal] SaaS navigation sections", sections);
|
||||
}
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
// 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'
|
||||
"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",
|
||||
] 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';
|
||||
| "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";
|
||||
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
|
||||
@@ -10,5 +10,3 @@ export function formatUTC(iso: string, withTime: boolean): string {
|
||||
}).format(date);
|
||||
return withTime ? `${formatted} UTC` : formatted;
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user