mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 13:01:59 +03:00
Merge pull request #4410 from toeverything/payment-system
feat: payment system
This commit is contained in:
commit
f11cc40ae3
@ -3,4 +3,6 @@ NEXTAUTH_URL="http://localhost:8080"
|
||||
OAUTH_EMAIL_SENDER="noreply@toeverything.info"
|
||||
OAUTH_EMAIL_LOGIN=""
|
||||
OAUTH_EMAIL_PASSWORD=""
|
||||
ENABLE_LOCAL_EMAIL="true"
|
||||
ENABLE_LOCAL_EMAIL="true"
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_KEY=
|
||||
|
@ -0,0 +1,68 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_stripe_customers" (
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"stripe_customer_id" VARCHAR NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_stripe_customers_pkey" PRIMARY KEY ("user_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_subscriptions" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" VARCHAR(36) NOT NULL,
|
||||
"plan" VARCHAR(20) NOT NULL,
|
||||
"recurring" VARCHAR(20) NOT NULL,
|
||||
"stripe_subscription_id" TEXT NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"start" TIMESTAMPTZ(6) NOT NULL,
|
||||
"end" TIMESTAMPTZ(6) NOT NULL,
|
||||
"next_bill_at" TIMESTAMPTZ(6),
|
||||
"canceled_at" TIMESTAMPTZ(6),
|
||||
"trial_start" TIMESTAMPTZ(6),
|
||||
"trial_end" TIMESTAMPTZ(6),
|
||||
"stripe_schedule_id" VARCHAR,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_subscriptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_invoices" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" VARCHAR(36) NOT NULL,
|
||||
"stripe_invoice_id" TEXT NOT NULL,
|
||||
"currency" VARCHAR(3) NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"plan" VARCHAR(20) NOT NULL,
|
||||
"recurring" VARCHAR(20) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
"reason" VARCHAR NOT NULL,
|
||||
"last_payment_error" TEXT,
|
||||
|
||||
CONSTRAINT "user_invoices_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_stripe_customers_stripe_customer_id_key" ON "user_stripe_customers"("stripe_customer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_subscriptions_user_id_key" ON "user_subscriptions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_subscriptions_stripe_subscription_id_key" ON "user_subscriptions"("stripe_subscription_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_invoices_stripe_invoice_id_key" ON "user_invoices"("stripe_invoice_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_stripe_customers" ADD CONSTRAINT "user_stripe_customers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_subscriptions" ADD CONSTRAINT "user_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_invoices" ADD CONSTRAINT "user_invoices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_invoices" ADD COLUMN "link" TEXT;
|
@ -27,6 +27,7 @@
|
||||
"@nestjs/apollo": "^12.0.9",
|
||||
"@nestjs/common": "^10.2.7",
|
||||
"@nestjs/core": "^10.2.7",
|
||||
"@nestjs/event-emitter": "^2.0.2",
|
||||
"@nestjs/graphql": "^12.0.9",
|
||||
"@nestjs/platform-express": "^10.2.7",
|
||||
"@nestjs/platform-socket.io": "^10.2.7",
|
||||
@ -74,6 +75,7 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"semver": "^7.5.4",
|
||||
"socket.io": "^4.7.2",
|
||||
"stripe": "^14.1.0",
|
||||
"ws": "^8.14.2",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
|
@ -49,6 +49,9 @@ model User {
|
||||
/// Not available if user signed up through OAuth providers
|
||||
password String? @db.VarChar
|
||||
features UserFeatureGates[]
|
||||
customer UserStripeCustomer?
|
||||
subscription UserSubscription?
|
||||
invoices UserInvoice[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -165,6 +168,70 @@ model NewFeaturesWaitingList {
|
||||
@@map("new_features_waiting_list")
|
||||
}
|
||||
|
||||
model UserStripeCustomer {
|
||||
userId String @id @map("user_id") @db.VarChar
|
||||
stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_stripe_customers")
|
||||
}
|
||||
|
||||
model UserSubscription {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @unique @map("user_id") @db.VarChar(36)
|
||||
plan String @db.VarChar(20)
|
||||
// yearly/monthly
|
||||
recurring String @db.VarChar(20)
|
||||
// subscription.id
|
||||
stripeSubscriptionId String @unique @map("stripe_subscription_id")
|
||||
// subscription.status, active/past_due/canceled/unpaid...
|
||||
status String @db.VarChar(20)
|
||||
// subscription.current_period_start
|
||||
start DateTime @map("start") @db.Timestamptz(6)
|
||||
// subscription.current_period_end
|
||||
end DateTime @map("end") @db.Timestamptz(6)
|
||||
// subscription.billing_cycle_anchor
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6)
|
||||
// subscription.canceled_at
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(6)
|
||||
// subscription.trial_start
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamptz(6)
|
||||
// subscription.trial_end
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamptz(6)
|
||||
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_subscriptions")
|
||||
}
|
||||
|
||||
model UserInvoice {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
stripeInvoiceId String @unique @map("stripe_invoice_id")
|
||||
currency String @db.VarChar(3)
|
||||
// CNY 12.50 stored as 1250
|
||||
amount Int @db.Integer
|
||||
status String @db.VarChar(20)
|
||||
plan String @db.VarChar(20)
|
||||
recurring String @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
// billing reason
|
||||
reason String @db.VarChar
|
||||
lastPaymentError String? @map("last_payment_error") @db.Text
|
||||
// stripe hosted invoice link
|
||||
link String? @db.Text
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_invoices")
|
||||
}
|
||||
|
||||
model DataMigration {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
name String @db.VarChar
|
||||
|
@ -363,4 +363,13 @@ export interface AFFiNEConfig {
|
||||
experimentalMergeWithJwstCodec: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
payment: {
|
||||
stripe: {
|
||||
keys: {
|
||||
APIKey: string;
|
||||
webhookKey: string;
|
||||
};
|
||||
} & import('stripe').Stripe.StripeConfig;
|
||||
};
|
||||
}
|
||||
|
@ -89,6 +89,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
'boolean',
|
||||
],
|
||||
ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'],
|
||||
STRIPE_API_KEY: 'payment.stripe.keys.APIKey',
|
||||
STRIPE_WEBHOOK_KEY: 'payment.stripe.keys.webhookKey',
|
||||
} satisfies AFFiNEConfig['ENV_MAP'],
|
||||
affineEnv: 'dev',
|
||||
get affine() {
|
||||
@ -207,6 +209,15 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
experimentalMergeWithJwstCodec: false,
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
stripe: {
|
||||
keys: {
|
||||
APIKey: '',
|
||||
webhookKey: '',
|
||||
},
|
||||
apiVersion: '2023-10-16',
|
||||
},
|
||||
},
|
||||
} satisfies AFFiNEConfig;
|
||||
|
||||
applyEnvToConfig(defaultConfig);
|
||||
|
@ -59,6 +59,7 @@ if (NODE_ENV === 'production') {
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
cors: true,
|
||||
rawBody: true,
|
||||
bodyParser: true,
|
||||
logger:
|
||||
NODE_ENV !== 'production' || AFFINE_ENV !== 'production'
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { DynamicModule, Type } from '@nestjs/common';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
import { GqlModule } from '../graphql.module';
|
||||
import { AuthModule } from './auth';
|
||||
import { DocModule } from './doc';
|
||||
import { PaymentModule } from './payment';
|
||||
import { SyncModule } from './sync';
|
||||
import { UsersModule } from './users';
|
||||
import { WorkspaceModule } from './workspaces';
|
||||
@ -17,22 +19,30 @@ switch (SERVER_FLAVOR) {
|
||||
break;
|
||||
case 'graphql':
|
||||
BusinessModules.push(
|
||||
EventEmitterModule.forRoot({
|
||||
global: true,
|
||||
}),
|
||||
GqlModule,
|
||||
WorkspaceModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
DocModule.forRoot()
|
||||
DocModule.forRoot(),
|
||||
PaymentModule
|
||||
);
|
||||
break;
|
||||
case 'allinone':
|
||||
default:
|
||||
BusinessModules.push(
|
||||
EventEmitterModule.forRoot({
|
||||
global: true,
|
||||
}),
|
||||
GqlModule,
|
||||
WorkspaceModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
SyncModule,
|
||||
DocModule.forRoot()
|
||||
DocModule.forRoot(),
|
||||
PaymentModule
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
21
packages/backend/server/src/modules/payment/index.ts
Normal file
21
packages/backend/server/src/modules/payment/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UsersModule } from '../users';
|
||||
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
|
||||
import { ScheduleManager } from './schedule';
|
||||
import { SubscriptionService } from './service';
|
||||
import { StripeProvider } from './stripe';
|
||||
import { StripeWebhook } from './webhook';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
providers: [
|
||||
ScheduleManager,
|
||||
StripeProvider,
|
||||
SubscriptionService,
|
||||
SubscriptionResolver,
|
||||
UserSubscriptionResolver,
|
||||
],
|
||||
controllers: [StripeWebhook],
|
||||
})
|
||||
export class PaymentModule {}
|
305
packages/backend/server/src/modules/payment/resolver.ts
Normal file
305
packages/backend/server/src/modules/payment/resolver.ts
Normal file
@ -0,0 +1,305 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { groupBy } from 'lodash-es';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { Auth, CurrentUser, Public } from '../auth';
|
||||
import { UserType } from '../users';
|
||||
import {
|
||||
decodeLookupKey,
|
||||
InvoiceStatus,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionService,
|
||||
SubscriptionStatus,
|
||||
} from './service';
|
||||
|
||||
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
|
||||
registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' });
|
||||
registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' });
|
||||
registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' });
|
||||
|
||||
@ObjectType()
|
||||
class SubscriptionPrice {
|
||||
@Field(() => String)
|
||||
type!: 'fixed';
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field()
|
||||
currency!: string;
|
||||
|
||||
@Field()
|
||||
amount!: number;
|
||||
|
||||
@Field()
|
||||
yearlyAmount!: number;
|
||||
}
|
||||
|
||||
@ObjectType('UserSubscription')
|
||||
class UserSubscriptionType implements Partial<UserSubscription> {
|
||||
@Field({ name: 'id' })
|
||||
stripeSubscriptionId!: string;
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: SubscriptionRecurring;
|
||||
|
||||
@Field(() => SubscriptionStatus)
|
||||
status!: SubscriptionStatus;
|
||||
|
||||
@Field(() => Date)
|
||||
start!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
end!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
trialStart?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
trialEnd?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
nextBillAt?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
canceledAt?: Date | null;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@ObjectType('UserInvoice')
|
||||
class UserInvoiceType implements Partial<UserInvoice> {
|
||||
@Field({ name: 'id' })
|
||||
stripeInvoiceId!: string;
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: SubscriptionRecurring;
|
||||
|
||||
@Field()
|
||||
currency!: string;
|
||||
|
||||
@Field()
|
||||
amount!: number;
|
||||
|
||||
@Field(() => InvoiceStatus)
|
||||
status!: InvoiceStatus;
|
||||
|
||||
@Field()
|
||||
reason!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
lastPaymentError?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
link?: string | null;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@Resolver(() => UserSubscriptionType)
|
||||
export class SubscriptionResolver {
|
||||
constructor(
|
||||
private readonly service: SubscriptionService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Query(() => [SubscriptionPrice])
|
||||
async prices(): Promise<SubscriptionPrice[]> {
|
||||
const prices = await this.service.listPrices();
|
||||
|
||||
const group = groupBy(
|
||||
prices.data.filter(price => !!price.lookup_key),
|
||||
price => {
|
||||
// @ts-expect-error empty lookup key is filtered out
|
||||
const [plan] = decodeLookupKey(price.lookup_key);
|
||||
return plan;
|
||||
}
|
||||
);
|
||||
|
||||
return Object.entries(group).map(([plan, prices]) => {
|
||||
const yearly = prices.find(
|
||||
price =>
|
||||
decodeLookupKey(
|
||||
// @ts-expect-error empty lookup key is filtered out
|
||||
price.lookup_key
|
||||
)[1] === SubscriptionRecurring.Yearly
|
||||
);
|
||||
const monthly = prices.find(
|
||||
price =>
|
||||
decodeLookupKey(
|
||||
// @ts-expect-error empty lookup key is filtered out
|
||||
price.lookup_key
|
||||
)[1] === SubscriptionRecurring.Monthly
|
||||
);
|
||||
|
||||
if (!yearly || !monthly) {
|
||||
throw new GraphQLError('The prices are not configured correctly', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'fixed',
|
||||
plan: plan as SubscriptionPlan,
|
||||
currency: monthly.currency,
|
||||
amount: monthly.unit_amount ?? 0,
|
||||
yearlyAmount: yearly.unit_amount ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
description: 'Create a subscription checkout link of stripe',
|
||||
})
|
||||
async checkout(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
|
||||
recurring: SubscriptionRecurring,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
const session = await this.service.createCheckoutSession({
|
||||
user,
|
||||
recurring,
|
||||
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new GraphQLError('Failed to create checkout session', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return session.url;
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
description: 'Create a stripe customer portal to manage payment methods',
|
||||
})
|
||||
async createCustomerPortal(@CurrentUser() user: User) {
|
||||
return this.service.createCustomerPortal(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async cancelSubscription(
|
||||
@CurrentUser() user: User,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.cancelSubscription(idempotencyKey, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async resumeSubscription(
|
||||
@CurrentUser() user: User,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.resumeCanceledSubscription(idempotencyKey, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async updateSubscriptionRecurring(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
|
||||
recurring: SubscriptionRecurring,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.updateSubscriptionRecurring(
|
||||
idempotencyKey,
|
||||
user.id,
|
||||
recurring
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(private readonly db: PrismaService) {}
|
||||
|
||||
@ResolveField(() => UserSubscriptionType, { nullable: true })
|
||||
async subscription(@CurrentUser() me: User, @Parent() user: User) {
|
||||
if (me.id !== user.id) {
|
||||
throw new GraphQLError(
|
||||
'You are not allowed to access this subscription',
|
||||
{
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [UserInvoiceType])
|
||||
async invoices(
|
||||
@CurrentUser() me: User,
|
||||
@Parent() user: User,
|
||||
@Args('take', { type: () => Int, nullable: true, defaultValue: 8 })
|
||||
take: number,
|
||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
||||
) {
|
||||
if (me.id !== user.id) {
|
||||
throw new GraphQLError('You are not allowed to access this invoices', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.db.userInvoice.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
take,
|
||||
skip,
|
||||
orderBy: {
|
||||
id: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
238
packages/backend/server/src/modules/payment/schedule.ts
Normal file
238
packages/backend/server/src/modules/payment/schedule.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
@Injectable()
|
||||
export class ScheduleManager {
|
||||
private _schedule: Stripe.SubscriptionSchedule | null = null;
|
||||
private readonly logger = new Logger(ScheduleManager.name);
|
||||
|
||||
constructor(private readonly stripe: Stripe) {}
|
||||
|
||||
static create(stripe: Stripe, schedule?: Stripe.SubscriptionSchedule) {
|
||||
const manager = new ScheduleManager(stripe);
|
||||
if (schedule) {
|
||||
manager._schedule = schedule;
|
||||
}
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
get schedule() {
|
||||
return this._schedule;
|
||||
}
|
||||
|
||||
get currentPhase() {
|
||||
if (!this._schedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._schedule.phases.find(
|
||||
phase =>
|
||||
phase.start_date * 1000 < Date.now() &&
|
||||
phase.end_date * 1000 > Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
get nextPhase() {
|
||||
if (!this._schedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._schedule.phases.find(
|
||||
phase => phase.start_date * 1000 > Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this._schedule?.status === 'active';
|
||||
}
|
||||
|
||||
async fromSchedule(schedule: string | Stripe.SubscriptionSchedule) {
|
||||
if (typeof schedule === 'string') {
|
||||
const s = await this.stripe.subscriptionSchedules
|
||||
.retrieve(schedule)
|
||||
.catch(e => {
|
||||
this.logger.error('Failed to retrieve subscription schedule', e);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return ScheduleManager.create(this.stripe, s);
|
||||
} else {
|
||||
return ScheduleManager.create(this.stripe, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
async fromSubscription(
|
||||
idempotencyKey: string,
|
||||
subscription: string | Stripe.Subscription
|
||||
) {
|
||||
if (typeof subscription === 'string') {
|
||||
subscription = await this.stripe.subscriptions.retrieve(subscription, {
|
||||
expand: ['schedule'],
|
||||
});
|
||||
}
|
||||
|
||||
if (subscription.schedule) {
|
||||
return await this.fromSchedule(subscription.schedule);
|
||||
} else {
|
||||
const schedule = await this.stripe.subscriptionSchedules.create(
|
||||
{ from_subscription: subscription.id },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
|
||||
return await this.fromSchedule(schedule);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a subscription by marking schedule's end behavior to `cancel`.
|
||||
* At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription.
|
||||
*/
|
||||
async cancel(idempotencyKey: string) {
|
||||
if (!this._schedule) {
|
||||
throw new Error('No schedule');
|
||||
}
|
||||
|
||||
if (!this.isActive || !this.currentPhase) {
|
||||
throw new Error('Unexpected subscription schedule status');
|
||||
}
|
||||
|
||||
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
||||
items: [
|
||||
{
|
||||
price: this.currentPhase.items[0].price as string,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
coupon: (this.currentPhase.coupon as string | null) ?? undefined,
|
||||
start_date: this.currentPhase.start_date,
|
||||
end_date: this.currentPhase.end_date,
|
||||
};
|
||||
|
||||
if (this.nextPhase) {
|
||||
// cancel a subscription with a schedule exiting will delete the upcoming phase,
|
||||
// it's hard to recover the subscription to the original state if user wan't to resume before due.
|
||||
// so we manually save the next phase's key information to metadata for later easy resuming.
|
||||
phases.metadata = {
|
||||
next_coupon: (this.nextPhase.coupon as string | null) || null, // avoid empty string
|
||||
next_price: this.nextPhase.items[0].price as string,
|
||||
};
|
||||
}
|
||||
|
||||
await this.stripe.subscriptionSchedules.update(
|
||||
this._schedule.id,
|
||||
{
|
||||
phases: [phases],
|
||||
end_behavior: 'cancel',
|
||||
},
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
|
||||
async resume(idempotencyKey: string) {
|
||||
if (!this._schedule) {
|
||||
throw new Error('No schedule');
|
||||
}
|
||||
|
||||
if (!this.isActive || !this.currentPhase) {
|
||||
throw new Error('Unexpected subscription schedule status');
|
||||
}
|
||||
|
||||
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
price: this.currentPhase.items[0].price as string,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
coupon: (this.currentPhase.coupon as string | null) ?? undefined,
|
||||
start_date: this.currentPhase.start_date,
|
||||
end_date: this.currentPhase.end_date,
|
||||
metadata: {
|
||||
next_coupon: null,
|
||||
next_price: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (this.currentPhase.metadata && this.currentPhase.metadata.next_price) {
|
||||
phases.push({
|
||||
items: [
|
||||
{
|
||||
price: this.currentPhase.metadata.next_price,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
coupon: this.currentPhase.metadata.next_coupon || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
await this.stripe.subscriptionSchedules.update(
|
||||
this._schedule.id,
|
||||
{
|
||||
phases: phases,
|
||||
end_behavior: 'release',
|
||||
},
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
|
||||
async release(idempotencyKey: string) {
|
||||
if (!this._schedule) {
|
||||
throw new Error('No schedule');
|
||||
}
|
||||
|
||||
await this.stripe.subscriptionSchedules.release(this._schedule.id, {
|
||||
idempotencyKey,
|
||||
});
|
||||
}
|
||||
|
||||
async update(idempotencyKey: string, price: string, coupon?: string) {
|
||||
if (!this._schedule) {
|
||||
throw new Error('No schedule');
|
||||
}
|
||||
|
||||
if (!this.isActive || !this.currentPhase) {
|
||||
throw new Error('Unexpected subscription schedule status');
|
||||
}
|
||||
|
||||
// if current phase's plan matches target, and no coupon change, just release the schedule
|
||||
if (
|
||||
this.currentPhase.items[0].price === price &&
|
||||
(!coupon || this.currentPhase.coupon === coupon)
|
||||
) {
|
||||
await this.stripe.subscriptionSchedules.release(this._schedule.id, {
|
||||
idempotencyKey,
|
||||
});
|
||||
this._schedule = null;
|
||||
} else {
|
||||
await this.stripe.subscriptionSchedules.update(
|
||||
this._schedule.id,
|
||||
{
|
||||
phases: [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
price: this.currentPhase.items[0].price as string,
|
||||
},
|
||||
],
|
||||
start_date: this.currentPhase.start_date,
|
||||
end_date: this.currentPhase.end_date,
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
price: price,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
coupon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
669
packages/backend/server/src/modules/payment/service.ts
Normal file
669
packages/backend/server/src/modules/payment/service.ts
Normal file
@ -0,0 +1,669 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
|
||||
import type {
|
||||
Prisma,
|
||||
User,
|
||||
UserInvoice,
|
||||
UserStripeCustomer,
|
||||
UserSubscription,
|
||||
} from '@prisma/client';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { UsersService } from '../users';
|
||||
import { ScheduleManager } from './schedule';
|
||||
|
||||
const OnEvent = (
|
||||
event: Stripe.Event.Type,
|
||||
opts?: Parameters<typeof RawOnEvent>[1]
|
||||
) => RawOnEvent(event, opts);
|
||||
|
||||
// Plan x Recurring make a stripe price lookup key
|
||||
export enum SubscriptionRecurring {
|
||||
Monthly = 'monthly',
|
||||
Yearly = 'yearly',
|
||||
}
|
||||
|
||||
export enum SubscriptionPlan {
|
||||
Free = 'free',
|
||||
Pro = 'pro',
|
||||
Team = 'team',
|
||||
Enterprise = 'enterprise',
|
||||
}
|
||||
|
||||
export function encodeLookupKey(
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring
|
||||
): string {
|
||||
return plan + '_' + recurring;
|
||||
}
|
||||
|
||||
export function decodeLookupKey(
|
||||
key: string
|
||||
): [SubscriptionPlan, SubscriptionRecurring] {
|
||||
const [plan, recurring] = key.split('_');
|
||||
|
||||
return [plan as SubscriptionPlan, recurring as SubscriptionRecurring];
|
||||
}
|
||||
|
||||
// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
||||
export enum SubscriptionStatus {
|
||||
Active = 'active',
|
||||
PastDue = 'past_due',
|
||||
Unpaid = 'unpaid',
|
||||
Canceled = 'canceled',
|
||||
Incomplete = 'incomplete',
|
||||
Paused = 'paused',
|
||||
IncompleteExpired = 'incomplete_expired',
|
||||
Trialing = 'trialing',
|
||||
}
|
||||
|
||||
export enum InvoiceStatus {
|
||||
Draft = 'draft',
|
||||
Open = 'open',
|
||||
Void = 'void',
|
||||
Paid = 'paid',
|
||||
Uncollectible = 'uncollectible',
|
||||
}
|
||||
|
||||
export enum CouponType {
|
||||
EarlyAccess = 'earlyaccess',
|
||||
EarlyAccessRenew = 'earlyaccessrenew',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionService {
|
||||
private readonly paymentConfig: Config['payment'];
|
||||
private readonly logger = new Logger(SubscriptionService.name);
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly stripe: Stripe,
|
||||
private readonly db: PrismaService,
|
||||
private readonly user: UsersService,
|
||||
private readonly scheduleManager: ScheduleManager
|
||||
) {
|
||||
this.paymentConfig = config.payment;
|
||||
|
||||
if (
|
||||
!this.paymentConfig.stripe.keys.APIKey ||
|
||||
!this.paymentConfig.stripe.keys.webhookKey /* default empty string */
|
||||
) {
|
||||
this.logger.warn('Stripe API key not set, Stripe will be disabled');
|
||||
this.logger.warn('Set STRIPE_API_KEY to enable Stripe');
|
||||
}
|
||||
}
|
||||
|
||||
async listPrices() {
|
||||
return this.stripe.prices.list();
|
||||
}
|
||||
|
||||
async createCheckoutSession({
|
||||
user,
|
||||
recurring,
|
||||
redirectUrl,
|
||||
idempotencyKey,
|
||||
plan = SubscriptionPlan.Pro,
|
||||
}: {
|
||||
user: User;
|
||||
plan?: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
redirectUrl: string;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
const currentSubscription = await this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentSubscription && currentSubscription.end < new Date()) {
|
||||
throw new Error('You already have a subscription');
|
||||
}
|
||||
|
||||
const price = await this.getPrice(plan, recurring);
|
||||
const customer = await this.getOrCreateCustomer(idempotencyKey, user);
|
||||
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
|
||||
|
||||
return await this.stripe.checkout.sessions.create(
|
||||
{
|
||||
line_items: [
|
||||
{
|
||||
price,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
...(coupon
|
||||
? {
|
||||
discounts: [{ coupon }],
|
||||
}
|
||||
: {
|
||||
allow_promotion_codes: true,
|
||||
}),
|
||||
mode: 'subscription',
|
||||
success_url: redirectUrl,
|
||||
customer: customer.stripeCustomerId,
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
name: 'auto',
|
||||
},
|
||||
},
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
idempotencyKey: string,
|
||||
userId: string
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
}
|
||||
|
||||
if (user.subscription.canceledAt) {
|
||||
throw new Error('Your subscription has already been canceled');
|
||||
}
|
||||
|
||||
// should release the schedule first
|
||||
if (user.subscription.stripeScheduleId) {
|
||||
const manager = await this.scheduleManager.fromSchedule(
|
||||
user.subscription.stripeScheduleId
|
||||
);
|
||||
await manager.cancel(idempotencyKey);
|
||||
return this.saveSubscription(
|
||||
user,
|
||||
await this.stripe.subscriptions.retrieve(
|
||||
user.subscription.stripeSubscriptionId
|
||||
),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// let customer contact support if they want to cancel immediately
|
||||
// see https://stripe.com/docs/billing/subscriptions/cancel
|
||||
const subscription = await this.stripe.subscriptions.update(
|
||||
user.subscription.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: true },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
return await this.saveSubscription(user, subscription);
|
||||
}
|
||||
}
|
||||
|
||||
async resumeCanceledSubscription(
|
||||
idempotencyKey: string,
|
||||
userId: string
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
}
|
||||
|
||||
if (!user.subscription.canceledAt) {
|
||||
throw new Error('Your subscription has not been canceled');
|
||||
}
|
||||
|
||||
if (user.subscription.end < new Date()) {
|
||||
throw new Error('Your subscription is expired, please checkout again.');
|
||||
}
|
||||
|
||||
if (user.subscription.stripeScheduleId) {
|
||||
const manager = await this.scheduleManager.fromSchedule(
|
||||
user.subscription.stripeScheduleId
|
||||
);
|
||||
await manager.resume(idempotencyKey);
|
||||
return this.saveSubscription(
|
||||
user,
|
||||
await this.stripe.subscriptions.retrieve(
|
||||
user.subscription.stripeSubscriptionId
|
||||
),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
const subscription = await this.stripe.subscriptions.update(
|
||||
user.subscription.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: false },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
|
||||
return await this.saveSubscription(user, subscription);
|
||||
}
|
||||
}
|
||||
|
||||
async updateSubscriptionRecurring(
|
||||
idempotencyKey: string,
|
||||
userId: string,
|
||||
recurring: SubscriptionRecurring
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
}
|
||||
|
||||
if (user.subscription.canceledAt) {
|
||||
throw new Error('Your subscription has already been canceled ');
|
||||
}
|
||||
|
||||
if (user.subscription.recurring === recurring) {
|
||||
throw new Error('You have already subscribed to this plan');
|
||||
}
|
||||
|
||||
const price = await this.getPrice(
|
||||
user.subscription.plan as SubscriptionPlan,
|
||||
recurring
|
||||
);
|
||||
|
||||
const manager = await this.scheduleManager.fromSubscription(
|
||||
idempotencyKey,
|
||||
user.subscription.stripeSubscriptionId
|
||||
);
|
||||
|
||||
await manager.update(
|
||||
idempotencyKey,
|
||||
price,
|
||||
// if user is early access user, use early access coupon
|
||||
manager.currentPhase?.coupon === CouponType.EarlyAccess ||
|
||||
manager.currentPhase?.coupon === CouponType.EarlyAccessRenew ||
|
||||
manager.nextPhase?.coupon === CouponType.EarlyAccessRenew
|
||||
? CouponType.EarlyAccessRenew
|
||||
: undefined
|
||||
);
|
||||
|
||||
return await this.db.userSubscription.update({
|
||||
where: {
|
||||
id: user.subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
|
||||
recurring,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createCustomerPortal(id: string) {
|
||||
const user = await this.db.userStripeCustomer.findUnique({
|
||||
where: {
|
||||
userId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Unknown user');
|
||||
}
|
||||
|
||||
try {
|
||||
const portal = await this.stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
});
|
||||
|
||||
return portal.url;
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to create customer portal.', e);
|
||||
throw new Error('Failed to create customer portal');
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('customer.subscription.created')
|
||||
@OnEvent('customer.subscription.updated')
|
||||
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
||||
const user = await this.retrieveUserFromCustomer(
|
||||
subscription.customer as string
|
||||
);
|
||||
|
||||
await this.saveSubscription(user, subscription);
|
||||
}
|
||||
|
||||
@OnEvent('customer.subscription.deleted')
|
||||
async onSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
const user = await this.retrieveUserFromCustomer(
|
||||
subscription.customer as string
|
||||
);
|
||||
|
||||
await this.db.userSubscription.deleteMany({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('invoice.paid')
|
||||
async onInvoicePaid(stripeInvoice: Stripe.Invoice) {
|
||||
await this.saveInvoice(stripeInvoice);
|
||||
|
||||
const line = stripeInvoice.lines.data[0];
|
||||
|
||||
if (!line.price || line.price.type !== 'recurring') {
|
||||
throw new Error('Unknown invoice with no recurring price');
|
||||
}
|
||||
|
||||
// deal with early access user
|
||||
if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) {
|
||||
const idempotencyKey = stripeInvoice.id + '_earlyaccess';
|
||||
const manager = await this.scheduleManager.fromSubscription(
|
||||
idempotencyKey,
|
||||
line.subscription as string
|
||||
);
|
||||
await manager.update(
|
||||
idempotencyKey,
|
||||
line.price.id,
|
||||
CouponType.EarlyAccessRenew
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('invoice.created')
|
||||
@OnEvent('invoice.finalization_failed')
|
||||
@OnEvent('invoice.payment_failed')
|
||||
async saveInvoice(stripeInvoice: Stripe.Invoice) {
|
||||
if (!stripeInvoice.customer) {
|
||||
throw new Error('Unexpected invoice with no customer');
|
||||
}
|
||||
|
||||
const user = await this.retrieveUserFromCustomer(
|
||||
typeof stripeInvoice.customer === 'string'
|
||||
? stripeInvoice.customer
|
||||
: stripeInvoice.customer.id
|
||||
);
|
||||
|
||||
const invoice = await this.db.userInvoice.findUnique({
|
||||
where: {
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
},
|
||||
});
|
||||
|
||||
const data: Partial<UserInvoice> = {
|
||||
currency: stripeInvoice.currency,
|
||||
amount: stripeInvoice.total,
|
||||
status: stripeInvoice.status ?? InvoiceStatus.Void,
|
||||
link: stripeInvoice.hosted_invoice_url,
|
||||
};
|
||||
|
||||
// handle payment error
|
||||
if (stripeInvoice.attempt_count > 1) {
|
||||
const paymentIntent = await this.stripe.paymentIntents.retrieve(
|
||||
stripeInvoice.payment_intent as string
|
||||
);
|
||||
|
||||
if (paymentIntent.last_payment_error) {
|
||||
if (paymentIntent.last_payment_error.type === 'card_error') {
|
||||
data.lastPaymentError =
|
||||
paymentIntent.last_payment_error.message ?? 'Failed to pay';
|
||||
} else {
|
||||
data.lastPaymentError = 'Internal Payment error';
|
||||
}
|
||||
}
|
||||
} else if (stripeInvoice.last_finalization_error) {
|
||||
if (stripeInvoice.last_finalization_error.type === 'card_error') {
|
||||
data.lastPaymentError =
|
||||
stripeInvoice.last_finalization_error.message ??
|
||||
'Failed to finalize invoice';
|
||||
} else {
|
||||
data.lastPaymentError = 'Internal Payment error';
|
||||
}
|
||||
}
|
||||
|
||||
// update invoice
|
||||
if (invoice) {
|
||||
await this.db.userInvoice.update({
|
||||
where: {
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
// create invoice
|
||||
const price = stripeInvoice.lines.data[0].price;
|
||||
|
||||
if (!price || price.type !== 'recurring') {
|
||||
throw new Error('Unexpected invoice with no recurring price');
|
||||
}
|
||||
|
||||
if (!price.lookup_key) {
|
||||
throw new Error('Unexpected subscription with no key');
|
||||
}
|
||||
|
||||
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
||||
|
||||
await this.db.userInvoice.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
plan,
|
||||
recurring,
|
||||
reason: stripeInvoice.billing_reason ?? 'contact support',
|
||||
...(data as any),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSubscription(
|
||||
user: User,
|
||||
subscription: Stripe.Subscription,
|
||||
fromWebhook = true
|
||||
): Promise<UserSubscription> {
|
||||
// webhook events may not in sequential order
|
||||
// always fetch the latest subscription and save
|
||||
// see https://stripe.com/docs/webhooks#behaviors
|
||||
if (fromWebhook) {
|
||||
subscription = await this.stripe.subscriptions.retrieve(subscription.id);
|
||||
}
|
||||
|
||||
// get next bill date from upcoming invoice
|
||||
// see https://stripe.com/docs/api/invoices/upcoming
|
||||
let nextBillAt: Date | null = null;
|
||||
if (
|
||||
(subscription.status === SubscriptionStatus.Active ||
|
||||
subscription.status === SubscriptionStatus.Trialing) &&
|
||||
!subscription.canceled_at
|
||||
) {
|
||||
nextBillAt = new Date(subscription.current_period_end * 1000);
|
||||
}
|
||||
|
||||
const price = subscription.items.data[0].price;
|
||||
if (!price.lookup_key) {
|
||||
throw new Error('Unexpected subscription with no key');
|
||||
}
|
||||
|
||||
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
||||
|
||||
const commonData = {
|
||||
start: new Date(subscription.current_period_start * 1000),
|
||||
end: new Date(subscription.current_period_end * 1000),
|
||||
trialStart: subscription.trial_start
|
||||
? new Date(subscription.trial_start * 1000)
|
||||
: null,
|
||||
trialEnd: subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: null,
|
||||
nextBillAt,
|
||||
canceledAt: subscription.canceled_at
|
||||
? new Date(subscription.canceled_at * 1000)
|
||||
: null,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
plan,
|
||||
recurring,
|
||||
status: subscription.status,
|
||||
stripeScheduleId: subscription.schedule as string | null,
|
||||
};
|
||||
|
||||
const currentSubscription = await this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentSubscription) {
|
||||
const update: Prisma.UserSubscriptionUpdateInput = {
|
||||
...commonData,
|
||||
};
|
||||
|
||||
// a schedule exists, update the recurring to scheduled one
|
||||
if (update.stripeScheduleId) {
|
||||
delete update.recurring;
|
||||
}
|
||||
|
||||
return await this.db.userSubscription.update({
|
||||
where: {
|
||||
id: currentSubscription.id,
|
||||
},
|
||||
data: update,
|
||||
});
|
||||
} else {
|
||||
return await this.db.userSubscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
...commonData,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateCustomer(
|
||||
idempotencyKey: string,
|
||||
user: User
|
||||
): Promise<UserStripeCustomer> {
|
||||
const customer = await this.db.userStripeCustomer.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (customer) {
|
||||
return customer;
|
||||
}
|
||||
|
||||
const stripeCustomersList = await this.stripe.customers.list({
|
||||
email: user.email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
let stripeCustomer: Stripe.Customer | undefined;
|
||||
if (stripeCustomersList.data.length) {
|
||||
stripeCustomer = stripeCustomersList.data[0];
|
||||
} else {
|
||||
stripeCustomer = await this.stripe.customers.create(
|
||||
{ email: user.email },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
|
||||
return await this.db.userStripeCustomer.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async retrieveUserFromCustomer(customerId: string) {
|
||||
const customer = await this.db.userStripeCustomer.findUnique({
|
||||
where: {
|
||||
stripeCustomerId: customerId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (customer?.user) {
|
||||
return customer.user;
|
||||
}
|
||||
|
||||
// customer may not saved is db, check it with stripe
|
||||
const stripeCustomer = await this.stripe.customers.retrieve(customerId);
|
||||
|
||||
if (stripeCustomer.deleted) {
|
||||
throw new Error('Unexpected subscription created with deleted customer');
|
||||
}
|
||||
|
||||
if (!stripeCustomer.email) {
|
||||
throw new Error('Unexpected subscription created with no email customer');
|
||||
}
|
||||
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
email: stripeCustomer.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
`Unexpected subscription created with unknown customer ${stripeCustomer.email}`
|
||||
);
|
||||
}
|
||||
|
||||
await this.db.userStripeCustomer.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async getPrice(
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring
|
||||
): Promise<string> {
|
||||
const prices = await this.stripe.prices.list({
|
||||
lookup_keys: [encodeLookupKey(plan, recurring)],
|
||||
});
|
||||
|
||||
if (!prices.data.length) {
|
||||
throw new Error(
|
||||
`Unknown subscription plan ${plan} with recurring ${recurring}`
|
||||
);
|
||||
}
|
||||
|
||||
return prices.data[0].id;
|
||||
}
|
||||
|
||||
private async getAvailableCoupon(
|
||||
user: User,
|
||||
couponType: CouponType
|
||||
): Promise<string | null> {
|
||||
const earlyAccess = await this.user.isEarlyAccessUser(user.email);
|
||||
if (earlyAccess) {
|
||||
try {
|
||||
const coupon = await this.stripe.coupons.retrieve(couponType);
|
||||
return coupon.valid ? coupon.id : null;
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to get early access coupon', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
18
packages/backend/server/src/modules/payment/stripe.ts
Normal file
18
packages/backend/server/src/modules/payment/stripe.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
import { omit } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config } from '../../config';
|
||||
|
||||
export const StripeProvider: FactoryProvider = {
|
||||
provide: Stripe,
|
||||
useFactory: (config: Config) => {
|
||||
const stripeConfig = config.payment.stripe;
|
||||
|
||||
return new Stripe(
|
||||
stripeConfig.keys.APIKey,
|
||||
omit(config.payment.stripe, 'keys', 'prices')
|
||||
);
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
64
packages/backend/server/src/modules/payment/webhook.ts
Normal file
64
packages/backend/server/src/modules/payment/webhook.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { RawBodyRequest } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Logger,
|
||||
NotAcceptableException,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import type { Request } from 'express';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config } from '../../config';
|
||||
|
||||
@Controller('/api/stripe')
|
||||
export class StripeWebhook {
|
||||
private readonly config: Config['payment'];
|
||||
private readonly logger = new Logger(StripeWebhook.name);
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly stripe: Stripe,
|
||||
private readonly event: EventEmitter2
|
||||
) {
|
||||
this.config = config.payment;
|
||||
}
|
||||
|
||||
@Post('/webhook')
|
||||
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
|
||||
// Check if webhook signing is configured.
|
||||
if (!this.config.stripe.keys.webhookKey) {
|
||||
this.logger.error(
|
||||
'Stripe Webhook key is not set, but a webhook was received.'
|
||||
);
|
||||
throw new NotAcceptableException();
|
||||
}
|
||||
|
||||
// Retrieve the event by verifying the signature using the raw body and secret.
|
||||
const signature = req.headers['stripe-signature'];
|
||||
try {
|
||||
const event = this.stripe.webhooks.constructEvent(
|
||||
req.rawBody ?? '',
|
||||
signature ?? '',
|
||||
this.config.stripe.keys.webhookKey
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`[${event.id}] Stripe Webhook {${event.type}} received.`
|
||||
);
|
||||
|
||||
// Stripe requires responseing webhook immediately and handle event asynchronously.
|
||||
setImmediate(() => {
|
||||
// handle duplicated events?
|
||||
// see https://stripe.com/docs/webhooks#handle-duplicate-events
|
||||
this.event.emitAsync(event.type, event.data.object).catch(e => {
|
||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error('Stripe Webhook error', err);
|
||||
throw new NotAcceptableException();
|
||||
}
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import { UsersService } from './users';
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
providers: [UserResolver, UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
|
@ -21,7 +21,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
||||
import type { FileUpload } from '../../types';
|
||||
import { Auth, CurrentUser, Public } from '../auth/guard';
|
||||
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { NewFeaturesKind } from './types';
|
||||
import { UsersService } from './users';
|
||||
@ -97,11 +97,17 @@ export class UserResolver {
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Publicable()
|
||||
@Query(() => UserType, {
|
||||
name: 'currentUser',
|
||||
description: 'Get current user',
|
||||
nullable: true,
|
||||
})
|
||||
async currentUser(@CurrentUser() user: UserType) {
|
||||
async currentUser(@CurrentUser() user?: UserType) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedUser = await this.users.findUserById(user.id);
|
||||
if (!storedUser) {
|
||||
throw new BadRequestException(`User ${user.id} not found in db`);
|
||||
|
@ -15,16 +15,21 @@ export class UsersService {
|
||||
|
||||
async canEarlyAccess(email: string) {
|
||||
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
|
||||
return this.prisma.newFeaturesWaitingList
|
||||
.findUnique({
|
||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||
})
|
||||
.catch(() => false);
|
||||
return this.isEarlyAccessUser(email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async isEarlyAccessUser(email: string) {
|
||||
return this.prisma.newFeaturesWaitingList
|
||||
.count({
|
||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||
})
|
||||
.then(count => count > 0)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async getStorageQuotaById(id: string) {
|
||||
const features = await this.prisma.user
|
||||
.findUnique({
|
||||
|
@ -23,6 +23,8 @@ type UserType {
|
||||
"""User password has been set"""
|
||||
hasPassword: Boolean
|
||||
token: TokenType!
|
||||
subscription: UserSubscription
|
||||
invoices(take: Int = 8, skip: Int): [UserInvoice!]!
|
||||
}
|
||||
|
||||
"""
|
||||
@ -55,6 +57,74 @@ type TokenType {
|
||||
sessionToken: String
|
||||
}
|
||||
|
||||
type SubscriptionPrice {
|
||||
type: String!
|
||||
plan: SubscriptionPlan!
|
||||
currency: String!
|
||||
amount: Int!
|
||||
yearlyAmount: Int!
|
||||
}
|
||||
|
||||
enum SubscriptionPlan {
|
||||
Free
|
||||
Pro
|
||||
Team
|
||||
Enterprise
|
||||
}
|
||||
|
||||
type UserSubscription {
|
||||
id: String!
|
||||
plan: SubscriptionPlan!
|
||||
recurring: SubscriptionRecurring!
|
||||
status: SubscriptionStatus!
|
||||
start: DateTime!
|
||||
end: DateTime!
|
||||
trialStart: DateTime
|
||||
trialEnd: DateTime
|
||||
nextBillAt: DateTime
|
||||
canceledAt: DateTime
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum SubscriptionRecurring {
|
||||
Monthly
|
||||
Yearly
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
Active
|
||||
PastDue
|
||||
Unpaid
|
||||
Canceled
|
||||
Incomplete
|
||||
Paused
|
||||
IncompleteExpired
|
||||
Trialing
|
||||
}
|
||||
|
||||
type UserInvoice {
|
||||
id: String!
|
||||
plan: SubscriptionPlan!
|
||||
recurring: SubscriptionRecurring!
|
||||
currency: String!
|
||||
amount: Int!
|
||||
status: InvoiceStatus!
|
||||
reason: String!
|
||||
lastPaymentError: String
|
||||
link: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
Draft
|
||||
Open
|
||||
Void
|
||||
Paid
|
||||
Uncollectible
|
||||
}
|
||||
|
||||
type InviteUserType {
|
||||
"""User name"""
|
||||
name: String
|
||||
@ -166,10 +236,11 @@ type Query {
|
||||
checkBlobSize(workspaceId: String!, size: Float!): WorkspaceBlobSizes!
|
||||
|
||||
"""Get current user"""
|
||||
currentUser: UserType!
|
||||
currentUser: UserType
|
||||
|
||||
"""Get user by email"""
|
||||
user(email: String!): UserType
|
||||
prices: [SubscriptionPrice!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@ -205,6 +276,15 @@ type Mutation {
|
||||
removeAvatar: RemoveAvatar!
|
||||
deleteAccount: DeleteAccount!
|
||||
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
|
||||
|
||||
"""Create a subscription checkout link of stripe"""
|
||||
checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String!
|
||||
|
||||
"""Create a stripe customer portal to manage payment methods"""
|
||||
createCustomerPortal: String!
|
||||
cancelSubscription(idempotencyKey: String!): UserSubscription!
|
||||
resumeSubscription(idempotencyKey: String!): UserSubscription!
|
||||
updateSubscriptionRecurring(recurring: SubscriptionRecurring!, idempotencyKey: String!): UserSubscription!
|
||||
}
|
||||
|
||||
"""The `Upload` scalar type represents a file upload."""
|
||||
|
@ -67,6 +67,6 @@ test('should be able to delete user', async t => {
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
await t.throwsAsync(() => currentUser(app, user.token.token));
|
||||
t.is(await currentUser(app, user.token.token), null);
|
||||
t.pass();
|
||||
});
|
||||
|
1
packages/common/env/src/global.ts
vendored
1
packages/common/env/src/global.ts
vendored
@ -30,6 +30,7 @@ export const runtimeFlagsSchema = z.object({
|
||||
enableCloud: z.boolean(),
|
||||
enableCaptcha: z.boolean(),
|
||||
enableEnhanceShareMode: z.boolean(),
|
||||
enablePayment: z.boolean(),
|
||||
// this is for the electron app
|
||||
serverUrlPrefix: z.string(),
|
||||
enableMoveDatabase: z.boolean(),
|
||||
|
@ -42,6 +42,7 @@
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"@toeverything/theme": "^0.7.20",
|
||||
"@vanilla-extract/dynamic": "^2.0.3",
|
||||
"bytes": "^3.1.2",
|
||||
"check-password-strength": "^2.0.7",
|
||||
"clsx": "^2.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
@ -74,6 +75,7 @@
|
||||
"@storybook/jest": "^0.2.3",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/bytes": "^3.1.3",
|
||||
"@types/react": "^18.2.28",
|
||||
"@types/react-datepicker": "^4.19.0",
|
||||
"@types/react-dnd": "^3.0.2",
|
||||
|
@ -11,6 +11,7 @@ export type SettingRowProps = PropsWithChildren<{
|
||||
spreadCol?: boolean;
|
||||
'data-testid'?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
export const SettingRow = ({
|
||||
@ -21,14 +22,19 @@ export const SettingRow = ({
|
||||
style,
|
||||
spreadCol = true,
|
||||
disabled = false,
|
||||
className,
|
||||
...props
|
||||
}: PropsWithChildren<SettingRowProps>) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(settingRow, {
|
||||
'two-col': spreadCol,
|
||||
disabled,
|
||||
})}
|
||||
className={clsx(
|
||||
settingRow,
|
||||
{
|
||||
'two-col': spreadCol,
|
||||
disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
data-testid={props['data-testid']}
|
||||
|
@ -86,7 +86,6 @@ globalStyle(`${settingRow} .desc`, {
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
globalStyle(`${settingRow} .right-col`, {
|
||||
width: '250px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
paddingLeft: '15px',
|
||||
@ -116,31 +115,19 @@ globalStyle(`${storageProgressWrapper} .storage-progress-desc`, {
|
||||
globalStyle(`${storageProgressWrapper} .storage-progress-bar-wrapper`, {
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--affine-pure-black-10)',
|
||||
backgroundColor: 'var(--affine-black-10)',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const storageProgressBar = style({
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--affine-processing-color)',
|
||||
selectors: {
|
||||
'&.warning': {
|
||||
// Wait for design
|
||||
backgroundColor: '#FF7C09',
|
||||
},
|
||||
'&.danger': {
|
||||
backgroundColor: 'var(--affine-error-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const storageExtendHint = style({
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
lineHeight: '20px',
|
||||
marginTop: 8,
|
||||
});
|
||||
globalStyle(`${storageExtendHint} a`, {
|
||||
color: 'var(--affine-link-color)',
|
||||
|
||||
export const storageButton = style({
|
||||
padding: '4px 12px',
|
||||
});
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { SubscriptionPlan } from '@affine/graphql';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import bytes from 'bytes';
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@ -10,20 +12,19 @@ export interface StorageProgressProgress {
|
||||
max: number;
|
||||
value: number;
|
||||
onUpgrade: () => void;
|
||||
plan: SubscriptionPlan;
|
||||
}
|
||||
|
||||
const transformBytesToMB = (bytes: number) => {
|
||||
return (bytes / 1024 / 1024).toFixed(2);
|
||||
};
|
||||
|
||||
const transformBytesToGB = (bytes: number) => {
|
||||
return (bytes / 1024 / 1024 / 1024).toFixed(2);
|
||||
};
|
||||
enum ButtonType {
|
||||
Primary = 'primary',
|
||||
Default = 'default',
|
||||
}
|
||||
|
||||
export const StorageProgress = ({
|
||||
max: upperLimit,
|
||||
value,
|
||||
onUpgrade,
|
||||
plan,
|
||||
}: StorageProgressProgress) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const percent = useMemo(
|
||||
@ -31,51 +32,53 @@ export const StorageProgress = ({
|
||||
[upperLimit, value]
|
||||
);
|
||||
|
||||
const used = useMemo(() => transformBytesToMB(value), [value]);
|
||||
const max = useMemo(() => transformBytesToGB(upperLimit), [upperLimit]);
|
||||
const used = useMemo(() => bytes.format(value), [value]);
|
||||
const max = useMemo(() => bytes.format(upperLimit), [upperLimit]);
|
||||
|
||||
const buttonType = useMemo(() => {
|
||||
if (plan === SubscriptionPlan.Free) {
|
||||
return ButtonType.Primary;
|
||||
}
|
||||
return ButtonType.Default;
|
||||
}, [plan]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.storageProgressContainer}>
|
||||
<div className={styles.storageProgressWrapper}>
|
||||
<div className="storage-progress-desc">
|
||||
<span>{t['com.affine.storage.used.hint']()}</span>
|
||||
<span>
|
||||
{used}MB/{max}GB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="storage-progress-bar-wrapper">
|
||||
<div
|
||||
className={clsx(styles.storageProgressBar, {
|
||||
warning: percent > 80,
|
||||
danger: percent > 99,
|
||||
})}
|
||||
style={{ width: `${percent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip content={t['com.affine.storage.disabled.hint']()}>
|
||||
<span tabIndex={0}>
|
||||
<Button disabled onClick={onUpgrade}>
|
||||
{t['com.affine.storage.upgrade']()}
|
||||
</Button>
|
||||
<div className={styles.storageProgressContainer}>
|
||||
<div className={styles.storageProgressWrapper}>
|
||||
<div className="storage-progress-desc">
|
||||
<span>{t['com.affine.storage.used.hint']()}</span>
|
||||
<span>
|
||||
{used}/{max}
|
||||
{` (${plan} ${t['com.affine.storage.plan']()})`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{percent > 80 ? (
|
||||
<div className={styles.storageExtendHint}>
|
||||
{t['com.affine.storage.extend.hint']()}
|
||||
<a
|
||||
href="https://community.affine.pro/c/insider-general/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t['com.affine.storage.extend.link']()}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
<div className="storage-progress-bar-wrapper">
|
||||
<div
|
||||
className={clsx(styles.storageProgressBar, {
|
||||
danger: percent > 80,
|
||||
})}
|
||||
style={{ width: `${percent > 100 ? '100' : percent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
options={{ hidden: percent < 100 }}
|
||||
content={t['com.affine.storage.maximum-tips']()}
|
||||
>
|
||||
<span tabIndex={0}>
|
||||
<Button
|
||||
type={buttonType}
|
||||
onClick={onUpgrade}
|
||||
className={styles.storageButton}
|
||||
>
|
||||
{plan === 'Free'
|
||||
? t['com.affine.storage.upgrade']()
|
||||
: t['com.affine.storage.change-plan']()}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -31,6 +31,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
enableCloud: true,
|
||||
enableCaptcha: true,
|
||||
enableEnhanceShareMode: false,
|
||||
enablePayment: true,
|
||||
serverUrlPrefix: 'https://app.affine.pro',
|
||||
editorFlags,
|
||||
appVersion: packageJson.version,
|
||||
@ -65,6 +66,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
enableCloud: true,
|
||||
enableCaptcha: true,
|
||||
enableEnhanceShareMode: false,
|
||||
enablePayment: true,
|
||||
serverUrlPrefix: 'https://affine.fail',
|
||||
editorFlags,
|
||||
appVersion: packageJson.version,
|
||||
@ -120,6 +122,11 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
enableMoveDatabase: process.env.ENABLE_MOVE_DATABASE
|
||||
? process.env.ENABLE_MOVE_DATABASE === 'true'
|
||||
: currentBuildPreset.enableMoveDatabase,
|
||||
enablePayment: process.env.ENABLE_PAYMENT
|
||||
? process.env.ENABLE_PAYMENT !== 'false'
|
||||
: buildFlags.mode === 'development'
|
||||
? true
|
||||
: currentBuildPreset.enablePayment,
|
||||
};
|
||||
|
||||
if (buildFlags.mode === 'development') {
|
||||
|
@ -41,8 +41,9 @@
|
||||
"@mui/material": "^5.14.14",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@react-hookz/web": "^23.1.0",
|
||||
"@toeverything/components": "^0.0.45",
|
||||
"@toeverything/components": "^0.0.46",
|
||||
"async-call-rpc": "^6.3.1",
|
||||
"bytes": "^3.1.2",
|
||||
"css-spring": "^4.1.0",
|
||||
"cssnano": "^6.0.1",
|
||||
"graphql": "^16.8.1",
|
||||
@ -75,6 +76,7 @@
|
||||
"@sentry/webpack-plugin": "^2.8.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.3.93",
|
||||
"@types/bytes": "^3.1.3",
|
||||
"@types/lodash-es": "^4.17.9",
|
||||
"@types/webpack-env": "^1.18.2",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
|
@ -12,6 +12,7 @@ export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
||||
export const openQuickSearchModalAtom = atom(false);
|
||||
export const openOnboardingModalAtom = atom(false);
|
||||
export const openSignOutModalAtom = atom(false);
|
||||
export const openPaymentDisableAtom = atom(false);
|
||||
|
||||
export type SettingAtom = Pick<SettingProps, 'activeTab' | 'workspaceId'> & {
|
||||
open: boolean;
|
||||
|
@ -62,3 +62,17 @@ export const accessMessage = style({
|
||||
marginTop: 65,
|
||||
marginBottom: 40,
|
||||
});
|
||||
|
||||
export const userPlanButton = style({
|
||||
display: 'flex',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
height: 20,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--affine-pure-white)',
|
||||
backgroundColor: 'var(--affine-brand-color)',
|
||||
padding: '0 4px',
|
||||
borderRadius: 4,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
@ -0,0 +1,37 @@
|
||||
import { SubscriptionPlan } from '@affine/graphql';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import Tooltip from '@toeverything/components/tooltip';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { openSettingModalAtom } from '../../../atoms';
|
||||
import { useUserSubscription } from '../../../hooks/use-subscription';
|
||||
import * as styles from './style.css';
|
||||
|
||||
export const UserPlanButton = () => {
|
||||
const [subscription] = useUserSubscription();
|
||||
const plan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
|
||||
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setSettingModalAtom({
|
||||
open: true,
|
||||
activeTab: 'plans',
|
||||
workspaceId: null,
|
||||
});
|
||||
},
|
||||
[setSettingModalAtom]
|
||||
);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
|
||||
<div className={styles.userPlanButton} onClick={handleClick}>
|
||||
{plan}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
@ -10,9 +10,9 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { Permission } from '@affine/graphql';
|
||||
import { Permission, SubscriptionPlan } from '@affine/graphql';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons';
|
||||
import { ArrowRightBigIcon, MoreVerticalIcon } from '@blocksuite/icons';
|
||||
import { Avatar } from '@toeverything/components/avatar';
|
||||
import { Button, IconButton } from '@toeverything/components/button';
|
||||
import { Loading } from '@toeverything/components/loading';
|
||||
@ -31,16 +31,24 @@ import {
|
||||
} from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { openSettingModalAtom } from '../../../atoms';
|
||||
import type { CheckedUser } from '../../../hooks/affine/use-current-user';
|
||||
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
|
||||
import { useInviteMember } from '../../../hooks/affine/use-invite-member';
|
||||
import { useMemberCount } from '../../../hooks/affine/use-member-count';
|
||||
import { type Member, useMembers } from '../../../hooks/affine/use-members';
|
||||
import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission';
|
||||
import { useUserSubscription } from '../../../hooks/use-subscription';
|
||||
import { AnyErrorBoundary } from '../any-error-boundary';
|
||||
import * as style from './style.css';
|
||||
import type { WorkspaceSettingDetailProps } from './types';
|
||||
|
||||
enum MemberLimitCount {
|
||||
Free = '3',
|
||||
Pro = '10',
|
||||
Other = '?',
|
||||
}
|
||||
|
||||
const COUNT_PER_PAGE = 8;
|
||||
export interface MembersPanelProps extends WorkspaceSettingDetailProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
@ -130,11 +138,47 @@ export const CloudWorkspaceMembersPanel = ({
|
||||
[pushNotification, revokeMemberPermission, t]
|
||||
);
|
||||
|
||||
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||
const handleUpgrade = useCallback(() => {
|
||||
setSettingModalAtom({
|
||||
open: true,
|
||||
activeTab: 'plans',
|
||||
workspaceId: null,
|
||||
});
|
||||
}, [setSettingModalAtom]);
|
||||
|
||||
const [subscription] = useUserSubscription();
|
||||
const plan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const memberLimit = useMemo(() => {
|
||||
if (plan === SubscriptionPlan.Free) {
|
||||
return MemberLimitCount.Free;
|
||||
}
|
||||
if (plan === SubscriptionPlan.Pro) {
|
||||
return MemberLimitCount.Pro;
|
||||
}
|
||||
return MemberLimitCount.Other;
|
||||
}, [plan]);
|
||||
const desc = useMemo(() => {
|
||||
return (
|
||||
<span>
|
||||
{t['com.affine.payment.member.description']({
|
||||
planName: plan,
|
||||
memberLimit,
|
||||
})}
|
||||
,
|
||||
<div className={style.goUpgradeWrapper} onClick={handleUpgrade}>
|
||||
<span className={style.goUpgrade}>go upgrade</span>
|
||||
<ArrowRightBigIcon className={style.arrowRight} />
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}, [handleUpgrade, memberLimit, plan, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
name={`${t['Members']()} (${memberCount})`}
|
||||
desc={t['Members hint']()}
|
||||
desc={desc}
|
||||
spreadCol={isOwner}
|
||||
>
|
||||
{isOwner ? (
|
||||
|
@ -182,3 +182,22 @@ export const workspaceLabel = style({
|
||||
lineHeight: '20px',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const goUpgrade = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-emphasis-color)',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '4px',
|
||||
display: 'inline',
|
||||
});
|
||||
|
||||
export const goUpgradeWrapper = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const arrowRight = style({
|
||||
fontSize: '16px',
|
||||
color: 'var(--affine-text-emphasis-color)',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
@ -0,0 +1,35 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ConfirmModal } from '@toeverything/components/modal';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { openPaymentDisableAtom } from '../../../atoms';
|
||||
import * as styles from './style.css';
|
||||
|
||||
export const PaymentDisableModal = () => {
|
||||
const [open, setOpen] = useAtom(openPaymentDisableAtom);
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const onClickCancel = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t['com.affine.payment.disable-payment.title']()}
|
||||
cancelText=""
|
||||
cancelButtonOptions={{ style: { display: 'none' } }}
|
||||
confirmButtonOptions={{
|
||||
type: 'primary',
|
||||
children: t['Got it'](),
|
||||
}}
|
||||
onConfirm={onClickCancel}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<p className={styles.paymentDisableModalContent}>
|
||||
{t['com.affine.payment.disable-payment.description']()}
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const paymentDisableModalContent = style({
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
allBlobSizesQuery,
|
||||
removeAvatarMutation,
|
||||
SubscriptionPlan,
|
||||
uploadAvatarMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@ -14,17 +15,24 @@ import { useMutation, useQuery } from '@affine/workspace/affine/gql';
|
||||
import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons';
|
||||
import { Avatar } from '@toeverything/components/avatar';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import bytes from 'bytes';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import {
|
||||
type FC,
|
||||
type MouseEvent,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { authAtom, openSignOutModalAtom } from '../../../../atoms';
|
||||
import {
|
||||
authAtom,
|
||||
openSettingModalAtom,
|
||||
openSignOutModalAtom,
|
||||
} from '../../../../atoms';
|
||||
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
|
||||
import { useUserSubscription } from '../../../../hooks/use-subscription';
|
||||
import { Upload } from '../../../pure/file-upload';
|
||||
import * as style from './style.css';
|
||||
|
||||
@ -124,6 +132,7 @@ export const AvatarAndName = () => {
|
||||
<Button
|
||||
data-testid="save-user-name"
|
||||
onClick={handleUpdateUserName}
|
||||
className={style.button}
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
}}
|
||||
@ -146,7 +155,21 @@ const StoragePanel = () => {
|
||||
query: allBlobSizesQuery,
|
||||
});
|
||||
|
||||
const onUpgrade = useCallback(() => {}, []);
|
||||
const [subscription] = useUserSubscription();
|
||||
const plan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
|
||||
const maxLimit = useMemo(() => {
|
||||
return bytes.parse(plan === SubscriptionPlan.Free ? '10GB' : '100GB');
|
||||
}, [plan]);
|
||||
|
||||
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||
const onUpgrade = useCallback(() => {
|
||||
setSettingModalAtom({
|
||||
open: true,
|
||||
activeTab: 'plans',
|
||||
workspaceId: null,
|
||||
});
|
||||
}, [setSettingModalAtom]);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
@ -155,7 +178,8 @@ const StoragePanel = () => {
|
||||
spreadCol={false}
|
||||
>
|
||||
<StorageProgress
|
||||
max={10737418240}
|
||||
max={maxLimit}
|
||||
plan={plan}
|
||||
value={data.collectAllBlobSizes.size}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
@ -200,7 +224,7 @@ export const AccountSetting: FC = () => {
|
||||
/>
|
||||
<AvatarAndName />
|
||||
<SettingRow name={t['com.affine.settings.email']()} desc={user.email}>
|
||||
<Button onClick={onChangeEmail}>
|
||||
<Button onClick={onChangeEmail} className={style.button}>
|
||||
{t['com.affine.settings.email.action']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
@ -208,7 +232,7 @@ export const AccountSetting: FC = () => {
|
||||
name={t['com.affine.settings.password']()}
|
||||
desc={t['com.affine.settings.password.message']()}
|
||||
>
|
||||
<Button onClick={onPasswordButtonClick}>
|
||||
<Button onClick={onPasswordButtonClick} className={style.button}>
|
||||
{user.hasPassword
|
||||
? t['com.affine.settings.password.action.change']()
|
||||
: t['com.affine.settings.password.action.set']()}
|
||||
|
@ -39,3 +39,7 @@ globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
|
||||
color: 'var(--affine-white)',
|
||||
fontSize: 'var(--affine-font-h-4)',
|
||||
});
|
||||
|
||||
export const button = style({
|
||||
padding: '4px 12px',
|
||||
});
|
||||
|
@ -0,0 +1,392 @@
|
||||
import {
|
||||
SettingHeader,
|
||||
SettingRow,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import {
|
||||
cancelSubscriptionMutation,
|
||||
createCustomerPortalMutation,
|
||||
type InvoicesQuery,
|
||||
invoicesQuery,
|
||||
InvoiceStatus,
|
||||
pricesQuery,
|
||||
resumeSubscriptionMutation,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons';
|
||||
import { Button, IconButton } from '@toeverything/components/button';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { openSettingModalAtom } from '../../../../../atoms';
|
||||
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
|
||||
import {
|
||||
type SubscriptionMutator,
|
||||
useUserSubscription,
|
||||
} from '../../../../../hooks/use-subscription';
|
||||
import { DowngradeModal } from '../plans/modals';
|
||||
import * as styles from './style.css';
|
||||
|
||||
enum DescriptionI18NKey {
|
||||
Basic = 'com.affine.payment.billing-setting.current-plan.description',
|
||||
Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly',
|
||||
Yearly = 'com.affine.payment.billing-setting.current-plan.description.yearly',
|
||||
}
|
||||
|
||||
const getMessageKey = (
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring
|
||||
): DescriptionI18NKey => {
|
||||
if (plan !== SubscriptionPlan.Pro) {
|
||||
return DescriptionI18NKey.Basic;
|
||||
}
|
||||
return DescriptionI18NKey[recurring];
|
||||
};
|
||||
|
||||
export const BillingSettings = () => {
|
||||
const status = useCurrentLoginStatus();
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
if (status !== 'authenticated') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t['com.affine.payment.billing-setting.title']()}
|
||||
subtitle={t['com.affine.payment.billing-setting.subtitle']()}
|
||||
/>
|
||||
{/* TODO: loading fallback */}
|
||||
<Suspense>
|
||||
<SettingWrapper
|
||||
title={t['com.affine.payment.billing-setting.information']()}
|
||||
>
|
||||
<SubscriptionSettings />
|
||||
</SettingWrapper>
|
||||
</Suspense>
|
||||
{/* TODO: loading fallback */}
|
||||
<Suspense>
|
||||
<SettingWrapper
|
||||
title={t['com.affine.payment.billing-setting.history']()}
|
||||
>
|
||||
<BillingHistory />
|
||||
</SettingWrapper>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SubscriptionSettings = () => {
|
||||
const [subscription, mutateSubscription] = useUserSubscription();
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: cancelSubscriptionMutation,
|
||||
});
|
||||
const [openCancelModal, setOpenCancelModal] = useState(false);
|
||||
|
||||
// allow replay request on network error until component unmount
|
||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
trigger(
|
||||
{ idempotencyKey },
|
||||
{
|
||||
onSuccess: data => {
|
||||
mutateSubscription(data.cancelSubscription);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [trigger, idempotencyKey, mutateSubscription]);
|
||||
|
||||
const { data: pricesQueryResult } = useQuery({
|
||||
query: pricesQuery,
|
||||
});
|
||||
|
||||
const plan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly;
|
||||
|
||||
const price = pricesQueryResult.prices.find(price => price.plan === plan);
|
||||
const amount =
|
||||
plan === SubscriptionPlan.Free
|
||||
? '0'
|
||||
: price
|
||||
? recurring === SubscriptionRecurring.Monthly
|
||||
? String(price.amount / 100)
|
||||
: String(price.yearlyAmount / 100)
|
||||
: '?';
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||
|
||||
const gotoPlansSetting = useCallback(() => {
|
||||
setOpenSettingModalAtom({
|
||||
open: true,
|
||||
activeTab: 'plans',
|
||||
workspaceId: null,
|
||||
});
|
||||
}, [setOpenSettingModalAtom]);
|
||||
|
||||
const currentPlanDesc = useMemo(() => {
|
||||
const messageKey = getMessageKey(plan, recurring);
|
||||
return (
|
||||
<Trans
|
||||
i18nKey={messageKey}
|
||||
values={{
|
||||
planName: plan,
|
||||
}}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
onClick={gotoPlansSetting}
|
||||
className={styles.currentPlanName}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, [plan, recurring, gotoPlansSetting]);
|
||||
|
||||
return (
|
||||
<div className={styles.subscription}>
|
||||
<div className={styles.planCard}>
|
||||
<div className={styles.currentPlan}>
|
||||
<SettingRow
|
||||
spreadCol={false}
|
||||
name={t['com.affine.payment.billing-setting.current-plan']()}
|
||||
desc={currentPlanDesc}
|
||||
/>
|
||||
<PlanAction plan={plan} gotoPlansSetting={gotoPlansSetting} />
|
||||
</div>
|
||||
<p className={styles.planPrice}>
|
||||
${amount}
|
||||
<span className={styles.billingFrequency}>
|
||||
/
|
||||
{recurring === SubscriptionRecurring.Monthly
|
||||
? t['com.affine.payment.billing-setting.month']()
|
||||
: t['com.affine.payment.billing-setting.year']()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{subscription?.status === SubscriptionStatus.Active && (
|
||||
<>
|
||||
<SettingRow
|
||||
className={styles.paymentMethod}
|
||||
name={t['com.affine.payment.billing-setting.payment-method']()}
|
||||
desc={t[
|
||||
'com.affine.payment.billing-setting.payment-method.description'
|
||||
]()}
|
||||
>
|
||||
<PaymentMethodUpdater />
|
||||
</SettingRow>
|
||||
{subscription.nextBillAt && (
|
||||
<SettingRow
|
||||
name={t['com.affine.payment.billing-setting.renew-date']()}
|
||||
desc={t[
|
||||
'com.affine.payment.billing-setting.renew-date.description'
|
||||
]({
|
||||
renewDate: new Date(
|
||||
subscription.nextBillAt
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{subscription.canceledAt ? (
|
||||
<SettingRow
|
||||
name={t['com.affine.payment.billing-setting.expiration-date']()}
|
||||
desc={t[
|
||||
'com.affine.payment.billing-setting.expiration-date.description'
|
||||
]({
|
||||
expirationDate: new Date(subscription.end).toLocaleDateString(),
|
||||
})}
|
||||
>
|
||||
<ResumeSubscription onSubscriptionUpdate={mutateSubscription} />
|
||||
</SettingRow>
|
||||
) : (
|
||||
<SettingRow
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => (isMutating ? null : setOpenCancelModal(true))}
|
||||
className="dangerous-setting"
|
||||
name={t[
|
||||
'com.affine.payment.billing-setting.cancel-subscription'
|
||||
]()}
|
||||
desc={t[
|
||||
'com.affine.payment.billing-setting.cancel-subscription.description'
|
||||
]({
|
||||
cancelDate: new Date(subscription.end).toLocaleDateString(),
|
||||
})}
|
||||
>
|
||||
<CancelSubscription loading={isMutating} />
|
||||
</SettingRow>
|
||||
)}
|
||||
<DowngradeModal
|
||||
open={openCancelModal}
|
||||
onCancel={() => (isMutating ? null : cancel())}
|
||||
onOpenChange={setOpenCancelModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PlanAction = ({
|
||||
plan,
|
||||
gotoPlansSetting,
|
||||
}: {
|
||||
plan: string;
|
||||
gotoPlansSetting: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={gotoPlansSetting}
|
||||
>
|
||||
{plan === SubscriptionPlan.Free
|
||||
? t['com.affine.payment.billing-setting.upgrade']()
|
||||
: t['com.affine.payment.billing-setting.change-plan']()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const PaymentMethodUpdater = () => {
|
||||
// TODO: open stripe customer portal
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: createCustomerPortalMutation,
|
||||
});
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const update = useCallback(() => {
|
||||
trigger(null, {
|
||||
onSuccess: data => {
|
||||
window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer');
|
||||
},
|
||||
});
|
||||
}, [trigger]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={update}
|
||||
loading={isMutating}
|
||||
disabled={isMutating}
|
||||
>
|
||||
{t['com.affine.payment.billing-setting.upgrade']()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ResumeSubscription = ({
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: resumeSubscriptionMutation,
|
||||
});
|
||||
|
||||
// allow replay request on network error until component unmount
|
||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
trigger(
|
||||
{ idempotencyKey },
|
||||
{
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.resumeSubscription);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [trigger, idempotencyKey, onSubscriptionUpdate]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={resume}
|
||||
loading={isMutating}
|
||||
disabled={isMutating}
|
||||
>
|
||||
{t['com.affine.payment.billing-setting.resume-subscription']()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const CancelSubscription = ({ loading }: { loading?: boolean }) => {
|
||||
return (
|
||||
<IconButton
|
||||
style={{ pointerEvents: 'none' }}
|
||||
icon={<ArrowRightSmallIcon />}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BillingHistory = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { data: invoicesQueryResult } = useQuery({
|
||||
query: invoicesQuery,
|
||||
variables: {
|
||||
skip: 0,
|
||||
take: 12,
|
||||
},
|
||||
});
|
||||
|
||||
const invoices = invoicesQueryResult.currentUser?.invoices ?? [];
|
||||
|
||||
return (
|
||||
<div className={styles.billingHistory}>
|
||||
{invoices.length === 0 ? (
|
||||
<p className={styles.noInvoice}>
|
||||
{t['com.affine.payment.billing-setting.no-invoice']()}
|
||||
</p>
|
||||
) : (
|
||||
// TODO: pagination
|
||||
invoices.map(invoice => (
|
||||
<InvoiceLine key={invoice.id} invoice={invoice} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InvoiceLine = ({
|
||||
invoice,
|
||||
}: {
|
||||
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (invoice.link) {
|
||||
window.open(invoice.link, '_blank', 'noopener noreferrer');
|
||||
}
|
||||
}, [invoice.link]);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
key={invoice.id}
|
||||
name={new Date(invoice.createdAt).toLocaleDateString()}
|
||||
// TODO: currency to format: usd => $, cny => ¥
|
||||
desc={`${
|
||||
invoice.status === InvoiceStatus.Paid
|
||||
? t['com.affine.payment.billing-setting.paid']()
|
||||
: ''
|
||||
} $${invoice.amount / 100}`}
|
||||
>
|
||||
<Button className={styles.button} onClick={open}>
|
||||
{t['com.affine.payment.billing-setting.view-invoice']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
@ -0,0 +1,53 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const subscription = style({});
|
||||
|
||||
export const billingHistory = style({});
|
||||
|
||||
export const planCard = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
|
||||
export const currentPlan = style({
|
||||
flex: '1 0 0',
|
||||
});
|
||||
|
||||
export const planAction = style({
|
||||
marginTop: '8px',
|
||||
});
|
||||
|
||||
export const planPrice = style({
|
||||
fontSize: 'var(--affine-font-h-6)',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const billingFrequency = style({
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
});
|
||||
|
||||
export const paymentMethod = style({
|
||||
marginTop: '24px',
|
||||
});
|
||||
|
||||
globalStyle('.dangerous-setting .name', {
|
||||
color: 'var(--affine-error-color)',
|
||||
});
|
||||
|
||||
export const noInvoice = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
});
|
||||
|
||||
export const currentPlanName = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-emphasis-color)',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
export const button = style({
|
||||
padding: '4px 12px',
|
||||
});
|
@ -0,0 +1,41 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export const UpgradeIcon = ({ width, height }: SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
className="icon"
|
||||
width={width || 16}
|
||||
height={height || 16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1.5 8C1.5 4.41022 4.41022 1.5 8 1.5C11.5898 1.5 14.5 4.41022 14.5 8C14.5 11.5898 11.5898 14.5 8 14.5C4.41022 14.5 1.5 11.5898 1.5 8ZM8 2.5C4.96251 2.5 2.5 4.96251 2.5 8C2.5 11.0375 4.96251 13.5 8 13.5C11.0375 13.5 13.5 11.0375 13.5 8C13.5 4.96251 11.0375 2.5 8 2.5ZM4.91917 7.64645L7.64645 4.91917C7.84171 4.72391 8.15829 4.72391 8.35355 4.91917L11.0808 7.64645C11.2761 7.84171 11.2761 8.15829 11.0808 8.35355C10.8856 8.54882 10.569 8.54882 10.3737 8.35355L8.5 6.47983V11C8.5 11.2761 8.27614 11.5 8 11.5C7.72386 11.5 7.5 11.2761 7.5 11V6.47983L5.62628 8.35355C5.43102 8.54882 5.11444 8.54882 4.91917 8.35355C4.72391 8.15829 4.72391 7.84171 4.91917 7.64645Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaymentIcon = ({ width, height }: SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
className="icon"
|
||||
width={width || 16}
|
||||
height={height || 16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1.5 4.66634C1.5 3.65382 2.32081 2.83301 3.33333 2.83301H12.6667C13.6792 2.83301 14.5 3.65382 14.5 4.66634V11.333C14.5 12.3455 13.6792 13.1663 12.6667 13.1663H3.33333C2.32081 13.1663 1.5 12.3455 1.5 11.333V4.66634ZM3.33333 3.83301C2.8731 3.83301 2.5 4.2061 2.5 4.66634V5.49967H13.5V4.66634C13.5 4.2061 13.1269 3.83301 12.6667 3.83301H3.33333ZM13.5 6.49967H2.5V11.333C2.5 11.7932 2.8731 12.1663 3.33333 12.1663H12.6667C13.1269 12.1663 13.5 11.7932 13.5 11.333V6.49967ZM9.5 9.99967C9.5 9.72353 9.72386 9.49967 10 9.49967H12C12.2761 9.49967 12.5 9.72353 12.5 9.99967C12.5 10.2758 12.2761 10.4997 12 10.4997H10C9.72386 10.4997 9.5 10.2758 9.5 9.99967Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
@ -7,8 +7,12 @@ import {
|
||||
} from '@blocksuite/icons';
|
||||
import type { ReactElement, SVGProps } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
|
||||
import { AboutAffine } from './about';
|
||||
import { AppearanceSettings } from './appearance';
|
||||
import { BillingSettings } from './billing';
|
||||
import { PaymentIcon, UpgradeIcon } from './icons';
|
||||
import { AFFiNECloudPlans } from './plans';
|
||||
import { Plugins } from './plugins';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
|
||||
@ -16,7 +20,9 @@ export type GeneralSettingKeys =
|
||||
| 'shortcuts'
|
||||
| 'appearance'
|
||||
| 'plugins'
|
||||
| 'about';
|
||||
| 'about'
|
||||
| 'plans'
|
||||
| 'billing';
|
||||
|
||||
interface GeneralSettingListItem {
|
||||
key: GeneralSettingKeys;
|
||||
@ -29,8 +35,9 @@ export type GeneralSettingList = GeneralSettingListItem[];
|
||||
|
||||
export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
const t = useAFFiNEI18N();
|
||||
const status = useCurrentLoginStatus();
|
||||
|
||||
return [
|
||||
const settings: GeneralSettingListItem[] = [
|
||||
{
|
||||
key: 'appearance',
|
||||
title: t['com.affine.settings.appearance'](),
|
||||
@ -43,6 +50,13 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
icon: KeyboardIcon,
|
||||
testId: 'shortcuts-panel-trigger',
|
||||
},
|
||||
{
|
||||
key: 'plans',
|
||||
title: t['com.affine.payment.title'](),
|
||||
icon: UpgradeIcon,
|
||||
testId: 'plans-panel-trigger',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'plugins',
|
||||
title: 'Plugins',
|
||||
@ -56,6 +70,17 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
testId: 'about-panel-trigger',
|
||||
},
|
||||
];
|
||||
|
||||
if (status === 'authenticated') {
|
||||
settings.splice(3, 0, {
|
||||
key: 'billing',
|
||||
title: t['com.affine.payment.billing-setting.title'](),
|
||||
icon: PaymentIcon,
|
||||
testId: 'billing-panel-trigger',
|
||||
});
|
||||
}
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
interface GeneralSettingProps {
|
||||
@ -72,6 +97,10 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => {
|
||||
return <Plugins />;
|
||||
case 'about':
|
||||
return <AboutAffine />;
|
||||
case 'plans':
|
||||
return <AFFiNECloudPlans />;
|
||||
case 'billing':
|
||||
return <BillingSettings />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -0,0 +1,19 @@
|
||||
export function BulledListIcon({ color = 'currentColor' }: { color: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<ellipse
|
||||
cx="4.00008"
|
||||
cy="7.99984"
|
||||
rx="1.33333"
|
||||
ry="1.33333"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -0,0 +1,196 @@
|
||||
import { RadioButton, RadioButtonGroup } from '@affine/component';
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import {
|
||||
pricesQuery,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useQuery } from '@affine/workspace/affine/gql';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
|
||||
import { useUserSubscription } from '../../../../../hooks/use-subscription';
|
||||
import { PlanLayout } from './layout';
|
||||
import { type FixedPrice, getPlanDetail, PlanCard } from './plan-card';
|
||||
import { PlansSkeleton } from './skeleton';
|
||||
import * as styles from './style.css';
|
||||
|
||||
const getRecurringLabel = ({
|
||||
recurring,
|
||||
t,
|
||||
}: {
|
||||
recurring: SubscriptionRecurring;
|
||||
t: ReturnType<typeof useAFFiNEI18N>;
|
||||
}) => {
|
||||
return recurring === SubscriptionRecurring.Monthly
|
||||
? t['com.affine.payment.recurring-monthly']()
|
||||
: t['com.affine.payment.recurring-yearly']();
|
||||
};
|
||||
|
||||
const Settings = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [subscription, mutateSubscription] = useUserSubscription();
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const planDetail = getPlanDetail(t);
|
||||
const scrollWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data: { prices },
|
||||
} = useQuery({
|
||||
query: pricesQuery,
|
||||
});
|
||||
|
||||
prices.forEach(price => {
|
||||
const detail = planDetail.get(price.plan);
|
||||
|
||||
if (detail?.type === 'fixed') {
|
||||
detail.price = (price.amount / 100).toFixed(2);
|
||||
detail.yearlyPrice = (price.yearlyAmount / 100 / 12).toFixed(2);
|
||||
detail.discount = (
|
||||
(1 - price.yearlyAmount / 12 / price.amount) *
|
||||
100
|
||||
).toFixed(2);
|
||||
}
|
||||
});
|
||||
|
||||
const [recurring, setRecurring] = useState<string>(
|
||||
subscription?.recurring ?? SubscriptionRecurring.Yearly
|
||||
);
|
||||
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const isCanceled = !!subscription?.canceledAt;
|
||||
const currentRecurring =
|
||||
subscription?.recurring ?? SubscriptionRecurring.Monthly;
|
||||
|
||||
const yearlyDiscount = (
|
||||
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
|
||||
)?.discount;
|
||||
|
||||
// auto scroll to current plan card
|
||||
useEffect(() => {
|
||||
if (!scrollWrapper.current) return;
|
||||
const currentPlanCard = scrollWrapper.current?.querySelector(
|
||||
'[data-current="true"]'
|
||||
);
|
||||
const wrapperComputedStyle = getComputedStyle(scrollWrapper.current);
|
||||
const left = currentPlanCard
|
||||
? currentPlanCard.getBoundingClientRect().left -
|
||||
scrollWrapper.current.getBoundingClientRect().left -
|
||||
parseInt(wrapperComputedStyle.paddingLeft)
|
||||
: 0;
|
||||
const appeared =
|
||||
scrollWrapper.current.getAttribute('data-appeared') === 'true';
|
||||
const animationFrameId = requestAnimationFrame(() => {
|
||||
scrollWrapper.current?.scrollTo({
|
||||
behavior: appeared ? 'smooth' : 'instant',
|
||||
left,
|
||||
});
|
||||
scrollWrapper.current?.setAttribute('data-appeared', 'true');
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [recurring]);
|
||||
|
||||
const subtitle = loggedIn ? (
|
||||
isCanceled ? (
|
||||
<p>
|
||||
{t['com.affine.payment.subtitle-canceled']({
|
||||
plan: `${getRecurringLabel({
|
||||
recurring: currentRecurring,
|
||||
t,
|
||||
})} ${currentPlan}`,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<Trans
|
||||
plan={currentPlan}
|
||||
i18nKey="com.affine.payment.subtitle-active"
|
||||
values={{ currentPlan }}
|
||||
>
|
||||
You are current on the {{ currentPlan }} plan. If you have any
|
||||
questions, please contact our
|
||||
<a
|
||||
href="#"
|
||||
target="_blank"
|
||||
style={{ color: 'var(--affine-link-color)' }}
|
||||
>
|
||||
customer support
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p>{t['com.affine.payment.subtitle-not-signed-in']()}</p>
|
||||
);
|
||||
|
||||
const tabs = (
|
||||
<RadioButtonGroup
|
||||
className={styles.recurringRadioGroup}
|
||||
value={recurring}
|
||||
onValueChange={setRecurring}
|
||||
>
|
||||
{Object.values(SubscriptionRecurring).map(recurring => (
|
||||
<RadioButton key={recurring} value={recurring}>
|
||||
{getRecurringLabel({ recurring, t })}
|
||||
{recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
|
||||
<span className={styles.radioButtonDiscount}>
|
||||
{t['com.affine.payment.discount-amount']({
|
||||
amount: yearlyDiscount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</RadioButton>
|
||||
))}
|
||||
</RadioButtonGroup>
|
||||
);
|
||||
|
||||
const scroll = (
|
||||
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
|
||||
{Array.from(planDetail.values()).map(detail => {
|
||||
return (
|
||||
<PlanCard
|
||||
key={detail.plan}
|
||||
onSubscriptionUpdate={mutateSubscription}
|
||||
onNotify={({ detail, recurring }) => {
|
||||
pushNotification({
|
||||
type: 'success',
|
||||
title: t['com.affine.payment.updated-notify-title'](),
|
||||
message: t['com.affine.payment.updated-notify-msg']({
|
||||
plan:
|
||||
detail.plan === SubscriptionPlan.Free
|
||||
? SubscriptionPlan.Free
|
||||
: getRecurringLabel({
|
||||
recurring: recurring as SubscriptionRecurring,
|
||||
t,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}}
|
||||
{...{ detail, subscription, recurring }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlanLayout scrollRef={scrollWrapper} {...{ subtitle, tabs, scroll }} />
|
||||
);
|
||||
};
|
||||
|
||||
export const AFFiNECloudPlans = () => {
|
||||
return (
|
||||
// TODO: Error Boundary
|
||||
<Suspense fallback={<PlansSkeleton />}>
|
||||
<Settings />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
@ -0,0 +1,36 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const plansLayoutRoot = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
});
|
||||
|
||||
export const scrollArea = style({
|
||||
marginLeft: 'calc(-1 * var(--setting-modal-gap-x))',
|
||||
paddingLeft: 'var(--setting-modal-gap-x)',
|
||||
width: 'var(--setting-modal-width)',
|
||||
overflowX: 'auto',
|
||||
scrollSnapType: 'x mandatory',
|
||||
paddingBottom: '21px',
|
||||
|
||||
'::-webkit-scrollbar': {
|
||||
display: 'block',
|
||||
height: '5px',
|
||||
background: 'transparent',
|
||||
},
|
||||
'::-webkit-scrollbar-thumb': {
|
||||
background: 'var(--affine-icon-secondary)',
|
||||
borderRadius: '5px',
|
||||
},
|
||||
});
|
||||
|
||||
export const allPlansLink = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
color: 'var(--affine-link-color)',
|
||||
background: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
});
|
@ -0,0 +1,58 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowRightBigIcon } from '@blocksuite/icons';
|
||||
import type { HtmlHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import * as styles from './layout.css';
|
||||
|
||||
export interface PlanLayoutProps
|
||||
extends Omit<HtmlHTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
title?: ReactNode;
|
||||
subtitle: ReactNode;
|
||||
tabs: ReactNode;
|
||||
scroll: ReactNode;
|
||||
footer?: ReactNode;
|
||||
scrollRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const SeeAllLink = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<a
|
||||
className={styles.allPlansLink}
|
||||
href="https://affine.pro/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t['com.affine.payment.see-all-plans']()}
|
||||
{<ArrowRightBigIcon width="16" height="16" />}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const PlanLayout = ({
|
||||
subtitle,
|
||||
tabs,
|
||||
scroll,
|
||||
title,
|
||||
footer = <SeeAllLink />,
|
||||
scrollRef,
|
||||
}: PlanLayoutProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.plansLayoutRoot}>
|
||||
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
||||
<SettingHeader
|
||||
style={{ marginBottom: '0px' }}
|
||||
title={title ?? t['com.affine.payment.title']()}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
{tabs}
|
||||
<div ref={scrollRef} className={styles.scrollArea}>
|
||||
{scroll}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,119 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { DialogTrigger } from '@radix-ui/react-dialog';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import {
|
||||
ConfirmModal,
|
||||
type ConfirmModalProps,
|
||||
Modal,
|
||||
} from '@toeverything/components/modal';
|
||||
import { type ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import * as styles from './style.css';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export const ConfirmLoadingModal = ({
|
||||
type,
|
||||
loading,
|
||||
open,
|
||||
content,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
...props
|
||||
}: {
|
||||
type: 'resume' | 'change';
|
||||
loading?: boolean;
|
||||
content?: ReactNode;
|
||||
} & ConfirmModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const confirmed = useRef(false);
|
||||
|
||||
const title = t[`com.affine.payment.modal.${type}.title`]();
|
||||
const confirmText = t[`com.affine.payment.modal.${type}.confirm`]();
|
||||
const cancelText = t[`com.affine.payment.modal.${type}.cancel`]();
|
||||
const contentText =
|
||||
type !== 'change' ? t[`com.affine.payment.modal.${type}.content`]() : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && open && confirmed.current) {
|
||||
onOpenChange?.(false);
|
||||
confirmed.current = false;
|
||||
}
|
||||
}, [loading, open, onOpenChange]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={title}
|
||||
cancelText={cancelText}
|
||||
confirmButtonOptions={{
|
||||
type: 'primary',
|
||||
children: confirmText,
|
||||
loading,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirm={() => {
|
||||
confirmed.current = true;
|
||||
onConfirm?.();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{content ?? contentText}
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Downgrade modal, confirm & cancel button are reversed
|
||||
* @param param0
|
||||
*/
|
||||
export const DowngradeModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCancel,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onCancel?: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t['com.affine.payment.modal.downgrade.title']()}
|
||||
open={open}
|
||||
contentOptions={{}}
|
||||
width={480}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<div className={styles.downgradeContentWrapper}>
|
||||
<p className={styles.downgradeContent}>
|
||||
{t['com.affine.payment.modal.downgrade.content']()}
|
||||
</p>
|
||||
<p className={styles.downgradeCaption}>
|
||||
{t['com.affine.payment.modal.downgrade.caption']()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer className={styles.downgradeFooter}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onOpenChange?.(false);
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
{t['com.affine.payment.modal.downgrade.cancel']()}
|
||||
</Button>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => onOpenChange?.(false)} type="primary">
|
||||
{t['com.affine.payment.modal.downgrade.confirm']()}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1,578 @@
|
||||
import type {
|
||||
Subscription,
|
||||
SubscriptionMutator,
|
||||
} from '@affine/core/hooks/use-subscription';
|
||||
import {
|
||||
cancelSubscriptionMutation,
|
||||
checkoutMutation,
|
||||
resumeSubscriptionMutation,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
updateSubscriptionMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useMutation } from '@affine/workspace/affine/gql';
|
||||
import { DoneIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtom } from 'jotai';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { openPaymentDisableAtom } from '../../../../../atoms';
|
||||
import { authAtom } from '../../../../../atoms/index';
|
||||
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
|
||||
import { BulledListIcon } from './icons/bulled-list';
|
||||
import { ConfirmLoadingModal, DowngradeModal } from './modals';
|
||||
import * as styles from './style.css';
|
||||
|
||||
export interface FixedPrice {
|
||||
type: 'fixed';
|
||||
plan: SubscriptionPlan;
|
||||
price: string;
|
||||
yearlyPrice: string;
|
||||
discount?: string;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
export interface DynamicPrice {
|
||||
type: 'dynamic';
|
||||
plan: SubscriptionPlan;
|
||||
contact: boolean;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
interface PlanCardProps {
|
||||
detail: FixedPrice | DynamicPrice;
|
||||
subscription?: Subscription | null;
|
||||
recurring: string;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
onNotify: (info: {
|
||||
detail: FixedPrice | DynamicPrice;
|
||||
recurring: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
|
||||
return new Map<SubscriptionPlan, FixedPrice | DynamicPrice>([
|
||||
[
|
||||
SubscriptionPlan.Free,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Free,
|
||||
price: '0',
|
||||
yearlyPrice: '0',
|
||||
benefits: [
|
||||
t['com.affine.payment.benefit-1'](),
|
||||
t['com.affine.payment.benefit-2'](),
|
||||
t['com.affine.payment.benefit-3'](),
|
||||
t['com.affine.payment.benefit-4']({ capacity: '10GB' }),
|
||||
t['com.affine.payment.benefit-5']({ capacity: '10M' }),
|
||||
t['com.affine.payment.benefit-6']({ capacity: '3' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Pro,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
price: '1',
|
||||
yearlyPrice: '1',
|
||||
benefits: [
|
||||
t['com.affine.payment.benefit-1'](),
|
||||
t['com.affine.payment.benefit-2'](),
|
||||
t['com.affine.payment.benefit-3'](),
|
||||
t['com.affine.payment.benefit-4']({ capacity: '100GB' }),
|
||||
t['com.affine.payment.benefit-5']({ capacity: '500M' }),
|
||||
t['com.affine.payment.benefit-6']({ capacity: '10' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Team,
|
||||
{
|
||||
type: 'dynamic',
|
||||
plan: SubscriptionPlan.Team,
|
||||
contact: true,
|
||||
benefits: [
|
||||
t['com.affine.payment.dynamic-benefit-1'](),
|
||||
t['com.affine.payment.dynamic-benefit-2'](),
|
||||
t['com.affine.payment.dynamic-benefit-3'](),
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Enterprise,
|
||||
{
|
||||
type: 'dynamic',
|
||||
plan: SubscriptionPlan.Enterprise,
|
||||
contact: true,
|
||||
benefits: [
|
||||
t['com.affine.payment.dynamic-benefit-4'](),
|
||||
t['com.affine.payment.dynamic-benefit-5'](),
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
export const PlanCard = (props: PlanCardProps) => {
|
||||
const { detail, subscription, recurring } = props;
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const currentRecurring = subscription?.recurring;
|
||||
|
||||
const isCurrent =
|
||||
loggedIn &&
|
||||
detail.plan === currentPlan &&
|
||||
(currentPlan === SubscriptionPlan.Free
|
||||
? true
|
||||
: currentRecurring === recurring);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-current={isCurrent}
|
||||
key={detail.plan}
|
||||
className={isCurrent ? styles.currentPlanCard : styles.planCard}
|
||||
>
|
||||
<div className={styles.planTitle}>
|
||||
<p>
|
||||
{detail.plan}{' '}
|
||||
{'discount' in detail &&
|
||||
recurring === SubscriptionRecurring.Yearly && (
|
||||
<span className={styles.discountLabel}>
|
||||
{detail.discount}% off
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.planPriceWrapper}>
|
||||
<p>
|
||||
{detail.type === 'dynamic' ? (
|
||||
<span className={styles.planPriceDesc}>Coming soon...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.planPrice}>
|
||||
$
|
||||
{recurring === SubscriptionRecurring.Monthly
|
||||
? detail.price
|
||||
: detail.yearlyPrice}
|
||||
</span>
|
||||
<span className={styles.planPriceDesc}>per month</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton {...props} />
|
||||
</div>
|
||||
<div className={styles.planBenefits}>
|
||||
{detail.benefits.map((content, i) => (
|
||||
<div key={i} className={styles.planBenefit}>
|
||||
<div className={styles.planBenefitIcon}>
|
||||
{detail.type == 'dynamic' ? (
|
||||
<BulledListIcon color="var(--affine-processing-color)" />
|
||||
) : (
|
||||
<DoneIcon
|
||||
width="16"
|
||||
height="16"
|
||||
color="var(--affine-processing-color)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.planBenefitText}>{content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ActionButton = ({
|
||||
detail,
|
||||
subscription,
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
onNotify,
|
||||
}: PlanCardProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const currentRecurring = subscription?.recurring;
|
||||
|
||||
const mutateAndNotify = useCallback(
|
||||
(sub: Parameters<SubscriptionMutator>[0]) => {
|
||||
onSubscriptionUpdate?.(sub);
|
||||
onNotify?.({ detail, recurring });
|
||||
},
|
||||
[onSubscriptionUpdate, onNotify, detail, recurring]
|
||||
);
|
||||
|
||||
// branches:
|
||||
// if contact => 'Contact Sales'
|
||||
// if not signed in:
|
||||
// if free => 'Sign up free'
|
||||
// else => 'Buy Pro'
|
||||
// else
|
||||
// if isCurrent
|
||||
// if canceled => 'Resume'
|
||||
// else => 'Current Plan'
|
||||
// if isCurrent => 'Current Plan'
|
||||
// else if free => 'Downgrade'
|
||||
// else if currentRecurring !== recurring => 'Change to {recurring} Billing'
|
||||
// else => 'Upgrade'
|
||||
|
||||
// contact
|
||||
if (detail.type === 'dynamic') {
|
||||
return <ContactSales />;
|
||||
}
|
||||
|
||||
// not signed in
|
||||
if (!loggedIn) {
|
||||
return (
|
||||
<SignUpAction>
|
||||
{detail.plan === SubscriptionPlan.Free
|
||||
? t['com.affine.payment.sign-up-free']()
|
||||
: t['com.affine.payment.buy-pro']()}
|
||||
</SignUpAction>
|
||||
);
|
||||
}
|
||||
|
||||
const isCanceled = !!subscription?.canceledAt;
|
||||
const isFree = detail.plan === SubscriptionPlan.Free;
|
||||
const isCurrent =
|
||||
detail.plan === currentPlan &&
|
||||
(isFree ? true : currentRecurring === recurring);
|
||||
|
||||
// is current
|
||||
if (isCurrent) {
|
||||
return isCanceled ? (
|
||||
<ResumeAction onSubscriptionUpdate={mutateAndNotify} />
|
||||
) : (
|
||||
<CurrentPlan />
|
||||
);
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
return (
|
||||
<Downgrade disabled={isCanceled} onSubscriptionUpdate={mutateAndNotify} />
|
||||
);
|
||||
}
|
||||
|
||||
return currentPlan === detail.plan ? (
|
||||
<ChangeRecurring
|
||||
from={currentRecurring as SubscriptionRecurring}
|
||||
to={recurring as SubscriptionRecurring}
|
||||
due={subscription?.nextBillAt || ''}
|
||||
onSubscriptionUpdate={mutateAndNotify}
|
||||
disabled={isCanceled}
|
||||
/>
|
||||
) : (
|
||||
<Upgrade
|
||||
recurring={recurring as SubscriptionRecurring}
|
||||
onSubscriptionUpdate={mutateAndNotify}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CurrentPlan = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<Button className={styles.planAction}>
|
||||
{t['com.affine.payment.current-plan']()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const Downgrade = ({
|
||||
disabled,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: cancelSubscriptionMutation,
|
||||
});
|
||||
|
||||
// allow replay request on network error until component unmount
|
||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||
|
||||
const downgrade = useCallback(() => {
|
||||
trigger(
|
||||
{ idempotencyKey },
|
||||
{
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.cancelSubscription);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [trigger, idempotencyKey, onSubscriptionUpdate]);
|
||||
|
||||
const tooltipContent = disabled
|
||||
? t['com.affine.payment.downgraded-tooltip']()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={tooltipContent} rootOptions={{ delayDuration: 0 }}>
|
||||
<div className={styles.planAction}>
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled || isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
{t['com.affine.payment.downgrade']()}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<DowngradeModal open={open} onCancel={downgrade} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactSales = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<a
|
||||
className={styles.planAction}
|
||||
href="https://6dxre9ihosp.typeform.com/to/uZeBtpPm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button className={styles.planAction} type="primary">
|
||||
{t['com.affine.payment.contact-sales']()}
|
||||
</Button>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const Upgrade = ({
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
recurring: SubscriptionRecurring;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: checkoutMutation,
|
||||
});
|
||||
|
||||
const newTabRef = useRef<Window | null>(null);
|
||||
|
||||
// allow replay request on network error until component unmount
|
||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
newTabRef.current = null;
|
||||
onSubscriptionUpdate();
|
||||
}, [onSubscriptionUpdate]);
|
||||
|
||||
const [, openPaymentDisableModal] = useAtom(openPaymentDisableAtom);
|
||||
const upgrade = useCallback(() => {
|
||||
if (!runtimeConfig.enablePayment) {
|
||||
openPaymentDisableModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newTabRef.current) {
|
||||
newTabRef.current.focus();
|
||||
} else {
|
||||
trigger(
|
||||
{ recurring, idempotencyKey },
|
||||
{
|
||||
onSuccess: data => {
|
||||
// FIXME: safari prevents from opening new tab by window api
|
||||
// TODO(@xp): what if electron?
|
||||
const newTab = window.open(
|
||||
data.checkout,
|
||||
'_blank',
|
||||
'noopener noreferrer'
|
||||
);
|
||||
|
||||
if (newTab) {
|
||||
newTabRef.current = newTab;
|
||||
|
||||
newTab.addEventListener('close', onClose);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [openPaymentDisableModal, trigger, recurring, idempotencyKey, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (newTabRef.current) {
|
||||
newTabRef.current.removeEventListener('close', onClose);
|
||||
newTabRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={upgrade}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
{t['com.affine.payment.upgrade']()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeRecurring = ({
|
||||
from,
|
||||
to,
|
||||
disabled,
|
||||
due,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
from: SubscriptionRecurring;
|
||||
to: SubscriptionRecurring;
|
||||
disabled?: boolean;
|
||||
due: string;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: updateSubscriptionMutation,
|
||||
});
|
||||
|
||||
// allow replay request on network error until component unmount
|
||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||
|
||||
const change = useCallback(() => {
|
||||
trigger(
|
||||
{ recurring: to, idempotencyKey },
|
||||
{
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.updateSubscriptionRecurring);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [trigger, to, idempotencyKey, onSubscriptionUpdate]);
|
||||
|
||||
const changeCurringContent = (
|
||||
<Trans values={{ from, to, due }} className={styles.downgradeContent}>
|
||||
You are changing your <span className={styles.textEmphasis}>{from}</span>{' '}
|
||||
subscription to <span className={styles.textEmphasis}>{to}</span>{' '}
|
||||
subscription. This change will take effect in the next billing cycle, with
|
||||
an effective date of <span className={styles.textEmphasis}>{due}</span>.
|
||||
</Trans>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled || isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
{t['com.affine.payment.change-to']({ to })}
|
||||
</Button>
|
||||
|
||||
<ConfirmLoadingModal
|
||||
type={'change'}
|
||||
loading={isMutating}
|
||||
open={open}
|
||||
onConfirm={change}
|
||||
onOpenChange={setOpen}
|
||||
content={changeCurringContent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SignUpAction = ({ children }: PropsWithChildren) => {
|
||||
const setOpen = useSetAtom(authAtom);
|
||||
|
||||
const onClickSignIn = useCallback(async () => {
|
||||
setOpen(state => ({
|
||||
...state,
|
||||
openModal: true,
|
||||
}));
|
||||
}, [setOpen]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={onClickSignIn}
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ResumeAction = ({
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: resumeSubscriptionMutation,
|
||||
});
|
||||
|
||||
// allow replay request on network error until component unmount
|
||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
trigger(
|
||||
{ idempotencyKey },
|
||||
{
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.resumeSubscription);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [trigger, idempotencyKey, onSubscriptionUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => setOpen(true)}
|
||||
loading={isMutating}
|
||||
disabled={isMutating}
|
||||
>
|
||||
{hovered
|
||||
? t['com.affine.payment.resume-renewal']()
|
||||
: t['com.affine.payment.current-plan']()}
|
||||
</Button>
|
||||
|
||||
<ConfirmLoadingModal
|
||||
type={'resume'}
|
||||
open={open}
|
||||
onConfirm={resume}
|
||||
onOpenChange={setOpen}
|
||||
loading={isMutating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,29 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const plansWrapper = style({
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
});
|
||||
|
||||
export const planItemCard = style({
|
||||
width: '258px',
|
||||
height: '426px',
|
||||
flexShrink: '0',
|
||||
borderRadius: '16px',
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
});
|
||||
|
||||
export const planItemHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
});
|
||||
export const planItemContent = style({
|
||||
flexGrow: '1',
|
||||
height: 0,
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
import { Skeleton } from '@mui/material';
|
||||
|
||||
import { PlanLayout } from './layout';
|
||||
import * as styles from './skeleton.css';
|
||||
|
||||
/**
|
||||
* Customize Skeleton component with rounded border radius
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
const RoundedSkeleton = ({
|
||||
radius = 8,
|
||||
...props
|
||||
}: {
|
||||
radius?: number;
|
||||
} & React.ComponentProps<typeof Skeleton>) => (
|
||||
<Skeleton {...props} style={{ borderRadius: `${radius}px` }} />
|
||||
);
|
||||
|
||||
const SubtitleSkeleton = () => (
|
||||
<Skeleton variant="text" width="100%" height="20px" />
|
||||
);
|
||||
|
||||
const TabsSkeleton = () => (
|
||||
// TODO: height should be `32px` by design
|
||||
// but the RadioGroup component is not matching with the design currently
|
||||
// set to `24px` for now to avoid blinking
|
||||
<Skeleton variant="rounded" width="256px" height="24px" />
|
||||
);
|
||||
|
||||
const PlanItemSkeleton = () => (
|
||||
<div className={styles.planItemCard}>
|
||||
<header className={styles.planItemHeader}>
|
||||
<RoundedSkeleton variant="rounded" width="100%" height="60px" />
|
||||
<RoundedSkeleton variant="rounded" width="100%" height="28px" />
|
||||
</header>
|
||||
|
||||
<main className={styles.planItemContent}>
|
||||
<RoundedSkeleton variant="rounded" width="100%" height="100%" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ScrollSkeleton = () => (
|
||||
<div className={styles.plansWrapper}>
|
||||
<PlanItemSkeleton />
|
||||
<PlanItemSkeleton />
|
||||
<PlanItemSkeleton />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PlansSkeleton = () => {
|
||||
return (
|
||||
<PlanLayout
|
||||
subtitle={<SubtitleSkeleton />}
|
||||
tabs={<TabsSkeleton />}
|
||||
scroll={<ScrollSkeleton />}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,149 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const wrapper = style({
|
||||
width: '100%',
|
||||
});
|
||||
export const recurringRadioGroup = style({
|
||||
width: '256px',
|
||||
});
|
||||
|
||||
export const radioButtonDiscount = style({
|
||||
marginLeft: '4px',
|
||||
color: 'var(--affine-primary-color)',
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const planCardsWrapper = style({
|
||||
paddingRight: 'calc(var(--setting-modal-gap-x) + 30px)',
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
width: 'fit-content',
|
||||
});
|
||||
|
||||
export const planCard = style({
|
||||
minHeight: '426px',
|
||||
minWidth: '258px',
|
||||
borderRadius: '16px',
|
||||
padding: '20px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
position: 'relative',
|
||||
|
||||
selectors: {
|
||||
'&::before': {
|
||||
content: '',
|
||||
position: 'absolute',
|
||||
right: 'calc(100% + var(--setting-modal-gap-x))',
|
||||
scrollSnapAlign: 'start',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const currentPlanCard = style([
|
||||
planCard,
|
||||
{
|
||||
borderWidth: '2px',
|
||||
borderColor: 'var(--affine-primary-color)',
|
||||
boxShadow: 'var(--affine-shadow-2)',
|
||||
},
|
||||
]);
|
||||
|
||||
export const discountLabel = style({
|
||||
color: 'var(--affine-primary-color)',
|
||||
marginLeft: '8px',
|
||||
lineHeight: '20px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
fontWeight: 500,
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'var(--affine-blue-50)',
|
||||
borderRadius: '4px',
|
||||
display: 'inline-block',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const planTitle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '10px',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const planPriceWrapper = style({
|
||||
minHeight: '28px',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
});
|
||||
|
||||
export const planPrice = style({
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
marginRight: '8px',
|
||||
});
|
||||
|
||||
export const planPriceDesc = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
});
|
||||
|
||||
export const planAction = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const planBenefits = style({
|
||||
marginTop: '20px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const planBenefit = style({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
lineHeight: '20px',
|
||||
alignItems: 'normal',
|
||||
fontSize: '12px',
|
||||
});
|
||||
|
||||
export const planBenefitIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '20px',
|
||||
});
|
||||
|
||||
export const planBenefitText = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const downgradeContentWrapper = style({
|
||||
padding: '12px 0 20px 0px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const downgradeContent = style({
|
||||
fontSize: '15px',
|
||||
lineHeight: '24px',
|
||||
fontWeight: 400,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
||||
|
||||
export const downgradeCaption = style({
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const downgradeFooter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '20px',
|
||||
paddingTop: '20px',
|
||||
});
|
||||
|
||||
export const textEmphasis = style({
|
||||
color: 'var(--affine-text-emphasis-color)',
|
||||
});
|
@ -2,7 +2,8 @@ import { WorkspaceDetailSkeleton } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ContactWithUsIcon } from '@blocksuite/icons';
|
||||
import { Modal, type ModalProps } from '@toeverything/components/modal';
|
||||
import { Suspense, useCallback } from 'react';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { Suspense, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import { AccountSetting } from './account-setting';
|
||||
@ -37,6 +38,39 @@ export const SettingModal = ({
|
||||
|
||||
const generalSettingList = useGeneralSettingList();
|
||||
|
||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||
const modalContentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalProps.open) return;
|
||||
let animationFrameId: number;
|
||||
const onResize = debounce(() => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
if (!modalContentRef.current || !modalContentWrapperRef.current) return;
|
||||
|
||||
const wrapperWidth = modalContentWrapperRef.current.offsetWidth;
|
||||
const contentWidth = modalContentRef.current.offsetWidth;
|
||||
|
||||
modalContentRef.current?.style.setProperty(
|
||||
'--setting-modal-width',
|
||||
`${wrapperWidth}px`
|
||||
);
|
||||
modalContentRef.current?.style.setProperty(
|
||||
'--setting-modal-gap-x',
|
||||
`${(wrapperWidth - contentWidth) / 2}px`
|
||||
);
|
||||
});
|
||||
}, 200);
|
||||
window.addEventListener('resize', onResize);
|
||||
onResize();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [modalProps.open]);
|
||||
|
||||
const onGeneralSettingClick = useCallback(
|
||||
(key: GeneralSettingKeys) => {
|
||||
onSettingClick({
|
||||
@ -84,32 +118,38 @@ export const SettingModal = ({
|
||||
onAccountSettingClick={onAccountSettingClick}
|
||||
/>
|
||||
|
||||
<div data-testid="setting-modal-content" className={style.wrapper}>
|
||||
<div className={style.content}>
|
||||
{activeTab === 'workspace' && workspaceId ? (
|
||||
<Suspense fallback={<WorkspaceDetailSkeleton />}>
|
||||
<WorkspaceSetting key={workspaceId} workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{generalSettingList.find(v => v.key === activeTab) ? (
|
||||
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
|
||||
) : null}
|
||||
{activeTab === 'account' && loginStatus === 'authenticated' ? (
|
||||
<AccountSetting />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="footer">
|
||||
<a
|
||||
href="https://community.affine.pro/home"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={style.suggestionLink}
|
||||
>
|
||||
<span className={style.suggestionLinkIcon}>
|
||||
<ContactWithUsIcon />
|
||||
</span>
|
||||
{t['com.affine.settings.suggestion']()}
|
||||
</a>
|
||||
<div
|
||||
data-testid="setting-modal-content"
|
||||
className={style.wrapper}
|
||||
ref={modalContentWrapperRef}
|
||||
>
|
||||
<div ref={modalContentRef} className={style.centerContainer}>
|
||||
<div className={style.content}>
|
||||
{activeTab === 'workspace' && workspaceId ? (
|
||||
<Suspense fallback={<WorkspaceDetailSkeleton />}>
|
||||
<WorkspaceSetting key={workspaceId} workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{generalSettingList.find(v => v.key === activeTab) ? (
|
||||
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
|
||||
) : null}
|
||||
{activeTab === 'account' && loginStatus === 'authenticated' ? (
|
||||
<AccountSetting />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="footer">
|
||||
<a
|
||||
href="https://community.affine.pro/home"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={style.suggestionLink}
|
||||
>
|
||||
<span className={style.suggestionLinkIcon}>
|
||||
<ContactWithUsIcon />
|
||||
</span>
|
||||
{t['com.affine.settings.suggestion']()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
@ -20,6 +20,7 @@ import { authAtom } from '../../../../atoms';
|
||||
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
|
||||
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
|
||||
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
|
||||
import { UserPlanButton } from '../../auth/user-plan-button';
|
||||
import type {
|
||||
GeneralSettingKeys,
|
||||
GeneralSettingList,
|
||||
@ -52,9 +53,13 @@ export const UserInfo = ({
|
||||
<Avatar size={28} name={user.name} url={user.image} className="avatar" />
|
||||
|
||||
<div className="content">
|
||||
<div className="name" title={user.name}>
|
||||
{user.name}
|
||||
<div className="name-container">
|
||||
<div className="name" title={user.name}>
|
||||
{user.name}
|
||||
</div>
|
||||
<UserPlanButton />
|
||||
</div>
|
||||
|
||||
<div className="email" title={user.email}>
|
||||
{user.email}
|
||||
</div>
|
||||
|
@ -94,7 +94,6 @@ export const currentWorkspaceLabel = style({
|
||||
export const sidebarFooter = style({ padding: '0 16px' });
|
||||
|
||||
export const accountButton = style({
|
||||
height: '42px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
@ -129,13 +128,21 @@ globalStyle(`${accountButton} .content`, {
|
||||
flexGrow: '1',
|
||||
minWidth: 0,
|
||||
});
|
||||
globalStyle(`${accountButton} .name-container`, {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
gap: '4px',
|
||||
height: '22px',
|
||||
});
|
||||
globalStyle(`${accountButton} .name`, {
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flexGrow: 1,
|
||||
height: '22px',
|
||||
});
|
||||
globalStyle(`${accountButton} .email`, {
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
@ -144,4 +151,5 @@ globalStyle(`${accountButton} .email`, {
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flexGrow: 1,
|
||||
height: '20px',
|
||||
});
|
||||
|
@ -3,22 +3,25 @@ import { style } from '@vanilla-extract/css';
|
||||
export const wrapper = style({
|
||||
flexGrow: '1',
|
||||
height: '100%',
|
||||
maxWidth: '560px',
|
||||
margin: '0 auto',
|
||||
padding: '40px 15px 20px 15px',
|
||||
overflow: 'hidden auto',
|
||||
|
||||
// children
|
||||
// margin: '0 auto',
|
||||
padding: '40px 15px 20px 15px',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
'::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
export const centerContainer = style({
|
||||
width: '100%',
|
||||
maxWidth: '560px',
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
width: '100%',
|
||||
marginBottom: '24px',
|
||||
|
@ -3,9 +3,10 @@ import { style } from '@vanilla-extract/css';
|
||||
export const userAccountContainer = style({
|
||||
display: 'flex',
|
||||
padding: '4px 0px 4px 12px',
|
||||
gap: '12px',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
});
|
||||
export const userEmail = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
@ -14,5 +15,12 @@ export const userEmail = style({
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 'calc(100% - 36px)',
|
||||
});
|
||||
|
||||
export const leftContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
openSettingModalAtom,
|
||||
openSignOutModalAtom,
|
||||
} from '../../../../../atoms';
|
||||
import { UserPlanButton } from '../../../../affine/auth/user-plan-button';
|
||||
import * as styles from './index.css';
|
||||
|
||||
const AccountMenu = ({ onEventEnd }: { onEventEnd?: () => void }) => {
|
||||
@ -73,7 +74,10 @@ export const UserAccountItem = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.userAccountContainer}>
|
||||
<div className={styles.userEmail}>{email}</div>
|
||||
<div className={styles.leftContainer}>
|
||||
<div className={styles.userEmail}>{email}</div>
|
||||
<UserPlanButton />
|
||||
</div>
|
||||
<Menu
|
||||
items={<AccountMenu onEventEnd={onEventEnd} />}
|
||||
contentOptions={{
|
||||
|
40
packages/frontend/core/src/hooks/use-subscription.ts
Normal file
40
packages/frontend/core/src/hooks/use-subscription.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { type SubscriptionQuery, subscriptionQuery } from '@affine/graphql';
|
||||
import { useQuery } from '@affine/workspace/affine/gql';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type Subscription = NonNullable<
|
||||
NonNullable<SubscriptionQuery['currentUser']>['subscription']
|
||||
>;
|
||||
|
||||
export type SubscriptionMutator = (update?: Partial<Subscription>) => void;
|
||||
|
||||
const selector = (data: SubscriptionQuery) =>
|
||||
data.currentUser?.subscription ?? null;
|
||||
|
||||
export const useUserSubscription = () => {
|
||||
const { data, mutate } = useQuery({
|
||||
query: subscriptionQuery,
|
||||
});
|
||||
|
||||
const set: SubscriptionMutator = useCallback(
|
||||
(update?: Partial<Subscription>) => {
|
||||
mutate(prev => {
|
||||
if (!update || !prev?.currentUser?.subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
currentUser: {
|
||||
subscription: {
|
||||
...prev.currentUser?.subscription,
|
||||
...update,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[mutate]
|
||||
);
|
||||
|
||||
return [selector(data), set] as const;
|
||||
};
|
93
packages/frontend/core/src/pages/upgrade-success.css.ts
Normal file
93
packages/frontend/core/src/pages/upgrade-success.css.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const affineLogo = style({
|
||||
color: 'inherit',
|
||||
});
|
||||
|
||||
export const topNav = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 120px',
|
||||
});
|
||||
|
||||
export const topNavLinks = style({
|
||||
display: 'flex',
|
||||
columnGap: 4,
|
||||
});
|
||||
|
||||
export const topNavLink = style({
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
padding: '4px 18px',
|
||||
});
|
||||
|
||||
export const tryAgainLink = style({
|
||||
color: 'var(--affine-link-color)',
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
});
|
||||
|
||||
export const centerContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 40,
|
||||
});
|
||||
|
||||
export const prompt = style({
|
||||
marginTop: 20,
|
||||
marginBottom: 12,
|
||||
});
|
||||
|
||||
export const body = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap',
|
||||
gap: '48px',
|
||||
padding: '0 20px',
|
||||
});
|
||||
|
||||
export const leftContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '548px',
|
||||
gap: '28px',
|
||||
});
|
||||
export const leftContentTitle = style({
|
||||
fontSize: 'var(--affine-font-title)',
|
||||
fontWeight: 700,
|
||||
minHeight: '44px',
|
||||
});
|
||||
export const leftContentText = style({
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.6',
|
||||
});
|
||||
|
||||
export const mail = style({
|
||||
color: 'var(--affine-link-color)',
|
||||
textDecoration: 'none',
|
||||
':visited': {
|
||||
color: 'var(--affine-link-color)',
|
||||
},
|
||||
});
|
105
packages/frontend/core/src/pages/upgrade-success.tsx
Normal file
105
packages/frontend/core/src/pages/upgrade-success.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { Empty } from '@affine/component';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Logo1Icon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import * as styles from './upgrade-success.css';
|
||||
|
||||
export const UpgradeSuccess = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const openDownloadLink = useCallback(() => {
|
||||
const url = `https://affine.pro/download`;
|
||||
open(url, '_blank');
|
||||
}, []);
|
||||
|
||||
const { jumpToIndex } = useNavigateHelper();
|
||||
const openAffine = useCallback(() => {
|
||||
jumpToIndex();
|
||||
}, [jumpToIndex]);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.topNav}>
|
||||
<a href="/" rel="noreferrer" className={styles.affineLogo}>
|
||||
<Logo1Icon width={24} height={24} />
|
||||
</a>
|
||||
|
||||
<div className={styles.topNavLinks}>
|
||||
<a
|
||||
href="https://affine.pro"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.topNavLink}
|
||||
>
|
||||
{t['com.affine.other-page.nav.official-website']()}
|
||||
</a>
|
||||
<a
|
||||
href="https://community.affine.pro/home"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.topNavLink}
|
||||
>
|
||||
{t['com.affine.other-page.nav.affine-community']()}
|
||||
</a>
|
||||
<a
|
||||
href="https://affine.pro/blog"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.topNavLink}
|
||||
>
|
||||
{t['com.affine.other-page.nav.blog']()}
|
||||
</a>
|
||||
<a
|
||||
href="https://affine.pro/about-us"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.topNavLink}
|
||||
>
|
||||
{t['com.affine.other-page.nav.contact-us']()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button onClick={openDownloadLink}>
|
||||
{t['com.affine.auth.open.affine.download-app']()}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.leftContainer}>
|
||||
<div className={styles.leftContentTitle}>
|
||||
{t['com.affine.payment.upgrade-success-page.title']()}
|
||||
</div>
|
||||
<div className={styles.leftContentText}>
|
||||
{t['com.affine.payment.upgrade-success-page.text']()}
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey={'com.affine.payment.upgrade-success-page.support'}
|
||||
components={{
|
||||
1: (
|
||||
<a
|
||||
href="mailto:support@toeverything.info"
|
||||
className={styles.mail}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button type="primary" size="extraLarge" onClick={openAffine}>
|
||||
{t['com.affine.other-page.nav.open-affine']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Empty />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
return <UpgradeSuccess />;
|
||||
};
|
@ -12,6 +12,7 @@ import {
|
||||
openSettingModalAtom,
|
||||
openSignOutModalAtom,
|
||||
} from '../atoms';
|
||||
import { PaymentDisableModal } from '../components/affine/payment-disable';
|
||||
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import { signOutCloud } from '../utils/cloud-utils';
|
||||
@ -201,6 +202,7 @@ export const AllWorkspaceModals = (): ReactElement => {
|
||||
<Suspense>
|
||||
<SignOutConfirmModal />
|
||||
</Suspense>
|
||||
<PaymentDisableModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -56,6 +56,10 @@ export const routes = [
|
||||
path: '/open-app/:action',
|
||||
lazy: () => import('./pages/open-app'),
|
||||
},
|
||||
{
|
||||
path: '/upgrade-success',
|
||||
lazy: () => import('./pages/upgrade-success'),
|
||||
},
|
||||
{
|
||||
path: '/desktop-signin',
|
||||
lazy: () => import('./pages/desktop-signin'),
|
||||
|
@ -0,0 +1,8 @@
|
||||
mutation cancelSubscription($idempotencyKey: String!) {
|
||||
cancelSubscription(idempotencyKey: $idempotencyKey) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
canceledAt
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
mutation checkout(
|
||||
$recurring: SubscriptionRecurring!
|
||||
$idempotencyKey: String!
|
||||
) {
|
||||
checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
mutation createCustomerPortal {
|
||||
createCustomerPortal
|
||||
}
|
@ -79,6 +79,22 @@ query allBlobSizes {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const cancelSubscriptionMutation = {
|
||||
id: 'cancelSubscriptionMutation' as const,
|
||||
operationName: 'cancelSubscription',
|
||||
definitionName: 'cancelSubscription',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation cancelSubscription($idempotencyKey: String!) {
|
||||
cancelSubscription(idempotencyKey: $idempotencyKey) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
canceledAt
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const changeEmailMutation = {
|
||||
id: 'changeEmailMutation' as const,
|
||||
operationName: 'changeEmail',
|
||||
@ -111,6 +127,28 @@ mutation changePassword($token: String!, $newPassword: String!) {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const checkoutMutation = {
|
||||
id: 'checkoutMutation' as const,
|
||||
operationName: 'checkout',
|
||||
definitionName: 'checkout',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation checkout($recurring: SubscriptionRecurring!, $idempotencyKey: String!) {
|
||||
checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const createCustomerPortalMutation = {
|
||||
id: 'createCustomerPortalMutation' as const,
|
||||
operationName: 'createCustomerPortal',
|
||||
definitionName: 'createCustomerPortal',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation createCustomerPortal {
|
||||
createCustomerPortal
|
||||
}`,
|
||||
};
|
||||
|
||||
export const createWorkspaceMutation = {
|
||||
id: 'createWorkspaceMutation' as const,
|
||||
operationName: 'createWorkspace',
|
||||
@ -321,6 +359,30 @@ query getWorkspaces {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const invoicesQuery = {
|
||||
id: 'invoicesQuery' as const,
|
||||
operationName: 'invoices',
|
||||
definitionName: 'currentUser',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query invoices($take: Int!, $skip: Int!) {
|
||||
currentUser {
|
||||
invoices(take: $take, skip: $skip) {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
currency
|
||||
amount
|
||||
reason
|
||||
lastPaymentError
|
||||
link
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const leaveWorkspaceMutation = {
|
||||
id: 'leaveWorkspaceMutation' as const,
|
||||
operationName: 'leaveWorkspace',
|
||||
@ -336,6 +398,23 @@ mutation leaveWorkspace($workspaceId: String!, $workspaceName: String!, $sendLea
|
||||
}`,
|
||||
};
|
||||
|
||||
export const pricesQuery = {
|
||||
id: 'pricesQuery' as const,
|
||||
operationName: 'prices',
|
||||
definitionName: 'prices',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query prices {
|
||||
prices {
|
||||
type
|
||||
plan
|
||||
currency
|
||||
amount
|
||||
yearlyAmount
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const removeAvatarMutation = {
|
||||
id: 'removeAvatarMutation' as const,
|
||||
operationName: 'removeAvatar',
|
||||
@ -349,6 +428,23 @@ mutation removeAvatar {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const resumeSubscriptionMutation = {
|
||||
id: 'resumeSubscriptionMutation' as const,
|
||||
operationName: 'resumeSubscription',
|
||||
definitionName: 'resumeSubscription',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation resumeSubscription($idempotencyKey: String!) {
|
||||
resumeSubscription(idempotencyKey: $idempotencyKey) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
start
|
||||
end
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const revokeMemberPermissionMutation = {
|
||||
id: 'revokeMemberPermissionMutation' as const,
|
||||
operationName: 'revokeMemberPermission',
|
||||
@ -469,6 +565,47 @@ mutation signUp($name: String!, $email: String!, $password: String!) {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const subscriptionQuery = {
|
||||
id: 'subscriptionQuery' as const,
|
||||
operationName: 'subscription',
|
||||
definitionName: 'currentUser',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query subscription {
|
||||
currentUser {
|
||||
subscription {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
start
|
||||
end
|
||||
nextBillAt
|
||||
canceledAt
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const updateSubscriptionMutation = {
|
||||
id: 'updateSubscriptionMutation' as const,
|
||||
operationName: 'updateSubscription',
|
||||
definitionName: 'updateSubscriptionRecurring',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation updateSubscription($recurring: SubscriptionRecurring!, $idempotencyKey: String!) {
|
||||
updateSubscriptionRecurring(
|
||||
recurring: $recurring
|
||||
idempotencyKey: $idempotencyKey
|
||||
) {
|
||||
id
|
||||
plan
|
||||
recurring
|
||||
nextBillAt
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const uploadAvatarMutation = {
|
||||
id: 'uploadAvatarMutation' as const,
|
||||
operationName: 'uploadAvatar',
|
||||
|
16
packages/frontend/graphql/src/graphql/invoices.gql
Normal file
16
packages/frontend/graphql/src/graphql/invoices.gql
Normal file
@ -0,0 +1,16 @@
|
||||
query invoices($take: Int!, $skip: Int!) {
|
||||
currentUser {
|
||||
invoices(take: $take, skip: $skip) {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
currency
|
||||
amount
|
||||
reason
|
||||
lastPaymentError
|
||||
link
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
9
packages/frontend/graphql/src/graphql/prices.gql
Normal file
9
packages/frontend/graphql/src/graphql/prices.gql
Normal file
@ -0,0 +1,9 @@
|
||||
query prices {
|
||||
prices {
|
||||
type
|
||||
plan
|
||||
currency
|
||||
amount
|
||||
yearlyAmount
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
mutation resumeSubscription($idempotencyKey: String!) {
|
||||
resumeSubscription(idempotencyKey: $idempotencyKey) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
start
|
||||
end
|
||||
}
|
||||
}
|
14
packages/frontend/graphql/src/graphql/subscription.gql
Normal file
14
packages/frontend/graphql/src/graphql/subscription.gql
Normal file
@ -0,0 +1,14 @@
|
||||
query subscription {
|
||||
currentUser {
|
||||
subscription {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
start
|
||||
end
|
||||
nextBillAt
|
||||
canceledAt
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
mutation updateSubscription(
|
||||
$recurring: SubscriptionRecurring!
|
||||
$idempotencyKey: String!
|
||||
) {
|
||||
updateSubscriptionRecurring(
|
||||
recurring: $recurring
|
||||
idempotencyKey: $idempotencyKey
|
||||
) {
|
||||
id
|
||||
plan
|
||||
recurring
|
||||
nextBillAt
|
||||
}
|
||||
}
|
@ -32,6 +32,14 @@ export interface Scalars {
|
||||
Upload: { input: File; output: File };
|
||||
}
|
||||
|
||||
export enum InvoiceStatus {
|
||||
Draft = 'Draft',
|
||||
Open = 'Open',
|
||||
Paid = 'Paid',
|
||||
Uncollectible = 'Uncollectible',
|
||||
Void = 'Void',
|
||||
}
|
||||
|
||||
export enum NewFeaturesKind {
|
||||
EarlyAccess = 'EarlyAccess',
|
||||
}
|
||||
@ -44,6 +52,29 @@ export enum Permission {
|
||||
Write = 'Write',
|
||||
}
|
||||
|
||||
export enum SubscriptionPlan {
|
||||
Enterprise = 'Enterprise',
|
||||
Free = 'Free',
|
||||
Pro = 'Pro',
|
||||
Team = 'Team',
|
||||
}
|
||||
|
||||
export enum SubscriptionRecurring {
|
||||
Monthly = 'Monthly',
|
||||
Yearly = 'Yearly',
|
||||
}
|
||||
|
||||
export enum SubscriptionStatus {
|
||||
Active = 'Active',
|
||||
Canceled = 'Canceled',
|
||||
Incomplete = 'Incomplete',
|
||||
IncompleteExpired = 'IncompleteExpired',
|
||||
PastDue = 'PastDue',
|
||||
Paused = 'Paused',
|
||||
Trialing = 'Trialing',
|
||||
Unpaid = 'Unpaid',
|
||||
}
|
||||
|
||||
export interface UpdateWorkspaceInput {
|
||||
id: Scalars['ID']['input'];
|
||||
/** is Public workspace */
|
||||
@ -99,6 +130,21 @@ export type AllBlobSizesQuery = {
|
||||
collectAllBlobSizes: { __typename?: 'WorkspaceBlobSizes'; size: number };
|
||||
};
|
||||
|
||||
export type CancelSubscriptionMutationVariables = Exact<{
|
||||
idempotencyKey: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type CancelSubscriptionMutation = {
|
||||
__typename?: 'Mutation';
|
||||
cancelSubscription: {
|
||||
__typename?: 'UserSubscription';
|
||||
id: string;
|
||||
status: SubscriptionStatus;
|
||||
nextBillAt: string | null;
|
||||
canceledAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChangeEmailMutationVariables = Exact<{
|
||||
token: Scalars['String']['input'];
|
||||
}>;
|
||||
@ -130,6 +176,22 @@ export type ChangePasswordMutation = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CheckoutMutationVariables = Exact<{
|
||||
recurring: SubscriptionRecurring;
|
||||
idempotencyKey: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
|
||||
|
||||
export type CreateCustomerPortalMutationVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type CreateCustomerPortalMutation = {
|
||||
__typename?: 'Mutation';
|
||||
createCustomerPortal: string;
|
||||
};
|
||||
|
||||
export type CreateWorkspaceMutationVariables = Exact<{
|
||||
init: Scalars['Upload']['input'];
|
||||
}>;
|
||||
@ -173,7 +235,7 @@ export type GetCurrentUserQuery = {
|
||||
avatarUrl: string | null;
|
||||
createdAt: string | null;
|
||||
token: { __typename?: 'TokenType'; sessionToken: string | null };
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type GetInviteInfoQueryVariables = Exact<{
|
||||
@ -297,6 +359,31 @@ export type GetWorkspacesQuery = {
|
||||
workspaces: Array<{ __typename?: 'WorkspaceType'; id: string }>;
|
||||
};
|
||||
|
||||
export type InvoicesQueryVariables = Exact<{
|
||||
take: Scalars['Int']['input'];
|
||||
skip: Scalars['Int']['input'];
|
||||
}>;
|
||||
|
||||
export type InvoicesQuery = {
|
||||
__typename?: 'Query';
|
||||
currentUser: {
|
||||
__typename?: 'UserType';
|
||||
invoices: Array<{
|
||||
__typename?: 'UserInvoice';
|
||||
id: string;
|
||||
status: InvoiceStatus;
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
currency: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
lastPaymentError: string | null;
|
||||
link: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type LeaveWorkspaceMutationVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
workspaceName: Scalars['String']['input'];
|
||||
@ -308,6 +395,20 @@ export type LeaveWorkspaceMutation = {
|
||||
leaveWorkspace: boolean;
|
||||
};
|
||||
|
||||
export type PricesQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type PricesQuery = {
|
||||
__typename?: 'Query';
|
||||
prices: Array<{
|
||||
__typename?: 'SubscriptionPrice';
|
||||
type: string;
|
||||
plan: SubscriptionPlan;
|
||||
currency: string;
|
||||
amount: number;
|
||||
yearlyAmount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type RemoveAvatarMutation = {
|
||||
@ -315,6 +416,22 @@ export type RemoveAvatarMutation = {
|
||||
removeAvatar: { __typename?: 'RemoveAvatar'; success: boolean };
|
||||
};
|
||||
|
||||
export type ResumeSubscriptionMutationVariables = Exact<{
|
||||
idempotencyKey: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type ResumeSubscriptionMutation = {
|
||||
__typename?: 'Mutation';
|
||||
resumeSubscription: {
|
||||
__typename?: 'UserSubscription';
|
||||
id: string;
|
||||
status: SubscriptionStatus;
|
||||
nextBillAt: string | null;
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type RevokeMemberPermissionMutationVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
userId: Scalars['String']['input'];
|
||||
@ -420,6 +537,42 @@ export type SignUpMutation = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type SubscriptionQuery = {
|
||||
__typename?: 'Query';
|
||||
currentUser: {
|
||||
__typename?: 'UserType';
|
||||
subscription: {
|
||||
__typename?: 'UserSubscription';
|
||||
id: string;
|
||||
status: SubscriptionStatus;
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
start: string;
|
||||
end: string;
|
||||
nextBillAt: string | null;
|
||||
canceledAt: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type UpdateSubscriptionMutationVariables = Exact<{
|
||||
recurring: SubscriptionRecurring;
|
||||
idempotencyKey: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type UpdateSubscriptionMutation = {
|
||||
__typename?: 'Mutation';
|
||||
updateSubscriptionRecurring: {
|
||||
__typename?: 'UserSubscription';
|
||||
id: string;
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
nextBillAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type UploadAvatarMutationVariables = Exact<{
|
||||
avatar: Scalars['Upload']['input'];
|
||||
}>;
|
||||
@ -539,6 +692,21 @@ export type Queries =
|
||||
name: 'getWorkspacesQuery';
|
||||
variables: GetWorkspacesQueryVariables;
|
||||
response: GetWorkspacesQuery;
|
||||
}
|
||||
| {
|
||||
name: 'invoicesQuery';
|
||||
variables: InvoicesQueryVariables;
|
||||
response: InvoicesQuery;
|
||||
}
|
||||
| {
|
||||
name: 'pricesQuery';
|
||||
variables: PricesQueryVariables;
|
||||
response: PricesQuery;
|
||||
}
|
||||
| {
|
||||
name: 'subscriptionQuery';
|
||||
variables: SubscriptionQueryVariables;
|
||||
response: SubscriptionQuery;
|
||||
};
|
||||
|
||||
export type Mutations =
|
||||
@ -552,6 +720,11 @@ export type Mutations =
|
||||
variables: SetBlobMutationVariables;
|
||||
response: SetBlobMutation;
|
||||
}
|
||||
| {
|
||||
name: 'cancelSubscriptionMutation';
|
||||
variables: CancelSubscriptionMutationVariables;
|
||||
response: CancelSubscriptionMutation;
|
||||
}
|
||||
| {
|
||||
name: 'changeEmailMutation';
|
||||
variables: ChangeEmailMutationVariables;
|
||||
@ -562,6 +735,16 @@ export type Mutations =
|
||||
variables: ChangePasswordMutationVariables;
|
||||
response: ChangePasswordMutation;
|
||||
}
|
||||
| {
|
||||
name: 'checkoutMutation';
|
||||
variables: CheckoutMutationVariables;
|
||||
response: CheckoutMutation;
|
||||
}
|
||||
| {
|
||||
name: 'createCustomerPortalMutation';
|
||||
variables: CreateCustomerPortalMutationVariables;
|
||||
response: CreateCustomerPortalMutation;
|
||||
}
|
||||
| {
|
||||
name: 'createWorkspaceMutation';
|
||||
variables: CreateWorkspaceMutationVariables;
|
||||
@ -587,6 +770,11 @@ export type Mutations =
|
||||
variables: RemoveAvatarMutationVariables;
|
||||
response: RemoveAvatarMutation;
|
||||
}
|
||||
| {
|
||||
name: 'resumeSubscriptionMutation';
|
||||
variables: ResumeSubscriptionMutationVariables;
|
||||
response: ResumeSubscriptionMutation;
|
||||
}
|
||||
| {
|
||||
name: 'revokeMemberPermissionMutation';
|
||||
variables: RevokeMemberPermissionMutationVariables;
|
||||
@ -637,6 +825,11 @@ export type Mutations =
|
||||
variables: SignUpMutationVariables;
|
||||
response: SignUpMutation;
|
||||
}
|
||||
| {
|
||||
name: 'updateSubscriptionMutation';
|
||||
variables: UpdateSubscriptionMutationVariables;
|
||||
response: UpdateSubscriptionMutation;
|
||||
}
|
||||
| {
|
||||
name: 'uploadAvatarMutation';
|
||||
variables: UploadAvatarMutationVariables;
|
||||
|
@ -343,7 +343,9 @@
|
||||
"com.affine.storage.extend.hint": "The usage has reached its maximum capacity, AFFiNE Cloud is currently in early access phase and is not supported for upgrading, please be patient and wait for our pricing plan. ",
|
||||
"com.affine.storage.extend.link": "To get more information click here.",
|
||||
"com.affine.storage.title": "AFFiNE Cloud Storage",
|
||||
"com.affine.storage.upgrade": "Upgrade to Pro",
|
||||
"com.affine.storage.upgrade": "Upgrade",
|
||||
"com.affine.storage.change-plan": "Change",
|
||||
"com.affine.storage.plan": "Plan",
|
||||
"com.affine.storage.used.hint": "Space used",
|
||||
"com.affine.themeSettings.dark": "Dark",
|
||||
"com.affine.themeSettings.light": "Light",
|
||||
@ -646,6 +648,8 @@
|
||||
"com.affine.cmdk.affine.getting-started": "Getting Started",
|
||||
"com.affine.cmdk.affine.contact-us": "Contact Us",
|
||||
"com.affine.cmdk.affine.restart-to-upgrade": "Restart to Upgrade",
|
||||
"com.affine.payment.disable-payment.title": "Account Upgrade Unavailable",
|
||||
"com.affine.payment.disable-payment.description": "This is a special testing(Canary) version of AFFiNE. Account upgrades are not supported in this version. If you want to experience the full service, please download the stable version from our website.",
|
||||
"com.affine.share-menu.publish-to-web": "Publish to Web",
|
||||
"com.affine.share-menu.publish-to-web.description": "Let anyone with a link view a read-only version of this page.",
|
||||
"com.affine.share-menu.share-privately": "Share Privately",
|
||||
@ -655,6 +659,86 @@
|
||||
"com.affine.auth.sign-out.confirm-modal.description": "After signing out, the Cloud Workspaces associated with this account will be removed from the current device, and signing in again will add them back.",
|
||||
"com.affine.auth.sign-out.confirm-modal.cancel": "Cancel",
|
||||
"com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out",
|
||||
"com.affine.payment.recurring-yearly": "Annually",
|
||||
"com.affine.payment.recurring-monthly": "Monthly",
|
||||
"com.affine.payment.title": "Pricing Plans",
|
||||
"com.affine.payment.subtitle-not-signed-in": "This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to your account first.",
|
||||
"com.affine.payment.subtitle-active": "You are current on the {{currentPlan}} plan. If you have any questions, please contact our <3>customer support</3>.",
|
||||
"com.affine.payment.subtitle-canceled": "You are currently on the {{plan}} plan. After the current billing period ends, your account will automatically switch to the Free plan.",
|
||||
"com.affine.payment.discount-amount": "{{amount}}% off",
|
||||
"com.affine.payment.sign-up-free": "Sign up free",
|
||||
"com.affine.payment.buy-pro": "Buy Pro",
|
||||
"com.affine.payment.current-plan": "Current Plan",
|
||||
"com.affine.payment.downgrade": "Downgrade",
|
||||
"com.affine.payment.upgrade": "Upgrade",
|
||||
"com.affine.payment.downgraded-tooltip": "You have successfully downgraded. After the current billing period ends, your account will automatically switch to the Free plan.",
|
||||
"com.affine.payment.contact-sales": "Contact Sales",
|
||||
"com.affine.payment.change-to": "Change to {{to}} Billing",
|
||||
"com.affine.payment.resume": "Resume",
|
||||
"com.affine.payment.resume-renewal": "Resume Auto-renewal",
|
||||
"com.affine.payment.benefit-1": "Unlimited local workspace",
|
||||
"com.affine.payment.benefit-2": "Unlimited login devices",
|
||||
"com.affine.payment.benefit-3": "Unlimited blocks",
|
||||
"com.affine.payment.benefit-4": "AFFiNE Cloud Storage {{capacity}}",
|
||||
"com.affine.payment.benefit-5": "The maximum file size is {{capacity}}",
|
||||
"com.affine.payment.benefit-6": "Number of members per Workspace ≤ {{capacity}}",
|
||||
"com.affine.payment.dynamic-benefit-1": "Best team workspace for collaboration and knowledge distilling.",
|
||||
"com.affine.payment.dynamic-benefit-2": "Focusing on what really matters with team project management and automation.",
|
||||
"com.affine.payment.dynamic-benefit-3": "Pay for seats, fits all team size.",
|
||||
"com.affine.payment.dynamic-benefit-4": "Solutions & best practices for dedicated needs.",
|
||||
"com.affine.payment.dynamic-benefit-5": "Embedable & interrogations with IT support.",
|
||||
"com.affine.payment.see-all-plans": "See all plans",
|
||||
"com.affine.payment.modal.resume.title": "Resume Auto-Renewal?",
|
||||
"com.affine.payment.modal.resume.content": "Are you sure you want to resume the subscription for your pro account? This means your payment method will be charged automatically at the end of each billing cycle, starting from the next billing cycle.",
|
||||
"com.affine.payment.modal.resume.cancel": "Cancel",
|
||||
"com.affine.payment.modal.resume.confirm": "Confirm",
|
||||
"com.affine.payment.modal.downgrade.title": "Are you sure?",
|
||||
"com.affine.payment.modal.downgrade.content": "We're sorry to see you go, but we're always working to improve, and your feedback is welcome. We hope to see you return in the future.",
|
||||
"com.affine.payment.modal.downgrade.caption": "You can still use AFFiNE Cloud Pro until the end of this billing period :)",
|
||||
"com.affine.payment.modal.downgrade.cancel": "Cancel Subscription",
|
||||
"com.affine.payment.modal.downgrade.confirm": "Keep AFFiNE Cloud Pro",
|
||||
"com.affine.payment.modal.change.title": "Change your subscription",
|
||||
"com.affine.payment.modal.change.content": "You are changing your <0>from</0> subscription to <1>to</1> subscription. This change will take effect in the next billing cycle, with an effective date of <2>due</2>.",
|
||||
"com.affine.payment.modal.change.cancel": "Cancel",
|
||||
"com.affine.payment.modal.change.confirm": "Change",
|
||||
"com.affine.payment.updated-notify-title": "Subscription updated",
|
||||
"com.affine.payment.updated-notify-msg": "You have changed your plan to {{plan}} billing.",
|
||||
"com.affine.storage.maximum-tips": "You have reached the maximum capacity limit for your current account",
|
||||
"com.affine.payment.tag-tooltips": "See all plans",
|
||||
"com.affine.payment.billing-setting.title": "Billing",
|
||||
"com.affine.payment.billing-setting.subtitle": "Manage your billing information and invoices.",
|
||||
"com.affine.payment.billing-setting.information": "Information",
|
||||
"com.affine.payment.billing-setting.history": "Billing history",
|
||||
"com.affine.payment.billing-setting.current-plan": "Current Plan",
|
||||
"com.affine.payment.billing-setting.current-plan.description": "You are current on the <1>{{planName}} plan</1>.",
|
||||
"com.affine.payment.billing-setting.current-plan.description.monthly": "You are current on the monthly <1>{{planName}} plan</1>.",
|
||||
"com.affine.payment.billing-setting.current-plan.description.yearly": "You are current on the yearly <1>{{planName}} plan</1>.",
|
||||
"com.affine.payment.billing-setting.month": "month",
|
||||
"com.affine.payment.billing-setting.year": "year",
|
||||
"com.affine.payment.billing-setting.payment-method": "Payment Method",
|
||||
"com.affine.payment.billing-setting.payment-method.description": "Provided by Stripe.",
|
||||
"com.affine.payment.billing-setting.renew-date": "Renew Date",
|
||||
"com.affine.payment.billing-setting.renew-date.description": "Next billing date: {{renewDate}}",
|
||||
"com.affine.payment.billing-setting.expiration-date": "Expiration Date",
|
||||
"com.affine.payment.billing-setting.expiration-date.description": "Your subscription is valid until {{expirationDate}}",
|
||||
"com.affine.payment.billing-setting.cancel-subscription": "Cancel Subscription",
|
||||
"com.affine.payment.billing-setting.cancel-subscription.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}",
|
||||
"com.affine.payment.billing-setting.upgrade": "Upgrade",
|
||||
"com.affine.payment.billing-setting.change-plan": "Change Plan",
|
||||
"com.affine.payment.billing-setting.resume-subscription": "Resume",
|
||||
"com.affine.payment.billing-setting.no-invoice": "There are no invoices to display.",
|
||||
"com.affine.payment.billing-setting.paid": "Paid",
|
||||
"com.affine.payment.billing-setting.view-invoice": "View Invoice",
|
||||
"com.affine.payment.upgrade-success-page.title": "Upgrade Successful!",
|
||||
"com.affine.payment.upgrade-success-page.text": "Congratulations! Your AFFiNE account has been successfully upgraded to a Pro account.",
|
||||
"com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support</1>.",
|
||||
"com.affine.other-page.nav.official-website": "Official Website",
|
||||
"com.affine.other-page.nav.affine-community": "AFFiNE Community",
|
||||
"com.affine.other-page.nav.blog": "Blog",
|
||||
"com.affine.other-page.nav.contact-us": "Contact us",
|
||||
"com.affine.other-page.nav.download-app": "Download App",
|
||||
"com.affine.other-page.nav.open-affine": "Open AFFiNE",
|
||||
"com.affine.payment.member.description": "Manage members here. {{planName}} Users can invite up to {{memberLimit}}",
|
||||
"com.affine.cmdk.affine.switch-state.on": "ON",
|
||||
"com.affine.cmdk.affine.switch-state.off": "OFF"
|
||||
}
|
||||
|
@ -17,7 +17,7 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/sdk": "workspace:*",
|
||||
"@blocksuite/icons": "2.1.35",
|
||||
"@toeverything/components": "^0.0.45",
|
||||
"@toeverything/components": "^0.0.46",
|
||||
"@vanilla-extract/css": "^1.13.0",
|
||||
"clsx": "^2.0.0",
|
||||
"idb": "^7.1.1",
|
||||
|
@ -18,7 +18,7 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/sdk": "workspace:*",
|
||||
"@blocksuite/icons": "2.1.35",
|
||||
"@toeverything/components": "^0.0.45"
|
||||
"@toeverything/components": "^0.0.46"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine/plugin-cli": "workspace:*"
|
||||
|
@ -17,7 +17,7 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/sdk": "workspace:*",
|
||||
"@blocksuite/icons": "2.1.35",
|
||||
"@toeverything/components": "^0.0.45",
|
||||
"@toeverything/components": "^0.0.46",
|
||||
"@toeverything/theme": "^0.7.20",
|
||||
"clsx": "^2.0.0",
|
||||
"foxact": "^0.2.20",
|
||||
|
@ -18,7 +18,7 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/sdk": "workspace:*",
|
||||
"@blocksuite/icons": "2.1.35",
|
||||
"@toeverything/components": "^0.0.45"
|
||||
"@toeverything/components": "^0.0.46"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine/plugin-cli": "workspace:*",
|
||||
|
70
yarn.lock
70
yarn.lock
@ -225,12 +225,14 @@ __metadata:
|
||||
"@toeverything/hooks": "workspace:*"
|
||||
"@toeverything/infra": "workspace:*"
|
||||
"@toeverything/theme": "npm:^0.7.20"
|
||||
"@types/bytes": "npm:^3.1.3"
|
||||
"@types/react": "npm:^18.2.28"
|
||||
"@types/react-datepicker": "npm:^4.19.0"
|
||||
"@types/react-dnd": "npm:^3.0.2"
|
||||
"@types/react-dom": "npm:^18.2.13"
|
||||
"@vanilla-extract/css": "npm:^1.13.0"
|
||||
"@vanilla-extract/dynamic": "npm:^2.0.3"
|
||||
bytes: "npm:^3.1.2"
|
||||
check-password-strength: "npm:^2.0.7"
|
||||
clsx: "npm:^2.0.0"
|
||||
dayjs: "npm:^1.11.10"
|
||||
@ -275,7 +277,7 @@ __metadata:
|
||||
"@affine/plugin-cli": "workspace:*"
|
||||
"@affine/sdk": "workspace:*"
|
||||
"@blocksuite/icons": "npm:2.1.35"
|
||||
"@toeverything/components": "npm:^0.0.45"
|
||||
"@toeverything/components": "npm:^0.0.46"
|
||||
"@types/marked": "npm:^6.0.0"
|
||||
"@vanilla-extract/css": "npm:^1.13.0"
|
||||
clsx: "npm:^2.0.0"
|
||||
@ -329,10 +331,12 @@ __metadata:
|
||||
"@sentry/webpack-plugin": "npm:^2.8.0"
|
||||
"@svgr/webpack": "npm:^8.1.0"
|
||||
"@swc/core": "npm:^1.3.93"
|
||||
"@toeverything/components": "npm:^0.0.45"
|
||||
"@toeverything/components": "npm:^0.0.46"
|
||||
"@types/bytes": "npm:^3.1.3"
|
||||
"@types/lodash-es": "npm:^4.17.9"
|
||||
"@types/webpack-env": "npm:^1.18.2"
|
||||
async-call-rpc: "npm:^6.3.1"
|
||||
bytes: "npm:^3.1.2"
|
||||
copy-webpack-plugin: "npm:^11.0.0"
|
||||
css-loader: "npm:^6.8.1"
|
||||
css-spring: "npm:^4.1.0"
|
||||
@ -490,7 +494,7 @@ __metadata:
|
||||
"@affine/plugin-cli": "workspace:*"
|
||||
"@affine/sdk": "workspace:*"
|
||||
"@blocksuite/icons": "npm:2.1.35"
|
||||
"@toeverything/components": "npm:^0.0.45"
|
||||
"@toeverything/components": "npm:^0.0.46"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@ -516,7 +520,7 @@ __metadata:
|
||||
"@affine/plugin-cli": "workspace:*"
|
||||
"@affine/sdk": "workspace:*"
|
||||
"@blocksuite/icons": "npm:2.1.35"
|
||||
"@toeverything/components": "npm:^0.0.45"
|
||||
"@toeverything/components": "npm:^0.0.46"
|
||||
"@toeverything/theme": "npm:^0.7.20"
|
||||
clsx: "npm:^2.0.0"
|
||||
foxact: "npm:^0.2.20"
|
||||
@ -617,7 +621,7 @@ __metadata:
|
||||
"@affine/plugin-cli": "workspace:*"
|
||||
"@affine/sdk": "workspace:*"
|
||||
"@blocksuite/icons": "npm:2.1.35"
|
||||
"@toeverything/components": "npm:^0.0.45"
|
||||
"@toeverything/components": "npm:^0.0.46"
|
||||
jotai: "npm:^2.4.3"
|
||||
react: "npm:18.2.0"
|
||||
react-dom: "npm:18.2.0"
|
||||
@ -675,6 +679,7 @@ __metadata:
|
||||
"@nestjs/apollo": "npm:^12.0.9"
|
||||
"@nestjs/common": "npm:^10.2.7"
|
||||
"@nestjs/core": "npm:^10.2.7"
|
||||
"@nestjs/event-emitter": "npm:^2.0.2"
|
||||
"@nestjs/graphql": "npm:^12.0.9"
|
||||
"@nestjs/platform-express": "npm:^10.2.7"
|
||||
"@nestjs/platform-socket.io": "npm:^10.2.7"
|
||||
@ -740,6 +745,7 @@ __metadata:
|
||||
semver: "npm:^7.5.4"
|
||||
sinon: "npm:^16.1.0"
|
||||
socket.io: "npm:^4.7.2"
|
||||
stripe: "npm:^14.1.0"
|
||||
supertest: "npm:^6.3.3"
|
||||
ts-node: "npm:^10.9.1"
|
||||
typescript: "npm:^5.2.2"
|
||||
@ -7333,6 +7339,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nestjs/event-emitter@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "@nestjs/event-emitter@npm:2.0.2"
|
||||
dependencies:
|
||||
eventemitter2: "npm:6.4.9"
|
||||
peerDependencies:
|
||||
"@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
"@nestjs/core": ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
reflect-metadata: ^0.1.12
|
||||
checksum: 9c7d2645b14bef5a9d26a8fbafb5963e18c9c15e267980c55abd913c8af9215ae363b8c0fc78711c22126e0a973f80aec8b8e962a64e699f523128d11c033894
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nestjs/graphql@npm:^12.0.9":
|
||||
version: 12.0.9
|
||||
resolution: "@nestjs/graphql@npm:12.0.9"
|
||||
@ -12580,9 +12599,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@toeverything/components@npm:^0.0.45":
|
||||
version: 0.0.45
|
||||
resolution: "@toeverything/components@npm:0.0.45"
|
||||
"@toeverything/components@npm:^0.0.46":
|
||||
version: 0.0.46
|
||||
resolution: "@toeverything/components@npm:0.0.46"
|
||||
dependencies:
|
||||
"@blocksuite/icons": "npm:^2.1.33"
|
||||
"@radix-ui/react-dialog": "npm:^1.0.4"
|
||||
@ -12594,7 +12613,7 @@ __metadata:
|
||||
clsx: ^2
|
||||
react: ^18
|
||||
react-dom: ^18
|
||||
checksum: fb168a83cd04da654ae1723098bf6902bf0eef8be9410a0814514b062d87e5fd2b972746a7770f8ca831a69d4a53255c87750e9fa8beab040d88deebc646dd92
|
||||
checksum: 1e74a620d82bc6f6c318ccac35a2f4f86d5e3b97761e798d0ad3f3b31a74f377e402338ad00a2af54c62dda7d88c231cc07b53f2aa9c9bff1de5088659e1c712
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -12894,6 +12913,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/bytes@npm:^3.1.3":
|
||||
version: 3.1.3
|
||||
resolution: "@types/bytes@npm:3.1.3"
|
||||
checksum: c636b74d5a6f4925f1030382d8afcfe271e329da7c8103d175a874688118b60b0b1523e23477554e29456d024e5a180df1ba99d88c49b3a51433b714acffdac9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/cacheable-request@npm:^6.0.1":
|
||||
version: 6.0.3
|
||||
resolution: "@types/cacheable-request@npm:6.0.3"
|
||||
@ -13485,6 +13511,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:>=8.1.0":
|
||||
version: 20.6.2
|
||||
resolution: "@types/node@npm:20.6.2"
|
||||
checksum: 4b150698cf90c211d4f2f021618f06c33a337d74e9a0ce10ec2e7123f02aacc231eff62118101f56de75f7be309c2da6eb0edb8388d501d4195c50bb919c7a05
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^16.0.0":
|
||||
version: 16.18.58
|
||||
resolution: "@types/node@npm:16.18.58"
|
||||
@ -16387,7 +16420,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bytes@npm:3.1.2":
|
||||
"bytes@npm:3.1.2, bytes@npm:^3.1.2":
|
||||
version: 3.1.2
|
||||
resolution: "bytes@npm:3.1.2"
|
||||
checksum: a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
|
||||
@ -20296,6 +20329,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eventemitter2@npm:6.4.9":
|
||||
version: 6.4.9
|
||||
resolution: "eventemitter2@npm:6.4.9"
|
||||
checksum: b829b1c6b11e15926b635092b5ad62b4463d1c928859831dcae606e988cf41893059e3541f5a8209d21d2f15314422ddd4d84d20830b4bf44978608d15b06b08
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eventemitter3@npm:^3.1.0":
|
||||
version: 3.1.2
|
||||
resolution: "eventemitter3@npm:3.1.2"
|
||||
@ -32291,6 +32331,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"stripe@npm:^14.1.0":
|
||||
version: 14.1.0
|
||||
resolution: "stripe@npm:14.1.0"
|
||||
dependencies:
|
||||
"@types/node": "npm:>=8.1.0"
|
||||
qs: "npm:^6.11.0"
|
||||
checksum: 1363bc6fed873a9d96312a7d33fb422b9e0ca32064cf027eee12cf5cd132a936d3e248013b59d7011ca5a40acaae9adfcbae175345f7c76ee778800ca1077756
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strnum@npm:^1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "strnum@npm:1.0.5"
|
||||
|
Loading…
Reference in New Issue
Block a user