mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Prettier 2: Electric Boogaloo (#6113)
# Description of Changes When I added Prettier formatting in #6052, my aim was to use just the default settings in Prettier. Turns out, Prettier looks _really hard_ for any config files if it's not explicitly given one, which means that if a developer has some sort of Prettier config file lying around on their system, Prettier might find it and use it. Also, Prettier changes its defaults based on stuff in `.editorconfig` without any good way of disabling that behaviour explicitly in its config file. To solve both of these issues, I've introduced a `.prettierrc` file which sets Prettier's defaults explicitly, and then reformatted all our code _again_ in Prettier's actual default settings. This should achieve the aim of #6052 and remove the possibility for it breaking on different dev computers.
This commit is contained in:
@@ -13,7 +13,8 @@ const ONBOARDING_SESSION_BLOCK_KEY = "stirling-onboarding-session-active";
|
||||
*/
|
||||
export default function OnboardingBootstrap() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding();
|
||||
const { clearPendingTourRequest, setStartAfterToolModeSelection } =
|
||||
useOnboarding();
|
||||
const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
@@ -38,7 +39,10 @@ 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);
|
||||
@@ -78,7 +82,10 @@ export default function OnboardingBootstrap() {
|
||||
// Keep existing logic to disable core onboarding flags
|
||||
useEffect(() => {
|
||||
// Ensure tool panel preference is set so tours are never deferred.
|
||||
if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
|
||||
if (
|
||||
!preferences.toolPanelModePromptSeen ||
|
||||
!preferences.hasSelectedToolPanelMode
|
||||
) {
|
||||
updatePreference("toolPanelModePromptSeen", true);
|
||||
updatePreference("hasSelectedToolPanelMode", true);
|
||||
}
|
||||
@@ -111,5 +118,7 @@ export default function OnboardingBootstrap() {
|
||||
]);
|
||||
|
||||
// Only render modal when it should be shown to avoid running hooks unnecessarily
|
||||
return showModal ? <SaasOnboardingModal opened={showModal} onClose={handleClose} /> : null;
|
||||
return showModal ? (
|
||||
<SaasOnboardingModal opened={showModal} onClose={handleClose} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -45,12 +45,20 @@ export default function TrialExpiredBootstrap() {
|
||||
}
|
||||
|
||||
// Check if all conditions are met to show the modal
|
||||
const isExpired = trialStatus.status === "incomplete_expired" || trialStatus.status === "canceled";
|
||||
const hasNoPayment = !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub;
|
||||
const 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", {
|
||||
@@ -92,7 +100,11 @@ export default function TrialExpiredBootstrap() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrialExpiredModal opened={showModal && !checkoutOpened} onClose={handleClose} onSubscribe={handleSubscribe} />
|
||||
<TrialExpiredModal
|
||||
opened={showModal && !checkoutOpened}
|
||||
onClose={handleClose}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<StripeCheckout
|
||||
@@ -103,7 +115,9 @@ 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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -50,10 +50,16 @@ export function GuestUserBanner({ className = "" }: GuestUserBannerProps) {
|
||||
};
|
||||
|
||||
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",
|
||||
|
||||
@@ -12,11 +12,19 @@ export function RequireAuth({ fallbackPath = "/login" }: RequireAuthProps) {
|
||||
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");
|
||||
console.warn(
|
||||
"[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost",
|
||||
);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ export default function UserbackWidget({ token }: UserbackWidgetProps) {
|
||||
try {
|
||||
// Prepare user data options
|
||||
const userInfo: { name?: string; email?: string } = {};
|
||||
if (user.user_metadata?.full_name) userInfo.name = user.user_metadata.full_name;
|
||||
if (user.user_metadata?.full_name)
|
||||
userInfo.name = user.user_metadata.full_name;
|
||||
if (user.email) userInfo.email = user.email;
|
||||
|
||||
const options = {
|
||||
@@ -45,7 +46,10 @@ 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;
|
||||
|
||||
@@ -24,14 +24,25 @@ 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") {
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
<img
|
||||
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||
alt="Stirling icon"
|
||||
className={styles.downloadIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -40,9 +51,16 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === "rocket" && (
|
||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -72,7 +90,12 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
<Stack
|
||||
gap={0}
|
||||
className={styles.modalContent}
|
||||
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
|
||||
style={{
|
||||
height: "100%",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
|
||||
<AnimatedSlideBackground
|
||||
@@ -96,18 +119,27 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
<div
|
||||
key={`body-${currentSlide.key}`}
|
||||
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
|
||||
>
|
||||
{currentSlide.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={currentStep} />
|
||||
<OnboardingStepper
|
||||
totalSteps={totalSteps}
|
||||
activeStep={currentStep}
|
||||
/>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{renderButtons({
|
||||
|
||||
@@ -2,7 +2,11 @@ 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 {
|
||||
ButtonDefinition,
|
||||
type FlowState,
|
||||
type ButtonAction,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
interface RenderButtonsProps {
|
||||
slideDefinition: {
|
||||
@@ -14,9 +18,18 @@ interface RenderButtonsProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) {
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
|
||||
export function renderButtons({
|
||||
slideDefinition,
|
||||
flowState,
|
||||
onAction,
|
||||
t,
|
||||
}: RenderButtonsProps) {
|
||||
const leftButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "left",
|
||||
);
|
||||
const rightButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "right",
|
||||
);
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
@@ -63,7 +76,9 @@ export function renderButtons({ slideDefinition, flowState, onAction, t }: Rende
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
|
||||
{button.icon === "chevron-left" && (
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +87,12 @@ export function renderButtons({ slideDefinition, flowState, onAction, t }: Rende
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
|
||||
<Button
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={disabled}
|
||||
styles={buttonStyles(variant)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
import { FLOW_SEQUENCES, SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
import {
|
||||
FLOW_SEQUENCES,
|
||||
SlideId,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
export interface FlowConfig {
|
||||
type: "saas-trial" | "saas-paid";
|
||||
@@ -13,12 +16,16 @@ 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 {
|
||||
|
||||
@@ -11,24 +11,35 @@ 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 }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Format the trial end date
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
// Determine which message to show based on payment method
|
||||
const afterTrialMessage = trialStatus.hasScheduledSub
|
||||
? t("onboarding.freeTrial.afterTrialWithPayment", "Your Pro subscription will start automatically when the trial ends.")
|
||||
? 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.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.",
|
||||
@@ -37,8 +48,14 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
// 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 });
|
||||
? 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" }}>
|
||||
@@ -56,9 +73,19 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "1.25rem", fontWeight: "bold", marginBottom: "0.5rem" }}>{daysText}</div>
|
||||
<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 })}
|
||||
{t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", {
|
||||
date: trialEndDate,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.9rem", opacity: 0.9 }}>{afterTrialMessage}</p>
|
||||
@@ -66,7 +93,9 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
|
||||
export default function FreeTrialSlide({
|
||||
trialStatus,
|
||||
}: FreeTrialSlideProps): SlideConfig {
|
||||
return {
|
||||
key: "free-trial",
|
||||
title: <FreeTrialSlideTitle />,
|
||||
|
||||
@@ -24,7 +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>("");
|
||||
@@ -44,7 +47,10 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
case "windows":
|
||||
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||
case "mac-apple":
|
||||
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
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":
|
||||
@@ -58,8 +64,16 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
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: "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);
|
||||
@@ -71,7 +85,10 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
@@ -84,7 +101,8 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
}
|
||||
}, [flowSlideIds.length, currentStep]);
|
||||
|
||||
const currentSlideId = flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const currentSlideId =
|
||||
flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
// Create slide with appropriate params - must be called before any early returns
|
||||
@@ -97,7 +115,14 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
trialStatus: trialStatus ?? undefined,
|
||||
});
|
||||
}, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange, trialStatus]);
|
||||
}, [
|
||||
slideDefinition,
|
||||
os.label,
|
||||
os.url,
|
||||
osOptions,
|
||||
handleDownloadUrlChange,
|
||||
trialStatus,
|
||||
]);
|
||||
|
||||
// Navigation functions
|
||||
const goNext = useCallback(() => {
|
||||
|
||||
@@ -10,7 +10,10 @@ import { createSaasConfigNavSections } from "@app/components/shared/config/saasC
|
||||
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 {
|
||||
Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
Z_INDEX_OVER_SETTINGS_MODAL,
|
||||
} from "@app/styles/zIndex";
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -36,31 +39,44 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||
return () => window.removeEventListener("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;
|
||||
const detail = (ev as CustomEvent).detail as
|
||||
| { key?: NavKey; notice?: string }
|
||||
| undefined;
|
||||
if (detail?.notice && (detail?.key ? detail.key === "plan" : true)) {
|
||||
setNotice(detail.notice);
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:notice", handler as EventListener);
|
||||
return () => window.removeEventListener("appConfig:notice", handler as EventListener);
|
||||
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);
|
||||
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]);
|
||||
@@ -68,7 +84,10 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (active !== "plan") return;
|
||||
console.log("[AppConfigModal] Credit balance updated while viewing Plan:", creditBalance);
|
||||
console.log(
|
||||
"[AppConfigModal] Credit balance updated while viewing Plan:",
|
||||
creditBalance,
|
||||
);
|
||||
}, [opened, active, creditBalance]);
|
||||
|
||||
const colors = useMemo(
|
||||
@@ -154,7 +173,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map((item) => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const color = isActive
|
||||
? colors.navItemActive
|
||||
: colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
@@ -162,10 +183,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : "transparent",
|
||||
background: isActive
|
||||
? colors.navItemActiveBg
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
<LocalIcon
|
||||
icon={item.icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
@@ -194,10 +222,22 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<Text fw={700} size="lg">
|
||||
{activeLabel}
|
||||
{active === "plan" && notice ? (
|
||||
<span style={{ marginLeft: 8, fontWeight: 600, color: "var(--mantine-color-yellow-7)" }}>– {notice}</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-yellow-7)",
|
||||
}}
|
||||
>
|
||||
– {notice}
|
||||
</span>
|
||||
) : null}
|
||||
</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
@@ -100,8 +100,19 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: "100%" }}>
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{icon &&
|
||||
(typeof icon === "string" ? (
|
||||
<LocalIcon
|
||||
@@ -111,15 +122,28 @@ 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 }}>
|
||||
<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>
|
||||
@@ -132,7 +156,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
styles={
|
||||
buttonTextColor
|
||||
? {
|
||||
|
||||
@@ -38,14 +38,18 @@ 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,
|
||||
},
|
||||
});
|
||||
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");
|
||||
@@ -56,7 +60,11 @@ export function ManageBillingButton({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={onClick} disabled={loading} className="px-4 py-2 rounded bg-black text-white">
|
||||
<Button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-black text-white"
|
||||
>
|
||||
{loading ? "Opening…" : children}
|
||||
</Button>
|
||||
{err && <div className="mt-2 text-red-600">{err}</div>}
|
||||
|
||||
@@ -9,7 +9,12 @@ 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 }) => {
|
||||
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 = {
|
||||
|
||||
@@ -2,7 +2,10 @@ 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 {
|
||||
EmbeddedCheckoutProvider,
|
||||
EmbeddedCheckout,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
@@ -26,7 +29,12 @@ 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;
|
||||
@@ -65,16 +73,19 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
try {
|
||||
setState({ status: "loading" });
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("create-checkout", {
|
||||
body: {
|
||||
purchase_type: purchaseType,
|
||||
ui_mode: "embedded",
|
||||
plan: planId,
|
||||
credits_pack: creditsPack,
|
||||
callback_base_url: window.location.origin,
|
||||
trial_conversion: isTrialConversion || false,
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"create-checkout",
|
||||
{
|
||||
body: {
|
||||
purchase_type: purchaseType,
|
||||
ui_mode: "embedded",
|
||||
plan: planId,
|
||||
credits_pack: creditsPack,
|
||||
callback_base_url: window.location.origin,
|
||||
trial_conversion: isTrialConversion || false,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to create checkout session");
|
||||
@@ -100,7 +111,10 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
},
|
||||
});
|
||||
} 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,
|
||||
@@ -120,7 +134,12 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -136,12 +155,21 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
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]);
|
||||
|
||||
@@ -175,7 +203,10 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
|
||||
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(
|
||||
@@ -184,7 +215,10 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -11,12 +11,22 @@ interface TrialExpiredModalProps {
|
||||
onSubscribe: () => void;
|
||||
}
|
||||
|
||||
export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpiredModalProps) {
|
||||
export function TrialExpiredModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSubscribe,
|
||||
}: TrialExpiredModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use CSS variables for theme colors
|
||||
const amberColor = getComputedStyle(document.documentElement).getPropertyValue("--color-amber-500").trim() || "#f59e0b";
|
||||
const redColor = getComputedStyle(document.documentElement).getPropertyValue("--color-red-500").trim() || "#ef4444";
|
||||
const 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 = [
|
||||
@@ -69,10 +79,20 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
<Stack
|
||||
gap={0}
|
||||
className={styles.modalContent}
|
||||
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
|
||||
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" }} />
|
||||
@@ -90,7 +110,9 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
}}
|
||||
>
|
||||
<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}`}>
|
||||
@@ -103,7 +125,10 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
|
||||
<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>
|
||||
|
||||
@@ -152,7 +177,8 @@ 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))",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))",
|
||||
color: "#FFFFFF",
|
||||
fontSize: "0.9375rem",
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -46,10 +46,13 @@ 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",
|
||||
@@ -94,7 +97,14 @@ export function TrialStatusBanner() {
|
||||
return () => {
|
||||
setBanner(null);
|
||||
};
|
||||
}, [shouldShowBanner, trialStatus, setBanner, t, handleOpenCheckout, handleDismiss]);
|
||||
}, [
|
||||
shouldShowBanner,
|
||||
trialStatus,
|
||||
setBanner,
|
||||
t,
|
||||
handleOpenCheckout,
|
||||
handleDismiss,
|
||||
]);
|
||||
|
||||
const handleCheckoutSuccess = () => {
|
||||
// Refresh to hide banner and show updated plan
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
createRoundedRectPath,
|
||||
createScale,
|
||||
} from "@app/components/shared/charts/utils/d3Utils";
|
||||
|
||||
export default function StackedBarChart({
|
||||
fractions,
|
||||
@@ -27,10 +37,16 @@ export default function StackedBarChart({
|
||||
|
||||
// Memoize theme detection to avoid recalculation
|
||||
const theme = useMemo(() => detectTheme(), []);
|
||||
const themeVars = useMemo(() => getChartThemeVars(theme.isDark), [theme.isDark]);
|
||||
const themeVars = useMemo(
|
||||
() => getChartThemeVars(theme.isDark),
|
||||
[theme.isDark],
|
||||
);
|
||||
|
||||
// Memoize tooltip positioner
|
||||
const tooltipPositioner = useMemo(() => createTooltipPositioner(tooltipPosition), [tooltipPosition]);
|
||||
const tooltipPositioner = useMemo(
|
||||
() => createTooltipPositioner(tooltipPosition),
|
||||
[tooltipPosition],
|
||||
);
|
||||
|
||||
const positionTooltip = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -70,7 +86,10 @@ export default function StackedBarChart({
|
||||
container.innerHTML = "";
|
||||
|
||||
// Calculate total capacity (sum of all denominators)
|
||||
const totalCapacity = fractions.reduce((sum: number, fraction: FractionData) => sum + fraction.denominator, 0);
|
||||
const totalCapacity = fractions.reduce(
|
||||
(sum: number, fraction: FractionData) => sum + fraction.denominator,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalCapacity === 0 && !loading) return;
|
||||
|
||||
@@ -150,21 +169,35 @@ export default function StackedBarChart({
|
||||
.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,
|
||||
});
|
||||
const path = createRoundedRectPath(
|
||||
xPos,
|
||||
0,
|
||||
segWidth,
|
||||
height,
|
||||
radius,
|
||||
{
|
||||
topLeft: true,
|
||||
topRight: false,
|
||||
bottomLeft: true,
|
||||
bottomRight: false,
|
||||
},
|
||||
);
|
||||
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
|
||||
} else if (isLast) {
|
||||
// Last segment: rounded on right side only
|
||||
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
|
||||
topLeft: false,
|
||||
topRight: true,
|
||||
bottomLeft: false,
|
||||
bottomRight: true,
|
||||
});
|
||||
const path = createRoundedRectPath(
|
||||
xPos,
|
||||
0,
|
||||
segWidth,
|
||||
height,
|
||||
radius,
|
||||
{
|
||||
topLeft: false,
|
||||
topRight: true,
|
||||
bottomLeft: false,
|
||||
bottomRight: true,
|
||||
},
|
||||
);
|
||||
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
|
||||
} else {
|
||||
// Middle segments: no rounded edges
|
||||
@@ -190,21 +223,32 @@ export default function StackedBarChart({
|
||||
.attr("fill", "transparent")
|
||||
.style("pointer-events", "all")
|
||||
.on("mouseenter", (event: MouseEvent) => {
|
||||
const tooltipData: TooltipData = { fractions: data, isDark: theme.isDark };
|
||||
const tooltipData: TooltipData = {
|
||||
fractions: data,
|
||||
isDark: theme.isDark,
|
||||
};
|
||||
const html = generateTooltipHTML(tooltipData);
|
||||
setTooltipContent(html);
|
||||
const tooltip = tooltipRef.current;
|
||||
if (tooltip) tooltip.style.opacity = "1";
|
||||
positionTooltip(event as unknown as MouseEvent);
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
|
||||
.on("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);
|
||||
@@ -246,7 +290,15 @@ export default function StackedBarChart({
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -9,9 +9,19 @@ 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" }}>
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: "flex", gap: "8px", alignItems: "center" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
@@ -22,7 +32,8 @@ export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
|
||||
}}
|
||||
></span>
|
||||
<span>
|
||||
<strong>{f.name}</strong> — {f.numeratorLabel}: {f.numerator} · {f.denominatorLabel}: {f.denominator - f.numerator}
|
||||
<strong>{f.name}</strong> — {f.numeratorLabel}: {f.numerator} ·{" "}
|
||||
{f.denominatorLabel}: {f.denominator - f.numerator}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -102,9 +102,19 @@ 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;
|
||||
const {
|
||||
topLeft = true,
|
||||
topRight = true,
|
||||
bottomLeft = true,
|
||||
bottomRight = true,
|
||||
} = corners;
|
||||
|
||||
if (width <= 0 || height <= 0) return "";
|
||||
|
||||
@@ -162,7 +172,10 @@ 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,10 +13,13 @@ export interface ThemeInfo {
|
||||
* @returns ThemeInfo object with theme detection results
|
||||
*/
|
||||
export function detectTheme(): ThemeInfo {
|
||||
const rootEl = typeof document !== "undefined" ? document.documentElement : null;
|
||||
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;
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
const isDark = schemeAttr ? schemeAttr === "dark" : prefersDark;
|
||||
|
||||
@@ -48,7 +51,10 @@ export function getChartThemeVars(isDark: boolean) {
|
||||
* @param tooltipElement The tooltip DOM element
|
||||
* @param isDark Whether the theme is dark
|
||||
*/
|
||||
export function applyTooltipStyles(tooltipElement: HTMLElement, isDark: boolean) {
|
||||
export function applyTooltipStyles(
|
||||
tooltipElement: HTMLElement,
|
||||
isDark: boolean,
|
||||
) {
|
||||
const themeVars = getChartThemeVars(isDark);
|
||||
|
||||
tooltipElement.style.background = themeVars.background;
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
export type TooltipPosition = "top" | "bottom" | "left" | "right";
|
||||
|
||||
export interface TooltipPositioner {
|
||||
positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void;
|
||||
positionTooltip: (
|
||||
event: MouseEvent,
|
||||
tooltip: HTMLElement,
|
||||
container: HTMLElement,
|
||||
) => void;
|
||||
hideTooltip: (tooltip: HTMLElement) => void;
|
||||
}
|
||||
|
||||
@@ -14,8 +18,14 @@ export interface TooltipPositioner {
|
||||
* @param position The tooltip position preference
|
||||
* @returns TooltipPositioner object with positioning functions
|
||||
*/
|
||||
export function createTooltipPositioner(position: TooltipPosition): TooltipPositioner {
|
||||
const positionTooltip = (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => {
|
||||
export function createTooltipPositioner(
|
||||
position: TooltipPosition,
|
||||
): TooltipPositioner {
|
||||
const positionTooltip = (
|
||||
event: MouseEvent,
|
||||
tooltip: HTMLElement,
|
||||
container: HTMLElement,
|
||||
) => {
|
||||
const bounds = container.getBoundingClientRect();
|
||||
const offsetX = event.clientX - bounds.left;
|
||||
const offsetY = event.clientY - bounds.top;
|
||||
|
||||
@@ -12,7 +12,12 @@ 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
|
||||
@@ -75,9 +80,12 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ fi
|
||||
}, []);
|
||||
|
||||
// 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 () => {
|
||||
@@ -110,7 +118,12 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ fi
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Error cropping image:", err);
|
||||
setError(t("config.account.profilePicture.cropper.cropError", "Failed to crop image. Please try again."));
|
||||
setError(
|
||||
t(
|
||||
"config.account.profilePicture.cropper.cropError",
|
||||
"Failed to crop image. Please try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@@ -120,7 +133,10 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ fi
|
||||
<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}
|
||||
@@ -152,18 +168,33 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ fi
|
||||
<Text size="sm" fw={500}>
|
||||
{t("config.account.profilePicture.cropper.zoom", "Zoom")}
|
||||
</Text>
|
||||
<Slider value={zoom} min={1} max={3} step={0.1} onChange={setZoom} disabled={processing} />
|
||||
<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")}
|
||||
</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>
|
||||
|
||||
@@ -18,7 +18,15 @@ export default function ApiKeys() {
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
|
||||
const { data: credits, isLoading: creditsLoading } = useCredits();
|
||||
const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch, hasAttempted } = useApiKey();
|
||||
const {
|
||||
apiKey,
|
||||
isLoading: apiKeyLoading,
|
||||
refresh,
|
||||
isRefreshing,
|
||||
error: apiKeyError,
|
||||
refetch,
|
||||
hasAttempted,
|
||||
} = useApiKey();
|
||||
|
||||
const copy = async (text: string, tag: string) => {
|
||||
try {
|
||||
@@ -40,7 +48,9 @@ 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);
|
||||
@@ -51,15 +61,26 @@ export default function ApiKeys() {
|
||||
<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.")}{" "}
|
||||
<Anchor component="button" underline="always" onClick={refetch} c="red.4">
|
||||
{t(
|
||||
"config.apiKeys.generateError",
|
||||
"We couldn't generate your API key.",
|
||||
)}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
underline="always"
|
||||
onClick={refetch}
|
||||
c="red.4"
|
||||
>
|
||||
{t("common.retry", "Retry")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
@@ -77,7 +98,12 @@ export default function ApiKeys() {
|
||||
>
|
||||
<Stack gap={10}>
|
||||
<Text fw={500}>{t("config.apiKeys.label", "API Key")}</Text>
|
||||
<Group justify="space-between" wrap="nowrap" align="center" style={{ gap: "1rem" }}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{ gap: "1rem" }}
|
||||
>
|
||||
<Text size="sm" c="dimmed" style={{ flex: 1 }}>
|
||||
{t(
|
||||
"config.apiKeys.guestInfo",
|
||||
@@ -124,7 +150,11 @@ export default function ApiKeys() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<RefreshModal opened={showRefreshModal} onClose={() => setShowRefreshModal(false)} onConfirm={refreshKeys} />
|
||||
<RefreshModal
|
||||
opened={showRefreshModal}
|
||||
onClose={() => setShowRefreshModal(false)}
|
||||
onConfirm={refreshKeys}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from "@app/auth/supabase";
|
||||
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";
|
||||
@@ -70,7 +75,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -87,7 +97,12 @@ 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;
|
||||
@@ -96,11 +111,13 @@ 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");
|
||||
@@ -127,7 +144,9 @@ 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");
|
||||
@@ -161,13 +180,20 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
await refreshProfilePictureMetadata();
|
||||
setSuccess(
|
||||
t("config.account.profilePicture.switchedToCustom", "Switched to custom picture. You can now upload your own."),
|
||||
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);
|
||||
}
|
||||
@@ -195,17 +221,23 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
// Refresh the session to reflect changes
|
||||
await refreshSession();
|
||||
|
||||
setSuccess("Account upgraded successfully! You can now sign in with your email.");
|
||||
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);
|
||||
@@ -224,7 +256,10 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
window.location.href = result.data.url;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`;
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Failed to upgrade account with ${provider}`;
|
||||
setUpgradeError(errorMessage);
|
||||
setIsLoading(false);
|
||||
sessionStorage.removeItem("pendingUpgrade");
|
||||
@@ -242,12 +277,18 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
await signOut();
|
||||
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);
|
||||
showToast({
|
||||
alertType: "error",
|
||||
title: t("config.account.overview.deleteFailedTitle", "Unable to delete account"),
|
||||
title: t(
|
||||
"config.account.overview.deleteFailedTitle",
|
||||
"Unable to delete account",
|
||||
),
|
||||
body: message,
|
||||
expandable: true,
|
||||
location: "top-right",
|
||||
@@ -264,26 +305,61 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
};
|
||||
|
||||
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" }}>
|
||||
<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.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>
|
||||
@@ -296,11 +372,26 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
<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 && (
|
||||
@@ -317,26 +408,61 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
{isOAuthPicture ? (
|
||||
<Group align="center" gap="md">
|
||||
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
|
||||
<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")}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUseCustomPicture}
|
||||
disabled={profileUploading}
|
||||
>
|
||||
{t(
|
||||
"config.account.profilePicture.useCustom",
|
||||
"Use custom picture",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group align="center" gap="md">
|
||||
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
|
||||
<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}
|
||||
@@ -349,12 +475,19 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button variant="outline" onClick={handleProfileRemove} disabled={!profilePictureUrl || profileUploading}>
|
||||
<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>
|
||||
@@ -376,11 +509,26 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
{isAnonymous && (
|
||||
<div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
<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>
|
||||
|
||||
@@ -398,7 +546,10 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
<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" }}>
|
||||
{oauthProviders
|
||||
@@ -418,7 +569,15 @@ 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}
|
||||
@@ -430,24 +589,39 @@ 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")}
|
||||
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}>
|
||||
@@ -470,7 +644,11 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Button color="red" variant="outline" onClick={() => setDeleteModalOpen(true)}>
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
>
|
||||
{t("config.account.overview.deleteAccount", "Delete Account")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -480,7 +658,10 @@ 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}
|
||||
>
|
||||
@@ -517,7 +698,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={confirmEmail.toLowerCase() !== user?.email?.toLowerCase()}
|
||||
disabled={
|
||||
confirmEmail.toLowerCase() !== user?.email?.toLowerCase()
|
||||
}
|
||||
type="submit"
|
||||
loading={isDeletingAccount}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from "@mantine/core";
|
||||
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";
|
||||
@@ -23,7 +31,12 @@ const PasswordSecurity: React.FC = () => {
|
||||
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) {
|
||||
@@ -37,13 +50,20 @@ const PasswordSecurity: React.FC = () => {
|
||||
setSuccess(null);
|
||||
|
||||
// Update to the new password directly
|
||||
const { error: updateError } = await supabase.auth.updateUser({ password: newPassword });
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password: newPassword,
|
||||
});
|
||||
if (updateError) {
|
||||
setError(updateError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(t("login.passwordUpdatedSuccess", "Your password has been updated successfully."));
|
||||
setSuccess(
|
||||
t(
|
||||
"login.passwordUpdatedSuccess",
|
||||
"Your password has been updated successfully.",
|
||||
),
|
||||
);
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setDidUpdate(true);
|
||||
@@ -66,13 +86,34 @@ const PasswordSecurity: React.FC = () => {
|
||||
<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" }}>
|
||||
<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
|
||||
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">
|
||||
@@ -95,10 +136,16 @@ const PasswordSecurity: React.FC = () => {
|
||||
|
||||
{didUpdate ? (
|
||||
<Alert color="green" mb="md">
|
||||
{success || t("login.passwordUpdatedSuccess", "Your password has been updated successfully.")}
|
||||
{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")}
|
||||
@@ -107,17 +154,28 @@ const PasswordSecurity: React.FC = () => {
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("account.confirmNewPassword", "Confirm New Password")}
|
||||
placeholder={t("account.confirmNewPassword", "Confirm New Password")}
|
||||
placeholder={t(
|
||||
"account.confirmNewPassword",
|
||||
"Confirm New Password",
|
||||
)}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Divider my="sm" />
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="default" onClick={() => setOpened(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => setOpened(false)}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleChangePassword} loading={isLoading}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleChangePassword}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("config.account.security.update", "Update password")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 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";
|
||||
@@ -11,8 +15,10 @@ const Plan: React.FC = () => {
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
|
||||
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
|
||||
const [purchaseType, setPurchaseType] = useState<PurchaseType>("subscription");
|
||||
const [selectedCreditsPack, setSelectedCreditsPack] = useState<CreditsPack>(null);
|
||||
const [purchaseType, setPurchaseType] =
|
||||
useState<PurchaseType>("subscription");
|
||||
const [selectedCreditsPack, setSelectedCreditsPack] =
|
||||
useState<CreditsPack>(null);
|
||||
const [currency, setCurrency] = useState<string>("gbp");
|
||||
const { trialStatus } = useAuth();
|
||||
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
|
||||
@@ -33,7 +39,10 @@ const Plan: React.FC = () => {
|
||||
|
||||
if (plan.isContactOnly) {
|
||||
// Open contact form or redirect to contact page
|
||||
window.open("mailto:[email protected]?subject=Enterprise Plan Inquiry", "_blank");
|
||||
window.open(
|
||||
"mailto:[email protected]?subject=Enterprise Plan Inquiry",
|
||||
"_blank",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +102,9 @@ 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);
|
||||
@@ -112,7 +123,9 @@ const Plan: React.FC = () => {
|
||||
handleAddPaymentClick();
|
||||
// Clean up URL
|
||||
params.delete("action");
|
||||
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname;
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}, [data, handleAddPaymentClick]);
|
||||
@@ -142,7 +155,8 @@ const Plan: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } = data;
|
||||
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } =
|
||||
data;
|
||||
const plansArray = Array.from(plans.values());
|
||||
|
||||
return (
|
||||
@@ -174,7 +188,11 @@ const Plan: React.FC = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<AvailablePlansSection plans={plansArray} currentPlan={currentPlan} onUpgradeClick={handleUpgradeClick} />
|
||||
<AvailablePlansSection
|
||||
plans={plansArray}
|
||||
currentPlan={currentPlan}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -187,19 +205,27 @@ const Plan: React.FC = () => {
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen && (selectedPlan !== null || selectedCreditsPack !== null)}
|
||||
opened={
|
||||
checkoutOpen &&
|
||||
(selectedPlan !== null || selectedCreditsPack !== null)
|
||||
}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={purchaseType === "subscription" ? (selectedPlan?.id as PlanID) : 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 || ""
|
||||
: data?.apiPackages.find((pkg) => pkg.id === selectedCreditsPack)
|
||||
?.name || ""
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={trialStatus?.isTrialing && purchaseType === "subscription"}
|
||||
isTrialConversion={
|
||||
trialStatus?.isTrialing && purchaseType === "subscription"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,15 +16,23 @@ interface UsageSectionProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function UsageSection({ apiUsage, obscured, overlayMessage, loading }: UsageSectionProps) {
|
||||
export default function UsageSection({
|
||||
apiUsage,
|
||||
obscured,
|
||||
overlayMessage,
|
||||
loading,
|
||||
}: UsageSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const weeklyUsed = apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
|
||||
const boughtUsed = apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
|
||||
const weeklyUsed =
|
||||
apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
|
||||
const boughtUsed =
|
||||
apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
|
||||
|
||||
// Totals for overall usage visualization
|
||||
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
|
||||
|
||||
const formatDate = (iso: string, withTime: boolean) => formatUTC(iso, withTime);
|
||||
const formatDate = (iso: string, withTime: boolean) =>
|
||||
formatUTC(iso, withTime);
|
||||
|
||||
// Prepare data for the stacked bar chart
|
||||
const fractions: FractionData[] = [
|
||||
@@ -67,7 +75,11 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>
|
||||
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
|
||||
{loading ? <SkeletonLoader type="block" width={40} height={14} /> : totalRemaining}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={40} height={14} />
|
||||
) : (
|
||||
totalRemaining
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -104,7 +116,11 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
</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)}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={160} height={12} />
|
||||
) : (
|
||||
formatDate(apiUsage.lastApiUsage, true)
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -130,7 +146,11 @@ 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>
|
||||
)}
|
||||
|
||||
+27
-6
@@ -20,18 +20,29 @@ export function useApiKey() {
|
||||
setError(null);
|
||||
try {
|
||||
// Backend is POST for get and update
|
||||
const res = await apiClient.post<ApiKeyResponse>("/api/v1/user/get-api-key");
|
||||
const res = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/get-api-key",
|
||||
);
|
||||
const value = typeof res.data === "string" ? res.data : res.data.apiKey;
|
||||
if (typeof value === "string") setApiKey(value);
|
||||
} catch (e: unknown) {
|
||||
// If not found, try to create one by calling update endpoint
|
||||
if (isAxiosError(e) && e.response?.status === 404) {
|
||||
try {
|
||||
const createRes = await apiClient.post<ApiKeyResponse>("/api/v1/user/update-api-key");
|
||||
const created = typeof createRes.data === "string" ? createRes.data : createRes.data.apiKey;
|
||||
const createRes = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/update-api-key",
|
||||
);
|
||||
const created =
|
||||
typeof createRes.data === "string"
|
||||
? createRes.data
|
||||
: createRes.data.apiKey;
|
||||
if (typeof created === "string") setApiKey(created);
|
||||
} catch (createErr: unknown) {
|
||||
setError(createErr instanceof Error ? createErr : new Error(String(createErr)));
|
||||
setError(
|
||||
createErr instanceof Error
|
||||
? createErr
|
||||
: new Error(String(createErr)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
@@ -46,7 +57,9 @@ export function useApiKey() {
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiClient.post<ApiKeyResponse>("/api/v1/user/update-api-key");
|
||||
const res = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/update-api-key",
|
||||
);
|
||||
const value = typeof res.data === "string" ? res.data : res.data.apiKey;
|
||||
if (typeof value === "string") setApiKey(value);
|
||||
} catch (e: unknown) {
|
||||
@@ -62,7 +75,15 @@ export function useApiKey() {
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchKey]);
|
||||
|
||||
return { apiKey, isLoading, isRefreshing, error, refetch: fetchKey, refresh, hasAttempted } as const;
|
||||
return {
|
||||
apiKey,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
error,
|
||||
refetch: fetchKey,
|
||||
refresh,
|
||||
hasAttempted,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default useApiKey;
|
||||
|
||||
+30
-9
@@ -12,13 +12,27 @@ 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 ?? "",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +48,8 @@ export function useCredits() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiClient.get<Record<string, unknown>>("/api/v1/credits");
|
||||
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 =
|
||||
@@ -60,7 +75,13 @@ export function useCredits() {
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
|
||||
|
||||
return { data, isLoading, error, refetch: fetchCredits, hasAttempted } as const;
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchCredits,
|
||||
hasAttempted,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default useCredits;
|
||||
|
||||
+25
-6
@@ -34,12 +34,27 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center">
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
<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" }}>
|
||||
<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>
|
||||
|
||||
@@ -65,9 +80,13 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
{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>
|
||||
) : (
|
||||
onAddPaymentClick && (
|
||||
|
||||
+30
-6
@@ -29,11 +29,26 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
<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">
|
||||
@@ -48,7 +63,9 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
<Slider
|
||||
value={selectedCredits}
|
||||
onChange={onSelectedCreditsChange}
|
||||
onChangeEnd={(value) => onSelectedCreditsChange(Math.round(value))}
|
||||
onChangeEnd={(value) =>
|
||||
onSelectedCreditsChange(Math.round(value))
|
||||
}
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.01}
|
||||
@@ -69,7 +86,10 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
<Flex gap={"xl"} justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[Math.round(selectedCredits)].credits.toLocaleString()} Credits
|
||||
{apiPackages[
|
||||
Math.round(selectedCredits)
|
||||
].credits.toLocaleString()}{" "}
|
||||
Credits
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{apiPackages[Math.round(selectedCredits)].description}
|
||||
@@ -88,7 +108,11 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => onCreditPurchaseClick(apiPackages[Math.round(selectedCredits)].id as CreditsPack)}
|
||||
onClick={() =>
|
||||
onCreditPurchaseClick(
|
||||
apiPackages[Math.round(selectedCredits)].id as CreditsPack,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("plan.purchase", "Purchase")}
|
||||
</Button>
|
||||
|
||||
+43
-10
@@ -16,22 +16,45 @@ 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" }}>
|
||||
<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" }}>
|
||||
<div
|
||||
className="flex h-[20rem] mb-4 "
|
||||
style={{ gap: "1rem", overflowX: "auto" }}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
@@ -44,7 +67,10 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, cu
|
||||
</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")}
|
||||
@@ -61,9 +87,14 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, cu
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">{t("plan.feature.title", "Feature")}</th>
|
||||
<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">
|
||||
<th
|
||||
key={plan.id}
|
||||
className="text-center p-2 min-w-24 relative"
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
@@ -89,7 +120,9 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, cu
|
||||
<tbody>
|
||||
{plans[0].features.map((_, featureIndex) => (
|
||||
<tr key={featureIndex} className="border-b">
|
||||
<td className="p-2">{plans[0].features[featureIndex].name}</td>
|
||||
<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 ? (
|
||||
|
||||
@@ -39,9 +39,19 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
|
||||
|
||||
return (
|
||||
<Card key={plan.id} padding="lg" radius="sm" withBorder className="h-full w-[33%] relative">
|
||||
<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" }}>
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -53,7 +63,9 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
</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">
|
||||
@@ -75,7 +87,13 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
|
||||
{!shouldHideUpgrade && (
|
||||
<Button
|
||||
variant={isCurrentPlan ? "filled" : plan.isContactOnly ? "outline" : "filled"}
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "filled"
|
||||
: plan.isContactOnly
|
||||
? "outline"
|
||||
: "filled"
|
||||
}
|
||||
disabled={isCurrentPlan}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick?.(plan)}
|
||||
|
||||
@@ -18,8 +18,12 @@ interface CreateSaasConfigNavSectionsOptions {
|
||||
t: TFunction<"translation", undefined>;
|
||||
}
|
||||
|
||||
function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSection[] {
|
||||
const preferencesIndex = sections.findIndex((section) => section.title === "Preferences");
|
||||
function ensurePreferencesSection(
|
||||
sections: ConfigNavSection[],
|
||||
): ConfigNavSection[] {
|
||||
const preferencesIndex = sections.findIndex(
|
||||
(section) => section.title === "Preferences",
|
||||
);
|
||||
|
||||
if (preferencesIndex === -1) {
|
||||
return [
|
||||
@@ -47,9 +51,13 @@ function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSectio
|
||||
return sections;
|
||||
}
|
||||
|
||||
function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[] {
|
||||
function appendDeveloperSection(
|
||||
sections: ConfigNavSection[],
|
||||
): ConfigNavSection[] {
|
||||
const hasDeveloper = sections.some((section) =>
|
||||
section.items.some((item) => item.key === "developer" || item.key === "api-keys"),
|
||||
section.items.some(
|
||||
(item) => item.key === "developer" || item.key === "api-keys",
|
||||
),
|
||||
);
|
||||
|
||||
if (hasDeveloper) {
|
||||
@@ -72,8 +80,13 @@ 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;
|
||||
@@ -127,7 +140,12 @@ export function createSaasConfigNavSections(
|
||||
sections = sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) =>
|
||||
item.key === "general" ? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> } : item,
|
||||
item.key === "general"
|
||||
? {
|
||||
...item,
|
||||
component: <GeneralSection hideUpdateSection hideAdminBanner />,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from "@mantine/core";
|
||||
import {
|
||||
Stack,
|
||||
Button,
|
||||
Text,
|
||||
Alert,
|
||||
SegmentedControl,
|
||||
Divider,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Group,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
||||
import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
|
||||
import { useSignature } from "@app/contexts/SignatureContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { PLACEMENT_ACTIVATION_DELAY, FILE_SWITCH_ACTIVATION_DELAY } from "@app/constants/signConstants";
|
||||
import {
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
FILE_SWITCH_ACTIVATION_DELAY,
|
||||
} from "@app/constants/signConstants";
|
||||
|
||||
// Import the new reusable components
|
||||
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
|
||||
@@ -37,7 +51,10 @@ type SignatureDrafts = {
|
||||
|
||||
interface SignSettingsProps {
|
||||
parameters: SignParameters;
|
||||
onParameterChange: <K extends keyof SignParameters>(key: K, value: SignParameters[K]) => void;
|
||||
onParameterChange: <K extends keyof SignParameters>(
|
||||
key: K,
|
||||
value: SignParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
onActivateDrawMode?: () => void;
|
||||
onActivateSignaturePlacement?: () => void;
|
||||
@@ -53,7 +70,12 @@ interface SignSettingsProps {
|
||||
|
||||
export type SignatureSource = "canvas" | "image" | "text" | "saved";
|
||||
|
||||
const DEFAULT_SIGNATURE_SOURCES: SignatureSource[] = ["canvas", "image", "text", "saved"];
|
||||
const DEFAULT_SIGNATURE_SOURCES: SignatureSource[] = [
|
||||
"canvas",
|
||||
"image",
|
||||
"text",
|
||||
"saved",
|
||||
];
|
||||
|
||||
const SignSettings = ({
|
||||
parameters,
|
||||
@@ -72,7 +94,10 @@ const SignSettings = ({
|
||||
const { t } = useTranslation();
|
||||
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
|
||||
const { activeFileIndex } = useViewer();
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const [historyAvailability, setHistoryAvailability] = useState({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
const historyApiInstance = historyApiRef.current;
|
||||
const translate = useCallback(
|
||||
(key: string, defaultValue: string, options?: Record<string, unknown>) =>
|
||||
@@ -80,7 +105,8 @@ const SignSettings = ({
|
||||
[t, translationScope],
|
||||
);
|
||||
const effectiveDefaultSource =
|
||||
(defaultSignatureSource && allowedSignatureSources.includes(defaultSignatureSource)
|
||||
(defaultSignatureSource &&
|
||||
allowedSignatureSources.includes(defaultSignatureSource)
|
||||
? defaultSignatureSource
|
||||
: allowedSignatureSources[0]) ?? "text";
|
||||
const canUseSavedLibrary = allowedSignatureSources.includes("saved");
|
||||
@@ -90,11 +116,16 @@ const SignSettings = ({
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState("2");
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false);
|
||||
const [isPlacementManuallyPaused, setPlacementManuallyPaused] =
|
||||
useState(false);
|
||||
|
||||
// State for different signature types
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<string | undefined>();
|
||||
const [imageSignatureData, setImageSignatureData] = useState<string | undefined>();
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [imageSignatureData, setImageSignatureData] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [signatureDrafts, setSignatureDrafts] = useState<SignatureDrafts>({});
|
||||
const lastSyncedTextDraft = useRef<SignatureDrafts["text"] | null>(null);
|
||||
const lastAppliedPlacementKey = useRef<string | null>(null);
|
||||
@@ -109,21 +140,30 @@ const SignSettings = ({
|
||||
byTypeCounts,
|
||||
storageType,
|
||||
} = useSavedSignatures();
|
||||
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
|
||||
const paramSource = parameters.signatureType as SignatureSource;
|
||||
if (allowedSignatureSources.includes(paramSource)) {
|
||||
return paramSource;
|
||||
}
|
||||
return effectiveDefaultSource;
|
||||
});
|
||||
const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<Record<SavedSignatureType, string | null>>({
|
||||
const [signatureSource, setSignatureSource] = useState<SignatureSource>(
|
||||
() => {
|
||||
const paramSource = parameters.signatureType as SignatureSource;
|
||||
if (allowedSignatureSources.includes(paramSource)) {
|
||||
return paramSource;
|
||||
}
|
||||
return effectiveDefaultSource;
|
||||
},
|
||||
);
|
||||
const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<
|
||||
Record<SavedSignatureType, string | null>
|
||||
>({
|
||||
canvas: null,
|
||||
image: null,
|
||||
text: null,
|
||||
});
|
||||
|
||||
const buildTextSignatureKey = useCallback(
|
||||
(signerName: string, fontSize: number, fontFamily: string, textColor: string) =>
|
||||
(
|
||||
signerName: string,
|
||||
fontSize: number,
|
||||
fontFamily: string,
|
||||
textColor: string,
|
||||
) =>
|
||||
JSON.stringify({
|
||||
signerName: signerName.trim(),
|
||||
fontSize,
|
||||
@@ -191,7 +231,10 @@ const SignSettings = ({
|
||||
(type: SavedSignatureType, explicitKey?: string | null) => {
|
||||
setLastSavedSignatureKeys((prev) => ({
|
||||
...prev,
|
||||
[type]: explicitKey !== undefined ? explicitKey : (signatureKeysByType[type] ?? null),
|
||||
[type]:
|
||||
explicitKey !== undefined
|
||||
? explicitKey
|
||||
: (signatureKeysByType[type] ?? null),
|
||||
}));
|
||||
},
|
||||
[signatureKeysByType],
|
||||
@@ -202,7 +245,11 @@ const SignSettings = ({
|
||||
if (!canvasSignatureData) {
|
||||
return;
|
||||
}
|
||||
const result = await saveSignatureToLibrary({ type: "canvas", dataUrl: canvasSignatureData }, "canvas", scope);
|
||||
const result = await saveSignatureToLibrary(
|
||||
{ type: "canvas", dataUrl: canvasSignatureData },
|
||||
"canvas",
|
||||
scope,
|
||||
);
|
||||
if (result.success) {
|
||||
setLastSavedKeyForType("canvas");
|
||||
}
|
||||
@@ -215,7 +262,11 @@ const SignSettings = ({
|
||||
if (!imageSignatureData) {
|
||||
return;
|
||||
}
|
||||
const result = await saveSignatureToLibrary({ type: "image", dataUrl: imageSignatureData }, "image", scope);
|
||||
const result = await saveSignatureToLibrary(
|
||||
{ type: "image", dataUrl: imageSignatureData },
|
||||
"image",
|
||||
scope,
|
||||
);
|
||||
if (result.success) {
|
||||
setLastSavedKeyForType("image");
|
||||
}
|
||||
@@ -296,7 +347,12 @@ const SignSettings = ({
|
||||
|
||||
const savedKey =
|
||||
signature.type === "text"
|
||||
? buildTextSignatureKey(signature.signerName, signature.fontSize, signature.fontFamily, signature.textColor)
|
||||
? buildTextSignatureKey(
|
||||
signature.signerName,
|
||||
signature.fontSize,
|
||||
signature.fontFamily,
|
||||
signature.textColor,
|
||||
)
|
||||
: signature.dataUrl;
|
||||
setLastSavedKeyForType(signature.type, savedKey);
|
||||
|
||||
@@ -358,17 +414,30 @@ const SignSettings = ({
|
||||
|
||||
let tooltipMessage: string | undefined;
|
||||
if (!isReady) {
|
||||
tooltipMessage = translate("saved.saveUnavailable", "Create a signature first to save it.");
|
||||
tooltipMessage = translate(
|
||||
"saved.saveUnavailable",
|
||||
"Create a signature first to save it.",
|
||||
);
|
||||
} else if (isSaved) {
|
||||
tooltipMessage = translate("saved.noChanges", "Current signature is already saved.");
|
||||
tooltipMessage = translate(
|
||||
"saved.noChanges",
|
||||
"Current signature is already saved.",
|
||||
);
|
||||
} else if (isSavedSignatureLimitReached) {
|
||||
tooltipMessage = translate("saved.limitDescription", "Remove a saved signature before adding new ones (max {{max}}).", {
|
||||
max: maxLimit,
|
||||
});
|
||||
tooltipMessage = translate(
|
||||
"saved.limitDescription",
|
||||
"Remove a saved signature before adding new ones (max {{max}}).",
|
||||
{
|
||||
max: maxLimit,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// SaaS-specific: Use simple "Save" label for localStorage mode
|
||||
const buttonLabel = storageType === "localStorage" && scope === "personal" ? translate("saved.save", "Save") : label;
|
||||
const buttonLabel =
|
||||
storageType === "localStorage" && scope === "personal"
|
||||
? translate("saved.save", "Save")
|
||||
: label;
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
@@ -376,7 +445,9 @@ const SignSettings = ({
|
||||
variant="outline"
|
||||
color={isSaved ? "green" : undefined}
|
||||
onClick={() => onClick(scope)}
|
||||
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
|
||||
disabled={
|
||||
!isReady || disabled || isSavedSignatureLimitReached || !hasChanges
|
||||
}
|
||||
leftSection={<LocalIcon icon={icon} width={16} height={16} />}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
@@ -387,7 +458,14 @@ const SignSettings = ({
|
||||
if (tooltipMessage) {
|
||||
return (
|
||||
<Tooltip label={tooltipMessage}>
|
||||
<Box style={{ display: fullWidth ? "block" : "inline-block", width: fullWidth ? "100%" : undefined }}>{button}</Box>
|
||||
<Box
|
||||
style={{
|
||||
display: fullWidth ? "block" : "inline-block",
|
||||
width: fullWidth ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
{button}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -436,7 +514,11 @@ const SignSettings = ({
|
||||
const hasBothButtons = personalButton && sharedButton;
|
||||
|
||||
if (!hasBothButtons) {
|
||||
return <Box style={{ width: "100%", marginTop: "0.4rem" }}>{personalButton || sharedButton}</Box>;
|
||||
return (
|
||||
<Box style={{ width: "100%", marginTop: "0.4rem" }}>
|
||||
{personalButton || sharedButton}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -455,13 +537,21 @@ const SignSettings = ({
|
||||
if (signatureSource === "saved") {
|
||||
return;
|
||||
}
|
||||
const nextSource = allowedSignatureSources.includes(parameters.signatureType as SignatureSource)
|
||||
const nextSource = allowedSignatureSources.includes(
|
||||
parameters.signatureType as SignatureSource,
|
||||
)
|
||||
? (parameters.signatureType as SignatureSource)
|
||||
: effectiveDefaultSource;
|
||||
if (signatureSource !== nextSource) {
|
||||
setSignatureSource(nextSource);
|
||||
}
|
||||
}, [parameters.signatureType, signatureSource, allowedSignatureSources, effectiveDefaultSource, canUseSavedLibrary]);
|
||||
}, [
|
||||
parameters.signatureType,
|
||||
signatureSource,
|
||||
allowedSignatureSources,
|
||||
effectiveDefaultSource,
|
||||
canUseSavedLibrary,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
@@ -533,7 +623,10 @@ const SignSettings = ({
|
||||
|
||||
// Directly activate placement on image upload
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||
window.setTimeout(
|
||||
() => onActivateSignaturePlacement?.(),
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
);
|
||||
} else {
|
||||
onActivateSignaturePlacement?.();
|
||||
}
|
||||
@@ -560,7 +653,10 @@ const SignSettings = ({
|
||||
lastAppliedPlacementKey.current = null;
|
||||
// Directly activate placement on signature change
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||
window.setTimeout(
|
||||
() => onActivateSignaturePlacement?.(),
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
);
|
||||
} else {
|
||||
onActivateSignaturePlacement?.();
|
||||
}
|
||||
@@ -571,14 +667,21 @@ const SignSettings = ({
|
||||
[onActivateSignaturePlacement],
|
||||
);
|
||||
|
||||
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
|
||||
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
|
||||
const hasCanvasSignature = useMemo(
|
||||
() => Boolean(canvasSignatureData),
|
||||
[canvasSignatureData],
|
||||
);
|
||||
const hasImageSignature = useMemo(
|
||||
() => Boolean(imageSignatureData),
|
||||
[imageSignatureData],
|
||||
);
|
||||
const hasTextSignature = useMemo(
|
||||
() => Boolean(parameters.signerName && parameters.signerName.trim() !== ""),
|
||||
[parameters.signerName],
|
||||
);
|
||||
|
||||
const hasAnySignature = hasCanvasSignature || hasImageSignature || hasTextSignature;
|
||||
const hasAnySignature =
|
||||
hasCanvasSignature || hasImageSignature || hasTextSignature;
|
||||
|
||||
const isCurrentTypeReady = useMemo(() => {
|
||||
switch (parameters.signatureType) {
|
||||
@@ -591,7 +694,12 @@ const SignSettings = ({
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [parameters.signatureType, hasCanvasSignature, hasImageSignature, hasTextSignature]);
|
||||
}, [
|
||||
parameters.signatureType,
|
||||
hasCanvasSignature,
|
||||
hasImageSignature,
|
||||
hasTextSignature,
|
||||
]);
|
||||
|
||||
const placementSignatureKey = useMemo(() => {
|
||||
if (!isCurrentTypeReady) {
|
||||
@@ -605,7 +713,8 @@ const SignSettings = ({
|
||||
return isCurrentTypeReady;
|
||||
}, [disabled, isCurrentTypeReady]);
|
||||
|
||||
const shouldAutoActivate = shouldEnablePlacement && !isPlacementManuallyPaused && !signaturesApplied;
|
||||
const shouldAutoActivate =
|
||||
shouldEnablePlacement && !isPlacementManuallyPaused && !signaturesApplied;
|
||||
|
||||
useEffect(() => {
|
||||
setSignatureDrafts((prev) => {
|
||||
@@ -667,7 +776,12 @@ const SignSettings = ({
|
||||
|
||||
return { ...prev, text: nextDraft };
|
||||
});
|
||||
}, [parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
|
||||
}, [
|
||||
parameters.signerName,
|
||||
parameters.fontSize,
|
||||
parameters.fontFamily,
|
||||
parameters.textColor,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (parameters.signatureType === "text") {
|
||||
@@ -741,7 +855,13 @@ const SignSettings = ({
|
||||
if (parameters.signatureData !== newSignatureData) {
|
||||
onParameterChange("signatureData", newSignatureData);
|
||||
}
|
||||
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData, onParameterChange]);
|
||||
}, [
|
||||
parameters.signatureType,
|
||||
parameters.signatureData,
|
||||
canvasSignatureData,
|
||||
imageSignatureData,
|
||||
onParameterChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEnablePlacement) {
|
||||
@@ -804,7 +924,13 @@ const SignSettings = ({
|
||||
}
|
||||
|
||||
trigger();
|
||||
}, [placementSignatureKey, shouldAutoActivate, shouldEnablePlacement, isPlacementMode, onActivateSignaturePlacement]);
|
||||
}, [
|
||||
placementSignatureKey,
|
||||
shouldAutoActivate,
|
||||
shouldEnablePlacement,
|
||||
isPlacementMode,
|
||||
onActivateSignaturePlacement,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (activeFileIndex === previousFileIndexRef.current) {
|
||||
return;
|
||||
@@ -827,7 +953,12 @@ const SignSettings = ({
|
||||
}
|
||||
|
||||
onActivateSignaturePlacement?.();
|
||||
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
||||
}, [
|
||||
activeFileIndex,
|
||||
shouldEnablePlacement,
|
||||
signaturesApplied,
|
||||
onActivateSignaturePlacement,
|
||||
]);
|
||||
|
||||
const sourceLabels: Record<SignatureSource, string> = {
|
||||
canvas: translate("type.canvas", "Draw"),
|
||||
@@ -878,7 +1009,11 @@ const SignSettings = ({
|
||||
disabled={disabled}
|
||||
initialSignatureData={canvasSignatureData}
|
||||
/>
|
||||
{renderSaveButtonRow("canvas", hasCanvasSignature, handleSaveCanvasSignature)}
|
||||
{renderSaveButtonRow(
|
||||
"canvas",
|
||||
hasCanvasSignature,
|
||||
handleSaveCanvasSignature,
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -886,8 +1021,15 @@ const SignSettings = ({
|
||||
if (signatureSource === "image") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<ImageUploader onImageChange={handleImageChange} disabled={disabled} />
|
||||
{renderSaveButtonRow("image", hasImageSignature, handleSaveImageSignature)}
|
||||
<ImageUploader
|
||||
onImageChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{renderSaveButtonRow(
|
||||
"image",
|
||||
hasImageSignature,
|
||||
handleSaveImageSignature,
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -900,7 +1042,9 @@ const SignSettings = ({
|
||||
fontSize={parameters.fontSize || 16}
|
||||
onFontSizeChange={(size) => onParameterChange("fontSize", size)}
|
||||
fontFamily={parameters.fontFamily || "Helvetica"}
|
||||
onFontFamilyChange={(family) => onParameterChange("fontFamily", family)}
|
||||
onFontFamilyChange={(family) =>
|
||||
onParameterChange("fontFamily", family)
|
||||
}
|
||||
textColor={parameters.textColor || "#000000"}
|
||||
onTextColorChange={(color) => onParameterChange("textColor", color)}
|
||||
disabled={disabled}
|
||||
@@ -908,13 +1052,19 @@ const SignSettings = ({
|
||||
placeholder={translate("text.placeholder", "Enter text")}
|
||||
fontLabel={translate("text.fontLabel", "Font")}
|
||||
fontSizeLabel={translate("text.fontSizeLabel", "Font size")}
|
||||
fontSizePlaceholder={translate("text.fontSizePlaceholder", "Type or select font size (8-200)")}
|
||||
fontSizePlaceholder={translate(
|
||||
"text.fontSizePlaceholder",
|
||||
"Type or select font size (8-200)",
|
||||
)}
|
||||
onAnyChange={() => {
|
||||
setPlacementManuallyPaused(false);
|
||||
lastAppliedPlacementKey.current = null;
|
||||
// Directly activate placement on text changes
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||
window.setTimeout(
|
||||
() => onActivateSignaturePlacement?.(),
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
);
|
||||
} else {
|
||||
onActivateSignaturePlacement?.();
|
||||
}
|
||||
@@ -927,7 +1077,10 @@ const SignSettings = ({
|
||||
|
||||
const placementInstructions = () => {
|
||||
if (signatureSource === "saved") {
|
||||
return translate("instructions.saved", "Select a saved signature above, then click anywhere on the PDF to place it.");
|
||||
return translate(
|
||||
"instructions.saved",
|
||||
"Select a saved signature above, then click anywhere on the PDF to place it.",
|
||||
);
|
||||
}
|
||||
if (parameters.signatureType === "canvas") {
|
||||
return translate(
|
||||
@@ -936,7 +1089,10 @@ const SignSettings = ({
|
||||
);
|
||||
}
|
||||
if (parameters.signatureType === "image") {
|
||||
return translate("instructions.image", "After uploading your signature image, click anywhere on the PDF to place it.");
|
||||
return translate(
|
||||
"instructions.image",
|
||||
"After uploading your signature image, click anywhere on the PDF to place it.",
|
||||
);
|
||||
}
|
||||
return translate(
|
||||
"instructions.text",
|
||||
@@ -952,12 +1108,18 @@ const SignSettings = ({
|
||||
: translate("instructions.paused", "Placement paused"),
|
||||
message: isPlacementMode
|
||||
? placementInstructions()
|
||||
: translate("instructions.resumeHint", "Resume placement to click and add your signature."),
|
||||
: translate(
|
||||
"instructions.resumeHint",
|
||||
"Resume placement to click and add your signature.",
|
||||
),
|
||||
}
|
||||
: {
|
||||
color: "yellow",
|
||||
title: translate("instructions.title", "How to add your signature"),
|
||||
message: translate("instructions.noSignature", "Create a signature above to enable placement tools."),
|
||||
message: translate(
|
||||
"instructions.noSignature",
|
||||
"Create a signature above to enable placement tools.",
|
||||
),
|
||||
};
|
||||
|
||||
const handlePausePlacement = () => {
|
||||
@@ -1007,7 +1169,11 @@ const SignSettings = ({
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
|
||||
<LocalIcon
|
||||
icon="material-symbols:pause-rounded"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<Text component="span" size="sm" fw={500}>
|
||||
{translate("mode.pause", "Pause placement")}
|
||||
</Text>
|
||||
@@ -1020,7 +1186,9 @@ const SignSettings = ({
|
||||
size="lg"
|
||||
aria-label={translate("mode.resume", "Resume placement")}
|
||||
onClick={handleResumePlacement}
|
||||
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
|
||||
disabled={
|
||||
disabled || !isCurrentTypeReady || !onActivateSignaturePlacement
|
||||
}
|
||||
style={{
|
||||
width: "auto",
|
||||
paddingInline: "0.75rem",
|
||||
@@ -1029,7 +1197,11 @@ const SignSettings = ({
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
|
||||
<LocalIcon
|
||||
icon="material-symbols:play-arrow-rounded"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<Text component="span" size="sm" fw={500}>
|
||||
{translate("mode.resume", "Resume placement")}
|
||||
</Text>
|
||||
@@ -1042,13 +1214,18 @@ const SignSettings = ({
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{translate("step.createDesc", "Choose how you want to create the signature")}
|
||||
{translate(
|
||||
"step.createDesc",
|
||||
"Choose how you want to create the signature",
|
||||
)}
|
||||
</Text>
|
||||
{sourceOptions.length > 1 && (
|
||||
<SegmentedControl
|
||||
value={signatureSource}
|
||||
fullWidth
|
||||
onChange={(value) => handleSignatureSourceChange(value as SignatureSource)}
|
||||
onChange={(value) =>
|
||||
handleSignatureSourceChange(value as SignatureSource)
|
||||
}
|
||||
data={sourceOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user