feat: sharing subscriptions UI (#1567)
* feat(web): add ui for subscription sharing * fix(web): add missing triggers * fix(snjs): setting authorization token on http service * fix(web): add alert upon invite failure * fix(web): display invitations list * fix(web): canceling subscription invitations * fix(web): fonts * fix(web): linter issues * fix: click event handler * fix: styles * feat: update styles * feat: don't show bottom separator if all invites used * fix(web): references to alert service * fix(web): remove usebeforeunload Co-authored-by: Aman Harwara <amanharwara@protonmail.com>
This commit is contained in:
@@ -126,6 +126,8 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setSession(session: Session, persist = true): void {
|
private setSession(session: Session, persist = true): void {
|
||||||
|
this.httpService.setAuthorizationToken(session.authorizationValue)
|
||||||
|
|
||||||
this.apiService.setSession(session, persist)
|
this.apiService.setSession(session, persist)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,8 +623,6 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
|
|||||||
|
|
||||||
this.httpService.setHost(host)
|
this.httpService.setHost(host)
|
||||||
|
|
||||||
this.httpService.setAuthorizationToken(session.authorizationValue)
|
|
||||||
|
|
||||||
await this.setSession(session)
|
await this.setSession(session)
|
||||||
|
|
||||||
this.webSocketsService.startWebSocketConnection(session.authorizationValue)
|
this.webSocketsService.startWebSocketConnection(session.authorizationValue)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"clean": "rm -fr dist && rm -rf src/components",
|
"clean": "rm -fr dist && rm -rf src/components",
|
||||||
"format": "prettier --write src/javascripts",
|
"format": "prettier --write src/javascripts",
|
||||||
"lint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint src/javascripts",
|
"lint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint src/javascripts",
|
||||||
|
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint src/javascripts --fix",
|
||||||
"start": "webpack-dev-server --config web.webpack.dev.js",
|
"start": "webpack-dev-server --config web.webpack.dev.js",
|
||||||
"start-secure": "yarn start --server-type https",
|
"start-secure": "yarn start --server-type https",
|
||||||
"test": "jest --config jest.config.js --coverage",
|
"test": "jest --config jest.config.js --coverage",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Subscription from './Subscription/Subscription'
|
|||||||
import SignOutWrapper from './SignOutView'
|
import SignOutWrapper from './SignOutView'
|
||||||
import FilesSection from './Files'
|
import FilesSection from './Files'
|
||||||
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
||||||
|
import SubscriptionSharing from './SubscriptionSharing/SubscriptionSharing'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -25,6 +26,7 @@ const AccountPreferences = ({ application, viewControllerManager }: Props) => (
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Subscription application={application} viewControllerManager={viewControllerManager} />
|
<Subscription application={application} viewControllerManager={viewControllerManager} />
|
||||||
|
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
|
||||||
{application.hasAccount() && viewControllerManager.featuresController.hasFiles && (
|
{application.hasAccount() && viewControllerManager.featuresController.hasFiles && (
|
||||||
<FilesSection application={application} />
|
<FilesSection application={application} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { InvitationStatus, Uuid } from '@standardnotes/snjs'
|
||||||
|
|
||||||
|
import { SubtitleLight, Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||||
|
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
|
||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
subscriptionState: SubscriptionController
|
||||||
|
application: WebApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
const InvitationsList = ({ subscriptionState, application }: Props) => {
|
||||||
|
const [lockContinue, setLockContinue] = useState(false)
|
||||||
|
|
||||||
|
const { usedInvitationsCount, subscriptionInvitations } = subscriptionState
|
||||||
|
|
||||||
|
const activeSubscriptions = subscriptionInvitations?.filter((invitation) =>
|
||||||
|
[InvitationStatus.Sent, InvitationStatus.Accepted].includes(invitation.status),
|
||||||
|
)
|
||||||
|
const inActiveSubscriptions = subscriptionInvitations?.filter((invitation) =>
|
||||||
|
[InvitationStatus.Declined, InvitationStatus.Canceled].includes(invitation.status),
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCancel = async (invitationUuid: Uuid) => {
|
||||||
|
if (lockContinue) {
|
||||||
|
application.alertService.alert('Cancelation already in progress.').catch(console.error)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLockContinue(true)
|
||||||
|
|
||||||
|
const success = await subscriptionState.cancelSubscriptionInvitation(invitationUuid)
|
||||||
|
|
||||||
|
setLockContinue(false)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
application.alertService
|
||||||
|
.alert('Could not cancel invitation. Please try again or contact support if the issue persists.')
|
||||||
|
.catch(console.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usedInvitationsCount === 0) {
|
||||||
|
return <Text className="mt-1 mb-3">Make your first subscription invitation below.</Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SubtitleLight className="mb-2 text-info">Active Invitations:</SubtitleLight>
|
||||||
|
{activeSubscriptions?.map((invitation) => (
|
||||||
|
<div key={invitation.uuid} className="mt-1 mb-4">
|
||||||
|
<Text>
|
||||||
|
{invitation.inviteeIdentifier} <span className="text-info">({invitation.status})</span>
|
||||||
|
</Text>
|
||||||
|
{invitation.status !== InvitationStatus.Canceled && (
|
||||||
|
<Button className="mt-2 min-w-20" label="Cancel" onClick={() => handleCancel(invitation.uuid)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!!inActiveSubscriptions?.length && (
|
||||||
|
<>
|
||||||
|
<SubtitleLight className="mb-2 text-info">Inactive Invitations:</SubtitleLight>
|
||||||
|
<div>
|
||||||
|
{inActiveSubscriptions?.map((invitation) => (
|
||||||
|
<div key={invitation.uuid} className="mb-3 first:mt-2">
|
||||||
|
<Text className="mt-1">
|
||||||
|
{invitation.inviteeIdentifier} <span className="text-info">({invitation.status})</span>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!subscriptionState.allInvitationsUsed && <HorizontalSeparator classes="my-4" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(InvitationsList)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { FunctionComponent, useState } from 'react'
|
||||||
|
|
||||||
|
import ModalDialog from '@/Components/Shared/ModalDialog'
|
||||||
|
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
|
||||||
|
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
|
||||||
|
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
|
||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { isEmailValid } from '@/Utils'
|
||||||
|
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
|
||||||
|
|
||||||
|
import InviteForm from './InviteForm'
|
||||||
|
import InviteSuccess from './InviteSuccess'
|
||||||
|
|
||||||
|
enum SubmitButtonTitles {
|
||||||
|
Default = 'Send Invitation',
|
||||||
|
Sending = 'Sending...',
|
||||||
|
Finish = 'Finish',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Steps {
|
||||||
|
InitialStep,
|
||||||
|
FinishStep,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onCloseDialog: () => void
|
||||||
|
application: WebApplication
|
||||||
|
subscriptionState: SubscriptionController
|
||||||
|
}
|
||||||
|
|
||||||
|
const Invite: FunctionComponent<Props> = ({ onCloseDialog, application, subscriptionState }) => {
|
||||||
|
const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default)
|
||||||
|
const [inviteeEmail, setInviteeEmail] = useState('')
|
||||||
|
const [isContinuing, setIsContinuing] = useState(false)
|
||||||
|
const [lockContinue, setLockContinue] = useState(false)
|
||||||
|
const [currentStep, setCurrentStep] = useState(Steps.InitialStep)
|
||||||
|
|
||||||
|
const validateInviteeEmail = async () => {
|
||||||
|
if (!isEmailValid(inviteeEmail)) {
|
||||||
|
application.alertService
|
||||||
|
.alert('The email you entered has an invalid format. Please review your input and try again.')
|
||||||
|
.catch(console.error)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
if (lockContinue) {
|
||||||
|
application.alertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
|
||||||
|
} else {
|
||||||
|
onCloseDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetProgressState = () => {
|
||||||
|
setSubmitButtonTitle(SubmitButtonTitles.Default)
|
||||||
|
setIsContinuing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processInvite = async () => {
|
||||||
|
setLockContinue(true)
|
||||||
|
|
||||||
|
const success = await subscriptionState.sendSubscriptionInvitation(inviteeEmail)
|
||||||
|
|
||||||
|
setLockContinue(false)
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (lockContinue || isContinuing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === Steps.FinishStep) {
|
||||||
|
handleDialogClose()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsContinuing(true)
|
||||||
|
setSubmitButtonTitle(SubmitButtonTitles.Sending)
|
||||||
|
|
||||||
|
const valid = await validateInviteeEmail()
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
resetProgressState()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await processInvite()
|
||||||
|
if (!success) {
|
||||||
|
application.alertService
|
||||||
|
.alert('We could not send the invitation. Please try again or contact support if the issue persists.')
|
||||||
|
.catch(console.error)
|
||||||
|
|
||||||
|
resetProgressState()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsContinuing(false)
|
||||||
|
setSubmitButtonTitle(SubmitButtonTitles.Finish)
|
||||||
|
setCurrentStep(Steps.FinishStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ModalDialog>
|
||||||
|
<ModalDialogLabel closeDialog={handleDialogClose}>Invite</ModalDialogLabel>
|
||||||
|
<ModalDialogDescription className="flex flex-row items-center px-4.5">
|
||||||
|
{currentStep === Steps.InitialStep && <InviteForm setInviteeEmail={setInviteeEmail} />}
|
||||||
|
{currentStep === Steps.FinishStep && <InviteSuccess />}
|
||||||
|
</ModalDialogDescription>
|
||||||
|
<ModalDialogButtons className="px-4.5">
|
||||||
|
<Button className="min-w-20" primary label={submitButtonTitle} onClick={handleSubmit} />
|
||||||
|
</ModalDialogButtons>
|
||||||
|
</ModalDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Invite
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Dispatch, FunctionComponent, SetStateAction } from 'react'
|
||||||
|
|
||||||
|
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setInviteeEmail: Dispatch<SetStateAction<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const InviteForm: FunctionComponent<Props> = ({ setInviteeEmail }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block" htmlFor="invite-email-input">
|
||||||
|
Invitee Email:
|
||||||
|
</label>
|
||||||
|
<DecoratedInput
|
||||||
|
type="email"
|
||||||
|
id="invite-email-input"
|
||||||
|
onChange={(email) => {
|
||||||
|
setInviteeEmail(email)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InviteForm
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
|
const InviteSuccess: FunctionComponent = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={'mb-2 font-bold text-info'}>Your invitation has been successfully sent.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InviteSuccess
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { FunctionComponent, useState } from 'react'
|
||||||
|
import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoProSubscription: FunctionComponent<Props> = ({ application }) => {
|
||||||
|
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false)
|
||||||
|
const [purchaseFlowError, setPurchaseFlowError] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const onPurchaseClick = async () => {
|
||||||
|
const errorMessage = 'There was an error when attempting to redirect you to the subscription page.'
|
||||||
|
setIsLoadingPurchaseFlow(true)
|
||||||
|
try {
|
||||||
|
if (!(await loadPurchaseFlowUrl(application))) {
|
||||||
|
setPurchaseFlowError(errorMessage)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setPurchaseFlowError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingPurchaseFlow(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text>
|
||||||
|
Subscription sharing is available only on the <span className="font-bold">Professional</span> plan. Please
|
||||||
|
upgrade in order to share subscription.
|
||||||
|
</Text>
|
||||||
|
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
|
||||||
|
{purchaseFlowError && <Text className="text-danger">{purchaseFlowError}</Text>}
|
||||||
|
<div className="flex">
|
||||||
|
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
|
||||||
|
{application.hasAccount() && (
|
||||||
|
<Button className="mt-3 min-w-20" primary label="Upgrade" onClick={onPurchaseClick} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoProSubscription
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||||
|
|
||||||
|
type Props = { subscriptionState: SubscriptionController }
|
||||||
|
|
||||||
|
const SharingStatusText = ({ subscriptionState }: Props) => {
|
||||||
|
const { usedInvitationsCount, allowedInvitationsCount } = subscriptionState
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text className="mt-1">
|
||||||
|
You have have used <span className="font-bold">{usedInvitationsCount}</span> out of {allowedInvitationsCount}{' '}
|
||||||
|
subscription invitations.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(SharingStatusText)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent, useState } from 'react'
|
||||||
|
|
||||||
|
import { Title } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||||
|
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
|
||||||
|
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
|
||||||
|
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
|
||||||
|
|
||||||
|
import NoProSubscription from './NoProSubscription'
|
||||||
|
import InvitationsList from './InvitationsList'
|
||||||
|
import Invite from './Invite/Invite'
|
||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import SharingStatusText from './SharingStatusText'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubscriptionSharing: FunctionComponent<Props> = ({ application, viewControllerManager }: Props) => {
|
||||||
|
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const subscriptionState = viewControllerManager.subscriptionController
|
||||||
|
|
||||||
|
const isSubscriptionSharingFeatureAvailable =
|
||||||
|
application.features.getFeatureStatus(FeatureIdentifier.TwoFactorAuth) === FeatureStatus.Entitled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreferencesGroup>
|
||||||
|
<PreferencesSegment>
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<div className="flex flex-grow flex-col">
|
||||||
|
<Title className="mb-2">Subscription Sharing</Title>
|
||||||
|
{isSubscriptionSharingFeatureAvailable ? (
|
||||||
|
<div>
|
||||||
|
<SharingStatusText subscriptionState={subscriptionState} />
|
||||||
|
<HorizontalSeparator classes="my-4" />
|
||||||
|
<InvitationsList subscriptionState={subscriptionState} application={application} />
|
||||||
|
{!subscriptionState.allInvitationsUsed && (
|
||||||
|
<Button className="min-w-20" label="Invite" onClick={() => setIsInviteDialogOpen(true)} />
|
||||||
|
)}
|
||||||
|
{isInviteDialogOpen && (
|
||||||
|
<Invite
|
||||||
|
onCloseDialog={() => setIsInviteDialogOpen(false)}
|
||||||
|
application={application}
|
||||||
|
subscriptionState={subscriptionState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NoProSubscription application={application} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreferencesSegment>
|
||||||
|
</PreferencesGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(SubscriptionSharing)
|
||||||
@@ -1,29 +1,25 @@
|
|||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
import { FunctionComponent, ReactNode } from 'react'
|
import { FunctionComponent, ReactNode } from 'react'
|
||||||
|
|
||||||
type ChildrenProp = {
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Title: FunctionComponent<ChildrenProp> = ({ children }) => (
|
|
||||||
<>
|
|
||||||
<h2 className="m-0 mb-1 text-lg font-bold text-info md:text-base">{children}</h2>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string
|
className?: string
|
||||||
} & ChildrenProp
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
export const Subtitle: FunctionComponent<Props> = ({ children, className = '' }) => (
|
export const Title: FunctionComponent<Props> = ({ children, className }) => (
|
||||||
<h4 className={`m-0 mb-1 text-sm font-medium ${className}`}>{children}</h4>
|
<h2 className={classNames('m-0 mb-1 text-lg font-bold text-info md:text-base', className)}>{children}</h2>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const SubtitleLight: FunctionComponent<Props> = ({ children, className = '' }) => (
|
export const Subtitle: FunctionComponent<Props> = ({ children, className }) => (
|
||||||
<h4 className={`m-0 mb-1 text-sm font-normal ${className}`}>{children}</h4>
|
<h4 className={classNames('m-0 mb-1 text-sm font-medium', className)}>{children}</h4>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Text: FunctionComponent<Props> = ({ children, className = '' }) => (
|
export const SubtitleLight: FunctionComponent<Props> = ({ children, className }) => (
|
||||||
<p className={`${className} text-sm md:text-xs`}>{children}</p>
|
<h4 className={classNames('m-0 mb-1 text-sm font-normal', className)}>{children}</h4>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Text: FunctionComponent<Props> = ({ children, className }) => (
|
||||||
|
<p className={classNames('text-sm md:text-xs', className)}>{children}</p>
|
||||||
)
|
)
|
||||||
|
|
||||||
const buttonClasses =
|
const buttonClasses =
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
ClientDisplayableError,
|
ClientDisplayableError,
|
||||||
convertTimestampToMilliseconds,
|
convertTimestampToMilliseconds,
|
||||||
InternalEventBus,
|
InternalEventBus,
|
||||||
|
Invitation,
|
||||||
|
InvitationStatus,
|
||||||
|
SubscriptionClientInterface,
|
||||||
|
Uuid,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { action, computed, makeObservable, observable } from 'mobx'
|
import { action, computed, makeObservable, observable } from 'mobx'
|
||||||
import { WebApplication } from '../../Application/Application'
|
import { WebApplication } from '../../Application/Application'
|
||||||
@@ -12,28 +16,40 @@ import { AvailableSubscriptions } from './AvailableSubscriptionsType'
|
|||||||
import { Subscription } from './SubscriptionType'
|
import { Subscription } from './SubscriptionType'
|
||||||
|
|
||||||
export class SubscriptionController extends AbstractViewController {
|
export class SubscriptionController extends AbstractViewController {
|
||||||
|
private readonly ALLOWED_SUBSCRIPTION_INVITATIONS = 5
|
||||||
|
|
||||||
userSubscription: Subscription | undefined = undefined
|
userSubscription: Subscription | undefined = undefined
|
||||||
availableSubscriptions: AvailableSubscriptions | undefined = undefined
|
availableSubscriptions: AvailableSubscriptions | undefined = undefined
|
||||||
|
subscriptionInvitations: Invitation[] | undefined = undefined
|
||||||
|
|
||||||
override deinit() {
|
override deinit() {
|
||||||
super.deinit()
|
super.deinit()
|
||||||
;(this.userSubscription as unknown) = undefined
|
;(this.userSubscription as unknown) = undefined
|
||||||
;(this.availableSubscriptions as unknown) = undefined
|
;(this.availableSubscriptions as unknown) = undefined
|
||||||
|
;(this.subscriptionInvitations as unknown) = undefined
|
||||||
|
|
||||||
destroyAllObjectProperties(this)
|
destroyAllObjectProperties(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
constructor(
|
||||||
|
application: WebApplication,
|
||||||
|
eventBus: InternalEventBus,
|
||||||
|
private subscriptionManager: SubscriptionClientInterface,
|
||||||
|
) {
|
||||||
super(application, eventBus)
|
super(application, eventBus)
|
||||||
|
|
||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
userSubscription: observable,
|
userSubscription: observable,
|
||||||
availableSubscriptions: observable,
|
availableSubscriptions: observable,
|
||||||
|
subscriptionInvitations: observable,
|
||||||
|
|
||||||
userSubscriptionName: computed,
|
userSubscriptionName: computed,
|
||||||
userSubscriptionExpirationDate: computed,
|
userSubscriptionExpirationDate: computed,
|
||||||
isUserSubscriptionExpired: computed,
|
isUserSubscriptionExpired: computed,
|
||||||
isUserSubscriptionCanceled: computed,
|
isUserSubscriptionCanceled: computed,
|
||||||
|
usedInvitationsCount: computed,
|
||||||
|
allowedInvitationsCount: computed,
|
||||||
|
allInvitationsUsed: computed,
|
||||||
|
|
||||||
setUserSubscription: action,
|
setUserSubscription: action,
|
||||||
setAvailableSubscriptions: action,
|
setAvailableSubscriptions: action,
|
||||||
@@ -43,6 +59,7 @@ export class SubscriptionController extends AbstractViewController {
|
|||||||
application.addEventObserver(async () => {
|
application.addEventObserver(async () => {
|
||||||
if (application.hasAccount()) {
|
if (application.hasAccount()) {
|
||||||
this.getSubscriptionInfo().catch(console.error)
|
this.getSubscriptionInfo().catch(console.error)
|
||||||
|
this.reloadSubscriptionInvitations().catch(console.error)
|
||||||
}
|
}
|
||||||
}, ApplicationEvent.Launched),
|
}, ApplicationEvent.Launched),
|
||||||
)
|
)
|
||||||
@@ -50,12 +67,14 @@ export class SubscriptionController extends AbstractViewController {
|
|||||||
this.disposers.push(
|
this.disposers.push(
|
||||||
application.addEventObserver(async () => {
|
application.addEventObserver(async () => {
|
||||||
this.getSubscriptionInfo().catch(console.error)
|
this.getSubscriptionInfo().catch(console.error)
|
||||||
|
this.reloadSubscriptionInvitations().catch(console.error)
|
||||||
}, ApplicationEvent.SignedIn),
|
}, ApplicationEvent.SignedIn),
|
||||||
)
|
)
|
||||||
|
|
||||||
this.disposers.push(
|
this.disposers.push(
|
||||||
application.addEventObserver(async () => {
|
application.addEventObserver(async () => {
|
||||||
this.getSubscriptionInfo().catch(console.error)
|
this.getSubscriptionInfo().catch(console.error)
|
||||||
|
this.reloadSubscriptionInvitations().catch(console.error)
|
||||||
}, ApplicationEvent.UserRolesChanged),
|
}, ApplicationEvent.UserRolesChanged),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,6 +110,22 @@ export class SubscriptionController extends AbstractViewController {
|
|||||||
return Boolean(this.userSubscription?.cancelled)
|
return Boolean(this.userSubscription?.cancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get usedInvitationsCount(): number {
|
||||||
|
return (
|
||||||
|
this.subscriptionInvitations?.filter((invitation) =>
|
||||||
|
[InvitationStatus.Accepted, InvitationStatus.Sent].includes(invitation.status),
|
||||||
|
).length ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get allowedInvitationsCount(): number {
|
||||||
|
return this.ALLOWED_SUBSCRIPTION_INVITATIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
get allInvitationsUsed(): boolean {
|
||||||
|
return this.usedInvitationsCount === this.ALLOWED_SUBSCRIPTION_INVITATIONS
|
||||||
|
}
|
||||||
|
|
||||||
public setUserSubscription(subscription: Subscription): void {
|
public setUserSubscription(subscription: Subscription): void {
|
||||||
this.userSubscription = subscription
|
this.userSubscription = subscription
|
||||||
}
|
}
|
||||||
@@ -99,6 +134,26 @@ export class SubscriptionController extends AbstractViewController {
|
|||||||
this.availableSubscriptions = subscriptions
|
this.availableSubscriptions = subscriptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendSubscriptionInvitation(inviteeEmail: string): Promise<boolean> {
|
||||||
|
const success = await this.subscriptionManager.inviteToSubscription(inviteeEmail)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await this.reloadSubscriptionInvitations()
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelSubscriptionInvitation(invitationUuid: Uuid): Promise<boolean> {
|
||||||
|
const success = await this.subscriptionManager.cancelInvitation(invitationUuid)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await this.reloadSubscriptionInvitations()
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
private async getAvailableSubscriptions() {
|
private async getAvailableSubscriptions() {
|
||||||
try {
|
try {
|
||||||
const subscriptions = await this.application.getAvailableSubscriptions()
|
const subscriptions = await this.application.getAvailableSubscriptions()
|
||||||
@@ -125,4 +180,8 @@ export class SubscriptionController extends AbstractViewController {
|
|||||||
await this.getSubscription()
|
await this.getSubscription()
|
||||||
await this.getAvailableSubscriptions()
|
await this.getAvailableSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async reloadSubscriptionInvitations(): Promise<void> {
|
||||||
|
this.subscriptionInvitations = await this.subscriptionManager.listSubscriptionInvitations()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
InternalEventBus,
|
InternalEventBus,
|
||||||
ItemCounterInterface,
|
ItemCounterInterface,
|
||||||
ItemCounter,
|
ItemCounter,
|
||||||
|
SubscriptionClientInterface,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { action, makeObservable, observable } from 'mobx'
|
import { action, makeObservable, observable } from 'mobx'
|
||||||
import { ActionsMenuController } from './ActionsMenuController'
|
import { ActionsMenuController } from './ActionsMenuController'
|
||||||
@@ -60,12 +61,15 @@ export class ViewControllerManager {
|
|||||||
private appEventObserverRemovers: (() => void)[] = []
|
private appEventObserverRemovers: (() => void)[] = []
|
||||||
private eventBus: InternalEventBus
|
private eventBus: InternalEventBus
|
||||||
private itemCounter: ItemCounterInterface
|
private itemCounter: ItemCounterInterface
|
||||||
|
private subscriptionManager: SubscriptionClientInterface
|
||||||
|
|
||||||
constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) {
|
constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) {
|
||||||
this.eventBus = new InternalEventBus()
|
this.eventBus = new InternalEventBus()
|
||||||
|
|
||||||
this.itemCounter = new ItemCounter()
|
this.itemCounter = new ItemCounter()
|
||||||
|
|
||||||
|
this.subscriptionManager = application.subscriptions
|
||||||
|
|
||||||
this.selectionController = new SelectedItemsController(application, this.eventBus)
|
this.selectionController = new SelectedItemsController(application, this.eventBus)
|
||||||
|
|
||||||
this.noteTagsController = new NoteTagsController(application, this.eventBus)
|
this.noteTagsController = new NoteTagsController(application, this.eventBus)
|
||||||
@@ -102,7 +106,7 @@ export class ViewControllerManager {
|
|||||||
|
|
||||||
this.accountMenuController = new AccountMenuController(application, this.eventBus, this.itemCounter)
|
this.accountMenuController = new AccountMenuController(application, this.eventBus, this.itemCounter)
|
||||||
|
|
||||||
this.subscriptionController = new SubscriptionController(application, this.eventBus)
|
this.subscriptionController = new SubscriptionController(application, this.eventBus, this.subscriptionManager)
|
||||||
|
|
||||||
this.purchaseFlowController = new PurchaseFlowController(application, this.eventBus)
|
this.purchaseFlowController = new PurchaseFlowController(application, this.eventBus)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user