[skip ci] feat: prepare for public release

This commit is contained in:
Hampus Kraft
2026-01-02 19:27:51 +00:00
parent 197b23757f
commit 5ae825fc7d
199 changed files with 38391 additions and 33358 deletions

View File

@@ -24,6 +24,7 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {ChannelTypes} from '~/Constants';
import * as Modal from '~/components/modals/Modal';
import ChannelStore from '~/stores/ChannelStore';
import ConnectionStore from '~/stores/gateway/ConnectionStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import {
@@ -41,6 +42,7 @@ import type {ChannelSettingsTabType} from './utils/channelSettingsConstants';
export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observer(({channelId, initialMobileTab}) => {
const {t} = useLingui();
const channel = ChannelStore.getChannel(channelId);
const guildId = channel?.guildId;
const [selectedTab, setSelectedTab] = React.useState<ChannelSettingsTabType>('overview');
const availableTabs = React.useMemo(() => {
@@ -59,6 +61,12 @@ export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observe
const mobileNav = useMobileNavigation<ChannelSettingsTabType>(initialTab);
const {enabled: isMobile} = MobileLayoutStore;
React.useEffect(() => {
if (guildId) {
ConnectionStore.syncGuildIfNeeded(guildId, 'channel-settings-modal');
}
}, [guildId]);
React.useEffect(() => {
if (!channel) {
ModalActionCreators.pop();

View File

@@ -22,6 +22,7 @@ import {observer} from 'mobx-react-lite';
import {useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {Form} from '~/components/form/Form';
@@ -31,6 +32,7 @@ import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import ModalStore from '~/stores/ModalStore';
interface FormInputs {
email: string;
@@ -230,3 +232,20 @@ export const ClaimAccountModal = observer(() => {
</Modal.Root>
);
});
const CLAIM_ACCOUNT_MODAL_KEY = 'claim-account-modal';
let hasShownClaimAccountModalThisSession = false;
export const openClaimAccountModal = ({force = false}: {force?: boolean} = {}): void => {
if (ModalStore.hasModal(CLAIM_ACCOUNT_MODAL_KEY)) {
return;
}
if (!force && hasShownClaimAccountModalThisSession) {
return;
}
hasShownClaimAccountModalThisSession = true;
ModalActionCreators.pushWithKey(
modal(() => <ClaimAccountModal />),
CLAIM_ACCOUNT_MODAL_KEY,
);
};

View File

@@ -23,12 +23,14 @@ import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GiftActionCreators from '~/actions/GiftActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
import i18n from '~/i18n';
import {UserRecord} from '~/records/UserRecord';
import GiftStore from '~/stores/GiftStore';
import UserStore from '~/stores/UserStore';
import {getGiftDurationText} from '~/utils/giftUtils';
import styles from './GiftAcceptModal.module.css';
@@ -41,6 +43,7 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
const giftState = GiftStore.gifts.get(code) ?? null;
const gift = giftState?.data ?? null;
const [isRedeeming, setIsRedeeming] = React.useState(false);
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
React.useEffect(() => {
if (!giftState) {
@@ -64,6 +67,10 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
};
const handleRedeem = async () => {
if (isUnclaimed) {
openClaimAccountModal({force: true});
return;
}
setIsRedeeming(true);
try {
await GiftActionCreators.redeem(i18n, code);
@@ -130,6 +137,42 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
const renderGift = () => {
const durationText = getGiftDurationText(i18n, gift!);
if (isUnclaimed) {
return (
<>
<div className={styles.card}>
<div className={styles.cardGrid}>
<div className={`${styles.iconCircle} ${styles.iconCircleInactive}`}>
<GiftIcon className={styles.icon} weight="fill" />
</div>
<div className={styles.cardContent}>
<h3 className={`${styles.title} ${styles.titlePrimary}`}>{durationText}</h3>
{creator && (
<span className={styles.subtitle}>{t`From ${creator.username}#${creator.discriminator}`}</span>
)}
<span className={styles.helpText}>
<Trans>Claim your account to redeem this gift.</Trans>
</span>
</div>
</div>
</div>
<div className={styles.footer}>
<Button variant="secondary" onClick={handleDismiss}>
<Trans>Maybe later</Trans>
</Button>
<Button
variant="primary"
onClick={() => {
openClaimAccountModal({force: true});
handleDismiss();
}}
>
<Trans>Claim Account</Trans>
</Button>
</div>
</>
);
}
return (
<>
<div className={styles.card}>

View File

@@ -25,6 +25,7 @@ import * as UnsavedChangesActionCreators from '~/actions/UnsavedChangesActionCre
import * as Modal from '~/components/modals/Modal';
import GuildSettingsModalStore from '~/stores/GuildSettingsModalStore';
import GuildStore from '~/stores/GuildStore';
import ConnectionStore from '~/stores/gateway/ConnectionStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PermissionStore from '~/stores/PermissionStore';
import UnsavedChangesStore from '~/stores/UnsavedChangesStore';
@@ -79,6 +80,10 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
const unsavedChangesStore = UnsavedChangesStore;
React.useEffect(() => {
ConnectionStore.syncGuildIfNeeded(guildId, 'guild-settings-modal');
}, [guildId]);
React.useEffect(() => {
if (!guild) {
ModalActionCreators.pop();

View File

@@ -230,6 +230,17 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
<span className={styles.channelName}>{channel.name}</span>
</Trans>
</p>
{invitesDisabled && (
<div className={styles.warningContainer}>
<WarningCircleIcon className={styles.warningIcon} weight="fill" />
<p className={styles.warningText}>
<Trans>
Invites are currently disabled in this community by an admin. While this invite can be created, it
cannot be accepted until invites are re-enabled.
</Trans>
</p>
</div>
)}
<div className={selectorStyles.headerSearch}>
<Input
value={searchQuery}
@@ -248,33 +259,19 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
<Spinner />
</div>
) : !showAdvanced ? (
<>
<RecipientList
recipients={recipients}
sendingTo={sendingTo}
sentTo={sentInvites}
onSend={handleSendInvite}
defaultButtonLabel={t`Invite`}
sentButtonLabel={t`Sent`}
buttonClassName={styles.inviteButton}
scrollerKey="invite-modal-friend-list-scroller"
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
showSearchInput={false}
/>
{invitesDisabled && (
<div className={styles.warningContainer}>
<WarningCircleIcon className={styles.warningIcon} weight="fill" />
<p className={styles.warningText}>
<Trans>
Invites are currently disabled in this community by an admin. While this invite can be created, it
cannot be accepted until invites are re-enabled.
</Trans>
</p>
</div>
)}
</>
<RecipientList
recipients={recipients}
sendingTo={sendingTo}
sentTo={sentInvites}
onSend={handleSendInvite}
defaultButtonLabel={t`Invite`}
sentButtonLabel={t`Sent`}
buttonClassName={styles.inviteButton}
scrollerKey="invite-modal-friend-list-scroller"
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
showSearchInput={false}
/>
) : (
<div className={styles.advancedView}>
<Select

View File

@@ -217,6 +217,7 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
const relationship = RelationshipStore.getRelationship(user.id);
const relationshipType = relationship?.type;
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
const guildMember = GuildMemberStore.getMember(profile?.guildId ?? guildId ?? '', user.id);
const memberRoles = profile?.guildId && guildMember ? guildMember.getSortedRoles() : [];
@@ -389,11 +390,14 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
</button>
);
}
return (
<button type="button" onClick={handleSendFriendRequest} className={styles.actionButton}>
<UserPlusIcon className={styles.icon} />
</button>
);
if (relationshipType === undefined && !currentUserUnclaimed) {
return (
<button type="button" onClick={handleSendFriendRequest} className={styles.actionButton}>
<UserPlusIcon className={styles.icon} />
</button>
);
}
return null;
};
return (

View File

@@ -1164,6 +1164,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
};
const renderActionButtons = () => {
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
if (isCurrentUser && disableEditProfile) {
return (
<div className={userProfileModalStyles.actionButtons}>
@@ -1284,8 +1285,11 @@ export const UserProfileModal: UserProfileModalComponent = observer(
);
}
if (relationshipType === undefined && !isUserBot) {
const tooltipText = currentUserUnclaimed
? t`Claim your account to send friend requests.`
: t`Send Friend Request`;
return (
<Tooltip text={t`Send Friend Request`} maxWidth="xl">
<Tooltip text={tooltipText} maxWidth="xl">
<div>
<Button
variant="secondary"
@@ -1293,6 +1297,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
square={true}
icon={<UserPlusIcon className={userProfileModalStyles.buttonIcon} />}
onClick={handleSendFriendRequest}
disabled={currentUserUnclaimed}
/>
</div>
</Tooltip>

View File

@@ -32,11 +32,9 @@ import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {ClientInfo} from '~/components/modals/components/ClientInfo';
import {LogoutModal} from '~/components/modals/components/LogoutModal';
import styles from '~/components/modals/components/MobileSettingsView.module.css';
import {ScrollSpyProvider, useScrollSpyContext} from '~/components/modals/hooks/ScrollSpyContext';
import type {MobileNavigationState} from '~/components/modals/hooks/useMobileNavigation';
import {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey';
import {
MobileSectionNav,
MobileSettingsDangerItem,
MobileHeader as SharedMobileHeader,
} from '~/components/modals/shared/MobileSettingsComponents';
@@ -44,16 +42,13 @@ import userSettingsStyles from '~/components/modals/UserSettingsModal.module.css
import {getSettingsTabComponent} from '~/components/modals/utils/desktopSettingsTabs';
import {
getCategoryLabel,
getSectionIdsForTab,
getSectionsForTab,
type SettingsTab,
tabHasSections,
type UserSettingsTabType,
} from '~/components/modals/utils/settingsConstants';
import {filterSettingsTabsForDeveloperMode} from '~/components/modals/utils/settingsTabFilters';
import {Button} from '~/components/uikit/Button/Button';
import {MentionBadgeAnimated} from '~/components/uikit/MentionBadge';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import {Scroller} from '~/components/uikit/Scroller';
import {Spinner} from '~/components/uikit/Spinner';
import {usePressable} from '~/hooks/usePressable';
import {usePushSubscriptions} from '~/hooks/usePushSubscriptions';
@@ -397,26 +392,7 @@ const headerFadeVariants = {
exit: {opacity: 0},
};
interface MobileSectionNavWrapperProps {
tabType: UserSettingsTabType;
}
const MobileSectionNavWrapper: React.FC<MobileSectionNavWrapperProps> = observer(({tabType}) => {
const {t} = useLingui();
const scrollSpyContext = useScrollSpyContext();
const sections = getSectionsForTab(tabType, t);
if (!scrollSpyContext || sections.length === 0) {
return null;
}
const {activeSectionId, scrollToSection} = scrollSpyContext;
return <MobileSectionNav sections={sections} activeSectionId={activeSectionId} onSectionClick={scrollToSection} />;
});
interface MobileContentWithScrollSpyProps {
tabType: UserSettingsTabType;
scrollKey: string;
initialGuildId?: string;
initialSubtab?: string;
@@ -424,21 +400,9 @@ interface MobileContentWithScrollSpyProps {
}
const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = observer(
({tabType, scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
const scrollerRef = React.useRef<ScrollerHandle | null>(null);
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
const sectionIds = React.useMemo(() => getSectionIdsForTab(tabType), [tabType]);
const hasSections = tabHasSections(tabType);
React.useEffect(() => {
if (scrollerRef.current) {
scrollContainerRef.current = scrollerRef.current.getScrollerNode();
}
});
const content = (
<Scroller ref={scrollerRef} className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
{hasSections && <MobileSectionNavWrapper tabType={tabType} />}
({scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
return (
<Scroller className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
<div className={styles.contentContainer}>
{currentTabComponent &&
React.createElement(currentTabComponent, {
@@ -448,16 +412,6 @@ const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = ob
</div>
</Scroller>
);
if (hasSections) {
return (
<ScrollSpyProvider sectionIds={sectionIds} containerRef={scrollContainerRef}>
{content}
</ScrollSpyProvider>
);
}
return content;
},
);
@@ -582,7 +536,6 @@ export const MobileSettingsView: React.FC<MobileSettingsViewProps> = observer(
style={{willChange: 'transform'}}
>
<MobileContentWithScrollSpy
tabType={currentTab.type}
scrollKey={scrollKey}
initialGuildId={initialGuildId}
initialSubtab={initialSubtab}

View File

@@ -94,6 +94,17 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
mobileLayoutState.enabled,
);
const isClaimed = currentUser?.isClaimed() ?? false;
const purchaseDisabled = !isClaimed;
const purchaseDisabledTooltip = <Trans>Claim your account to purchase Fluxer Plutonium.</Trans>;
const handleSelectPlanGuarded = React.useCallback(
(plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => {
if (purchaseDisabled) return;
handleSelectPlan(plan);
},
[handleSelectPlan, purchaseDisabled],
);
const monthlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Monthly, countryCode), [countryCode]);
const yearlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Yearly, countryCode), [countryCode]);
const visionaryPrice = React.useMemo(() => getFormattedPrice(PricingTier.Visionary, countryCode), [countryCode]);
@@ -221,12 +232,14 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
scrollToPerks={scrollToPerks}
handlePerksKeyDown={handlePerksKeyDown}
navigateToRedeemGift={navigateToRedeemGift}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
handleOpenCustomerPortal={handleOpenCustomerPortal}
handleReactivateSubscription={handleReactivateSubscription}
handleCancelSubscription={handleCancelSubscription}
handleCommunityButtonPointerDown={handleCommunityButtonPointerDown}
handleCommunityButtonClick={handleCommunityButtonClick}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
<div className={styles.disclaimerContainer}>
<PurchaseDisclaimer align="center" isPremium />
@@ -245,7 +258,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots}
isVisionarySoldOut={isVisionarySoldOut}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
) : (
<GiftSection
@@ -257,7 +272,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots}
isVisionarySoldOut={isVisionarySoldOut}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
)}
@@ -280,7 +297,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
isGiftSubscription={subscriptionStatus.isGiftSubscription}
loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
) : null}
@@ -302,7 +321,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots}
isVisionarySoldOut={isVisionarySoldOut}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
)}
</div>

View File

@@ -19,9 +19,7 @@
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {Button} from '~/components/uikit/Button/Button';
import {WarningAlert} from '~/components/uikit/WarningAlert/WarningAlert';
@@ -30,7 +28,7 @@ export const UnclaimedAccountAlert = observer(() => {
<WarningAlert
title={<Trans>Unclaimed Account</Trans>}
actions={
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Button small={true} onClick={() => openClaimAccountModal({force: true})}>
<Trans>Claim Account</Trans>
</Button>
}

View File

@@ -17,12 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {Button} from '~/components/uikit/Button/Button';
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
import styles from './BottomCTASection.module.css';
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
interface BottomCTASectionProps {
isGiftMode: boolean;
@@ -33,6 +34,8 @@ interface BottomCTASectionProps {
loadingSlots: boolean;
isVisionarySoldOut: boolean;
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
@@ -45,7 +48,12 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
loadingSlots,
isVisionarySoldOut,
handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
return (
<div className={styles.container}>
<h2 className={styles.title}>
@@ -54,63 +62,79 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
<div className={styles.buttonContainer}>
{!isGiftMode ? (
<>
<Button
variant="secondary"
onClick={() => handleSelectPlan('monthly')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
>
<Trans>Monthly {monthlyPrice}</Trans>
</Button>
<Button
variant="primary"
onClick={() => handleSelectPlan('yearly')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
>
<Trans>Yearly {yearlyPrice}</Trans>
</Button>
<Button
variant="primary"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
className={styles.button}
>
{isVisionarySoldOut ? <Trans>Visionary Sold Out</Trans> : <Trans>Visionary {visionaryPrice}</Trans>}
</Button>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<Button
variant="secondary"
onClick={() => handleSelectPlan('monthly')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
disabled={purchaseDisabled}
>
<Trans>Monthly {monthlyPrice}</Trans>
</Button>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<Button
variant="primary"
onClick={() => handleSelectPlan('yearly')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
disabled={purchaseDisabled}
>
<Trans>Yearly {yearlyPrice}</Trans>
</Button>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<Button
variant="primary"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
disabled={purchaseDisabled || isVisionarySoldOut}
className={styles.button}
>
{isVisionarySoldOut ? <Trans>Visionary Sold Out</Trans> : <Trans>Visionary {visionaryPrice}</Trans>}
</Button>
</PurchaseDisabledWrapper>
</>
) : (
<>
<Button
variant="secondary"
onClick={() => handleSelectPlan('gift1Year')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
>
<Trans>1 Year {yearlyPrice}</Trans>
</Button>
<Button
variant="primary"
onClick={() => handleSelectPlan('gift1Month')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
>
<Trans>1 Month {monthlyPrice}</Trans>
</Button>
<Button
variant="primary"
onClick={() => handleSelectPlan('giftVisionary')}
submitting={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
className={styles.button}
>
{isVisionarySoldOut ? (
<Trans>Visionary Gift Sold Out</Trans>
) : (
<Trans>Visionary {visionaryPrice}</Trans>
)}
</Button>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<Button
variant="secondary"
onClick={() => handleSelectPlan('gift1Year')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
disabled={purchaseDisabled}
>
<Trans>1 Year {yearlyPrice}</Trans>
</Button>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<Button
variant="primary"
onClick={() => handleSelectPlan('gift1Month')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
disabled={purchaseDisabled}
>
<Trans>1 Month {monthlyPrice}</Trans>
</Button>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<Button
variant="primary"
onClick={() => handleSelectPlan('giftVisionary')}
submitting={loadingCheckout || loadingSlots}
disabled={purchaseDisabled || isVisionarySoldOut}
className={styles.button}
>
{isVisionarySoldOut ? (
<Trans>Visionary Gift Sold Out</Trans>
) : (
<Trans>Visionary {visionaryPrice}</Trans>
)}
</Button>
</PurchaseDisabledWrapper>
</>
)}
</div>

View File

@@ -26,6 +26,7 @@ import {PricingCard} from '../PricingCard';
import gridStyles from '../PricingGrid.module.css';
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
import styles from './GiftSection.module.css';
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
import {SectionHeader} from './SectionHeader';
interface GiftSectionProps {
@@ -38,6 +39,8 @@ interface GiftSectionProps {
loadingSlots: boolean;
isVisionarySoldOut: boolean;
handleSelectPlan: (plan: 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const GiftSection: React.FC<GiftSectionProps> = observer(
@@ -51,8 +54,11 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
loadingSlots,
isVisionarySoldOut,
handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
return (
<div ref={giftSectionRef}>
@@ -65,35 +71,43 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
/>
<div className={gridStyles.gridWrapper}>
<div className={gridStyles.gridThreeColumns}>
<PricingCard
title={t`1 Year Gift`}
price={yearlyPrice}
period={t`one-time purchase`}
badge={t`Save 17%`}
onSelect={() => handleSelectPlan('gift1Year')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
/>
<PricingCard
title={t`1 Month Gift`}
price={monthlyPrice}
period={t`one-time purchase`}
isPopular
onSelect={() => handleSelectPlan('gift1Month')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
/>
<PricingCard
title={t`Visionary Gift`}
price={visionaryPrice}
period={t`one-time, lifetime`}
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
onSelect={() => handleSelectPlan('giftVisionary')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`1 Year Gift`}
price={yearlyPrice}
period={t`one-time purchase`}
badge={t`Save 17%`}
onSelect={() => handleSelectPlan('gift1Year')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`1 Month Gift`}
price={monthlyPrice}
period={t`one-time purchase`}
isPopular
onSelect={() => handleSelectPlan('gift1Month')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<PricingCard
title={t`Visionary Gift`}
price={visionaryPrice}
period={t`one-time, lifetime`}
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
onSelect={() => handleSelectPlan('giftVisionary')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled || isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
</PurchaseDisabledWrapper>
</div>
</div>
<div className={styles.footerContainer}>

View File

@@ -27,6 +27,7 @@ import gridStyles from '../PricingGrid.module.css';
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
import {ToggleButton} from '../ToggleButton';
import styles from './PricingSection.module.css';
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
interface PricingSectionProps {
isGiftMode: boolean;
@@ -39,6 +40,8 @@ interface PricingSectionProps {
loadingSlots: boolean;
isVisionarySoldOut: boolean;
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const PricingSection: React.FC<PricingSectionProps> = observer(
@@ -53,8 +56,11 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
loadingSlots,
isVisionarySoldOut,
handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
return (
<section className={styles.section}>
@@ -67,65 +73,81 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
<div className={gridStyles.gridThreeColumns}>
{!isGiftMode ? (
<>
<PricingCard
title={t`Monthly`}
price={monthlyPrice}
period={t`per month`}
onSelect={() => handleSelectPlan('monthly')}
isLoading={loadingCheckout || loadingSlots}
/>
<PricingCard
title={t`Yearly`}
price={yearlyPrice}
period={t`per year`}
badge={t`Save 17%`}
isPopular
onSelect={() => handleSelectPlan('yearly')}
buttonText={t`Upgrade Now`}
isLoading={loadingCheckout || loadingSlots}
/>
<PricingCard
title={t`Visionary`}
price={visionaryPrice}
period={t`one-time, lifetime`}
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
onSelect={() => handleSelectPlan('visionary')}
isLoading={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`Monthly`}
price={monthlyPrice}
period={t`per month`}
onSelect={() => handleSelectPlan('monthly')}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`Yearly`}
price={yearlyPrice}
period={t`per year`}
badge={t`Save 17%`}
isPopular
onSelect={() => handleSelectPlan('yearly')}
buttonText={t`Upgrade Now`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<PricingCard
title={t`Visionary`}
price={visionaryPrice}
period={t`one-time, lifetime`}
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
onSelect={() => handleSelectPlan('visionary')}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled || isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
</PurchaseDisabledWrapper>
</>
) : (
<>
<PricingCard
title={t`1 Year Gift`}
price={yearlyPrice}
period={t`one-time purchase`}
badge={t`Save 17%`}
onSelect={() => handleSelectPlan('gift1Year')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
/>
<PricingCard
title={t`1 Month Gift`}
price={monthlyPrice}
period={t`one-time purchase`}
isPopular
onSelect={() => handleSelectPlan('gift1Month')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
/>
<PricingCard
title={t`Visionary Gift`}
price={visionaryPrice}
period={t`one-time, lifetime`}
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
onSelect={() => handleSelectPlan('giftVisionary')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`1 Year Gift`}
price={yearlyPrice}
period={t`one-time purchase`}
badge={t`Save 17%`}
onSelect={() => handleSelectPlan('gift1Year')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`1 Month Gift`}
price={monthlyPrice}
period={t`one-time purchase`}
isPopular
onSelect={() => handleSelectPlan('gift1Month')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<PricingCard
title={t`Visionary Gift`}
price={visionaryPrice}
period={t`one-time, lifetime`}
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
onSelect={() => handleSelectPlan('giftVisionary')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled || isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
</PurchaseDisabledWrapper>
</>
)}
</div>

View File

@@ -0,0 +1,39 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type React from 'react';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
interface PurchaseDisabledWrapperProps {
disabled: boolean;
tooltipText: React.ReactNode;
children: React.ReactElement;
}
export const PurchaseDisabledWrapper: React.FC<PurchaseDisabledWrapperProps> = ({disabled, tooltipText, children}) => {
if (!disabled) return children;
const tooltipContent = typeof tooltipText === 'function' ? (tooltipText as () => React.ReactNode) : () => tooltipText;
return (
<Tooltip text={tooltipContent}>
<div>{children}</div>
</Tooltip>
);
};

View File

@@ -17,12 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {DotsThreeIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {Button} from '~/components/uikit/Button/Button';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {UserRecord} from '~/records/UserRecord';
import {PerksButton} from '../PerksButton';
import type {GracePeriodInfo} from './hooks/useSubscriptionStatus';
@@ -61,6 +62,8 @@ interface SubscriptionCardProps {
handleCancelSubscription: () => void;
handleCommunityButtonPointerDown: (event: React.PointerEvent) => void;
handleCommunityButtonClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
@@ -97,9 +100,25 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
handleCancelSubscription,
handleCommunityButtonPointerDown,
handleCommunityButtonClick,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
const isPremium = currentUser.isPremium();
const tooltipText: string | (() => React.ReactNode) =
purchaseDisabledTooltip != null
? () => purchaseDisabledTooltip
: t`Claim your account to purchase or redeem Fluxer Plutonium.`;
const wrapIfDisabled = (element: React.ReactElement, key: string, disabled: boolean) =>
disabled ? (
<Tooltip key={key} text={tooltipText}>
<div>{element}</div>
</Tooltip>
) : (
element
);
return (
<div className={clsx(styles.card, subscriptionCardColorClass)}>
@@ -251,44 +270,63 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
<div className={styles.actions}>
{isGiftSubscription ? (
<>
<Button variant="inverted" onClick={navigateToRedeemGift} small className={styles.actionButton}>
<Trans>Redeem Gift Code</Trans>
</Button>
{!isVisionary && !isVisionarySoldOut && (
{wrapIfDisabled(
<Button
variant="inverted"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
onClick={navigateToRedeemGift}
small
className={styles.actionButton}
disabled={purchaseDisabled}
>
<Trans>Upgrade to Visionary</Trans>
</Button>
<Trans>Redeem Gift Code</Trans>
</Button>,
'redeem-gift',
purchaseDisabled,
)}
{!isVisionary &&
!isVisionarySoldOut &&
wrapIfDisabled(
<Button
variant="inverted"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
small
className={styles.actionButton}
disabled={purchaseDisabled}
>
<Trans>Upgrade to Visionary</Trans>
</Button>,
'upgrade-gift-visionary',
purchaseDisabled,
)}
</>
) : (
<>
{hasEverPurchased && (
<Button
variant="inverted"
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
small
className={styles.actionButton}
>
{isFullyExpired ? (
<Trans>Resubscribe</Trans>
) : isInGracePeriod ? (
<Trans>Resubscribe</Trans>
) : premiumWillCancel ? (
<Trans>Reactivate</Trans>
) : isVisionary ? (
<Trans>Open Customer Portal</Trans>
) : (
<Trans>Manage Subscription</Trans>
)}
</Button>
)}
{hasEverPurchased &&
wrapIfDisabled(
<Button
variant="inverted"
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
small
className={styles.actionButton}
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
>
{isFullyExpired ? (
<Trans>Resubscribe</Trans>
) : isInGracePeriod ? (
<Trans>Resubscribe</Trans>
) : premiumWillCancel ? (
<Trans>Reactivate</Trans>
) : isVisionary ? (
<Trans>Open Customer Portal</Trans>
) : (
<Trans>Manage Subscription</Trans>
)}
</Button>,
'manage-reactivate',
purchaseDisabled && shouldUseReactivateQuickAction,
)}
{isVisionary && (
<Button
@@ -305,17 +343,22 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
</Button>
)}
{!isVisionary && !isVisionarySoldOut && (
<Button
variant="inverted"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
small
className={styles.actionButton}
>
<Trans>Upgrade to Visionary</Trans>
</Button>
)}
{!isVisionary &&
!isVisionarySoldOut &&
wrapIfDisabled(
<Button
variant="inverted"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
small
className={styles.actionButton}
disabled={purchaseDisabled}
>
<Trans>Upgrade to Visionary</Trans>
</Button>,
'upgrade-visionary',
purchaseDisabled,
)}
{shouldUseCancelQuickAction && (
<Button

View File

@@ -23,6 +23,7 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {VisionarySlots} from '~/actions/PremiumActionCreators';
import {Button} from '~/components/uikit/Button/Button';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {VisionaryBenefit} from '../VisionaryBenefit';
import {SectionHeader} from './SectionHeader';
import styles from './VisionarySection.module.css';
@@ -36,6 +37,8 @@ interface VisionarySectionProps {
loadingCheckout: boolean;
loadingSlots: boolean;
handleSelectPlan: (plan: 'visionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const VisionarySection: React.FC<VisionarySectionProps> = observer(
@@ -48,9 +51,15 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
loadingCheckout,
loadingSlots,
handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const currentAccessLabel = isGiftSubscription ? t`gift time` : t`subscription`;
const tooltipText: string | (() => React.ReactNode) =
purchaseDisabledTooltip != null
? () => purchaseDisabledTooltip
: t`Claim your account to purchase Fluxer Plutonium.`;
return (
<section className={styles.section}>
@@ -99,15 +108,32 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
{!isVisionary && visionarySlots && visionarySlots.remaining > 0 && (
<div className={styles.ctaContainer}>
<Button
variant="primary"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
className={styles.ctaButton}
>
<CrownIcon className={styles.ctaIcon} weight="fill" />
<Trans>Upgrade to Visionary {formatter.format(visionarySlots.remaining)} Left</Trans>
</Button>
{purchaseDisabled ? (
<Tooltip text={tooltipText}>
<div>
<Button
variant="primary"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
className={styles.ctaButton}
disabled
>
<CrownIcon className={styles.ctaIcon} weight="fill" />
<Trans>Upgrade to Visionary {formatter.format(visionarySlots.remaining)} Left</Trans>
</Button>
</div>
</Tooltip>
) : (
<Button
variant="primary"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
className={styles.ctaButton}
>
<CrownIcon className={styles.ctaIcon} weight="fill" />
<Trans>Upgrade to Visionary {formatter.format(visionarySlots.remaining)} Left</Trans>
</Button>
)}
{isPremium && (
<p className={styles.disclaimer}>

View File

@@ -54,6 +54,7 @@ import GuildStore from '~/stores/GuildStore';
import PermissionStore from '~/stores/PermissionStore';
import RelationshipStore from '~/stores/RelationshipStore';
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
import UserStore from '~/stores/UserStore';
import * as CallUtils from '~/utils/CallUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as PermissionUtils from '~/utils/PermissionUtils';
@@ -74,6 +75,7 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
const currentUserId = AuthenticationStore.currentUserId;
const isCurrentUser = user.id === currentUserId;
const isBot = user.bot;
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
const relationship = RelationshipStore.getRelationship(user.id);
const relationshipType = relationship?.type;
@@ -262,7 +264,8 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
});
} else if (
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
relationshipType !== RelationshipTypes.BLOCKED
relationshipType !== RelationshipTypes.BLOCKED &&
!currentUserUnclaimed
) {
relationshipItems.push({
icon: <UserPlusIcon className={styles.icon} />,

View File

@@ -36,13 +36,16 @@ interface StatusSlateProps {
description: React.ReactNode;
actions?: Array<StatusAction>;
fullHeight?: boolean;
iconClassName?: string;
iconStyle?: React.CSSProperties;
}
export const StatusSlate: React.FC<StatusSlateProps> = observer(
({Icon, title, description, actions = [], fullHeight = false}) => {
({Icon, title, description, actions = [], fullHeight = false, iconClassName, iconStyle}) => {
const iconClass = [styles.icon, iconClassName].filter(Boolean).join(' ');
return (
<div className={`${styles.container} ${fullHeight ? styles.fullHeight : ''}`}>
<Icon className={styles.icon} aria-hidden />
<Icon className={iconClass} style={iconStyle} aria-hidden />
<h3 className={styles.title}>{title}</h3>
<p className={styles.description}>{description}</p>
{actions.length > 0 && (

View File

@@ -100,3 +100,7 @@
border-top: 1px solid var(--background-header-secondary);
padding-top: 1rem;
}
.claimButton {
align-self: flex-start;
}

View File

@@ -22,7 +22,7 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {EmailChangeModal} from '~/components/modals/EmailChangeModal';
import {PasswordChangeModal} from '~/components/modals/PasswordChangeModal';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
@@ -94,7 +94,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
<Trans>No email address set</Trans>
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Button small={true} className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
<Trans>Add Email</Trans>
</Button>
</div>
@@ -134,7 +134,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
<Trans>No password set</Trans>
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Button small={true} className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
<Trans>Set Password</Trans>
</Button>
</>

View File

@@ -96,3 +96,7 @@
display: flex;
gap: 0.5rem;
}
.claimButton {
align-self: flex-start;
}

View File

@@ -24,7 +24,7 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {BackupCodesViewModal} from '~/components/modals/BackupCodesViewModal';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal';
import {MfaTotpEnableModal} from '~/components/modals/MfaTotpEnableModal';
@@ -202,7 +202,7 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
<Trans>Claim your account to access security features like two-factor authentication and passkeys.</Trans>
}
>
<Button onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Button className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
<Trans>Claim Account</Trans>
</Button>
</SettingsTabSection>

View File

@@ -17,7 +17,7 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {BookOpenIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
@@ -38,12 +38,16 @@ import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.mod
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
import UserStore from '~/stores/UserStore';
const ApplicationsTab: React.FC = observer(() => {
const {t} = useLingui();
const {checkUnsavedChanges} = useUnsavedChangesFlash('applications');
const {setContentKey} = useSettingsContentKey();
const store = ApplicationsTabStore;
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
React.useLayoutEffect(() => {
setContentKey(store.contentKey);
@@ -138,9 +142,19 @@ const ApplicationsTab: React.FC = observer(() => {
description={<Trans>Create and manage applications and bots for your account.</Trans>}
>
<div className={styles.buttonContainer}>
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
<Trans>Create Application</Trans>
</Button>
{isUnclaimed ? (
<Tooltip text={t`Claim your account to create applications.`}>
<div>
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal} disabled>
<Trans>Create Application</Trans>
</Button>
</div>
</Tooltip>
) : (
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
<Trans>Create Application</Trans>
</Button>
)}
<a className={styles.documentationLink} href="https://fluxer.dev" target="_blank" rel="noreferrer">
<BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} />
<Trans>Read the Documentation (fluxer.dev)</Trans>

View File

@@ -25,6 +25,7 @@ import {observer} from 'mobx-react-lite';
import React from 'react';
import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {
SettingsTabContainer,
SettingsTabContent,
@@ -39,6 +40,7 @@ import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {BetaCodeRecord} from '~/records/BetaCodeRecord';
import BetaCodeStore from '~/stores/BetaCodeStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import * as DateUtils from '~/utils/DateUtils';
import styles from './BetaCodesTab.module.css';
@@ -247,10 +249,12 @@ const BetaCodesTab: React.FC = observer(() => {
const fetchStatus = BetaCodeStore.fetchStatus;
const allowance = BetaCodeStore.allowance;
const nextResetAt = BetaCodeStore.nextResetAt;
const isClaimed = UserStore.currentUser?.isClaimed() ?? false;
React.useEffect(() => {
if (!isClaimed) return;
BetaCodeActionCreators.fetch();
}, []);
}, [isClaimed]);
const sortedBetaCodes = React.useMemo(() => {
return [...betaCodes].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
@@ -280,6 +284,27 @@ const BetaCodesTab: React.FC = observer(() => {
return i18n._(msg`${allowance} codes remaining this week`);
}, [allowance, nextResetAt, i18n]);
if (!isClaimed) {
return (
<SettingsTabContainer>
<SettingsTabContent>
<StatusSlate
Icon={TicketIcon}
title={<Trans>Claim your account</Trans>}
description={<Trans>Claim your account to generate beta codes.</Trans>}
actions={[
{
text: <Trans>Claim Account</Trans>,
onClick: () => openClaimAccountModal({force: true}),
variant: 'primary',
},
]}
/>
</SettingsTabContent>
</SettingsTabContainer>
);
}
if (fetchStatus === 'pending' || fetchStatus === 'idle') {
return (
<div className={styles.spinnerContainer}>

View File

@@ -24,7 +24,7 @@ import {useCallback, useState} from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {testBulkDeleteAllMessages} from '~/actions/UserActionCreators';
import {CaptchaModal} from '~/components/modals/CaptchaModal';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {KeyboardModeIntroModal} from '~/components/modals/KeyboardModeIntroModal';
import {Button} from '~/components/uikit/Button/Button';
import type {GatewaySocket} from '~/lib/GatewaySocket';
@@ -67,7 +67,7 @@ export const ToolsTabContent: React.FC<ToolsTabContentProps> = observer(({socket
}, []);
const handleOpenClaimAccountModal = useCallback(() => {
ModalActionCreators.push(ModalActionCreators.modal(() => <ClaimAccountModal />));
openClaimAccountModal({force: true});
}, []);
if (shouldCrash) {

View File

@@ -58,6 +58,7 @@
flex: 1;
flex-direction: column;
gap: 0.75rem;
box-sizing: border-box;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid var(--background-header-secondary);
@@ -117,6 +118,8 @@
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -129,15 +132,39 @@
}
.authSessionLocation {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
color: var(--text-primary-muted);
min-width: 0;
flex-wrap: nowrap;
}
.locationText {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.locationSeparator {
background-color: var(--background-modifier-accent);
width: 0.25rem;
height: 0.25rem;
border-radius: 9999px;
display: inline-block;
flex-shrink: 0;
opacity: 0.8;
margin: 0 0.15rem;
}
.lastUsed {
font-size: 0.75rem;
flex-shrink: 0;
white-space: nowrap;
}
.authSessionActions {
@@ -228,17 +255,11 @@
}
.devicesGrid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 1024px) {
.devicesGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.logoutSection {
display: flex;
flex-direction: column;

View File

@@ -115,10 +115,10 @@ const AuthSession: React.FC<AuthSessionProps> = observer(
</span>
<div className={styles.authSessionLocation}>
{authSession.clientLocation}
<span className={styles.locationText}>{authSession.clientLocation}</span>
{!isCurrent && (
<>
<StatusDot />
<span aria-hidden className={styles.locationSeparator} />
<span className={styles.lastUsed}>
{DateUtils.getShortRelativeDateString(authSession.approxLastUsedAt ?? new Date(0))}
</span>

View File

@@ -18,7 +18,7 @@
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {CaretDownIcon, CheckIcon, CopyIcon, GiftIcon, NetworkSlashIcon} from '@phosphor-icons/react';
import {CaretDownIcon, CheckIcon, CopyIcon, GiftIcon, NetworkSlashIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
@@ -31,6 +31,7 @@ import * as UserActionCreators from '~/actions/UserActionCreators';
import {UserPremiumTypes} from '~/Constants';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
@@ -180,6 +181,7 @@ const GiftInventoryTab: React.FC = observer(() => {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
const [expandedGiftId, setExpandedGiftId] = React.useState<string | null>(null);
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
const giftCodeForm = useForm<GiftCodeFormInputs>({defaultValues: {code: ''}});
@@ -201,6 +203,10 @@ const GiftInventoryTab: React.FC = observer(() => {
});
const fetchGifts = React.useCallback(async () => {
if (isUnclaimed) {
setLoading(false);
return;
}
try {
setError(false);
const userGifts = await GiftActionCreators.fetchUserGifts();
@@ -211,7 +217,7 @@ const GiftInventoryTab: React.FC = observer(() => {
setError(true);
setLoading(false);
}
}, []);
}, [isUnclaimed]);
React.useEffect(() => {
fetchGifts();
@@ -225,6 +231,23 @@ const GiftInventoryTab: React.FC = observer(() => {
fetchGifts();
};
if (isUnclaimed) {
return (
<StatusSlate
Icon={WarningCircleIcon}
title={<Trans>Claim your account</Trans>}
description={<Trans>Claim your account to redeem or manage Plutonium gift codes.</Trans>}
actions={[
{
text: <Trans>Claim Account</Trans>,
onClick: () => openClaimAccountModal({force: true}),
variant: 'primary',
},
]}
/>
);
}
return (
<div className={styles.container}>
<div>