Merge pull request #4410 from toeverything/payment-system

feat: payment system
This commit is contained in:
DarkSky 2023-10-30 07:15:52 +00:00 committed by GitHub
commit f11cc40ae3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 4535 additions and 151 deletions

View File

@ -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=

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user_invoices" ADD COLUMN "link" TEXT;

View File

@ -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"
},

View File

@ -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

View File

@ -363,4 +363,13 @@ export interface AFFiNEConfig {
experimentalMergeWithJwstCodec: boolean;
};
};
payment: {
stripe: {
keys: {
APIKey: string;
webhookKey: string;
};
} & import('stripe').Stripe.StripeConfig;
};
}

View File

@ -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);

View File

@ -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'

View File

@ -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;
}

View 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 {}

View 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',
},
});
}
}

View 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 }
);
}
}
}

View 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;
}
}

View 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],
};

View 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();
}
}
}

View File

@ -7,6 +7,7 @@ import { UsersService } from './users';
@Module({
imports: [StorageModule],
providers: [UserResolver, UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -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`);

View File

@ -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({

View File

@ -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."""

View File

@ -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();
});

View File

@ -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(),

View File

@ -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",

View File

@ -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']}

View File

@ -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',
});

View File

@ -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>
);
};

View File

@ -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') {

View File

@ -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",

View File

@ -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;

View File

@ -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',
});

View File

@ -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>
);
};

View File

@ -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 ? (

View File

@ -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',
});

View File

@ -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>
);
};

View File

@ -0,0 +1,5 @@
import { style } from '@vanilla-extract/css';
export const paymentDisableModalContent = style({
color: 'var(--affine-text-primary-color)',
});

View File

@ -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']()}

View File

@ -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',
});

View File

@ -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>
);
};

View File

@ -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',
});

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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&nbsp;
<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>
);
};

View File

@ -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)',
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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}
/>
</>
);
};

View File

@ -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,
});

View File

@ -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 />}
/>
);
};

View File

@ -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)',
});

View File

@ -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>

View File

@ -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>

View File

@ -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',
});

View File

@ -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',

View File

@ -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',
});

View File

@ -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={{

View 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;
};

View 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)',
},
});

View 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 />;
};

View File

@ -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 />
</>
);
};

View File

@ -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'),

View File

@ -0,0 +1,8 @@
mutation cancelSubscription($idempotencyKey: String!) {
cancelSubscription(idempotencyKey: $idempotencyKey) {
id
status
nextBillAt
canceledAt
}
}

View File

@ -0,0 +1,6 @@
mutation checkout(
$recurring: SubscriptionRecurring!
$idempotencyKey: String!
) {
checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
}

View File

@ -0,0 +1,3 @@
mutation createCustomerPortal {
createCustomerPortal
}

View File

@ -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',

View 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
}
}
}

View File

@ -0,0 +1,9 @@
query prices {
prices {
type
plan
currency
amount
yearlyAmount
}
}

View File

@ -0,0 +1,9 @@
mutation resumeSubscription($idempotencyKey: String!) {
resumeSubscription(idempotencyKey: $idempotencyKey) {
id
status
nextBillAt
start
end
}
}

View File

@ -0,0 +1,14 @@
query subscription {
currentUser {
subscription {
id
status
plan
recurring
start
end
nextBillAt
canceledAt
}
}
}

View File

@ -0,0 +1,14 @@
mutation updateSubscription(
$recurring: SubscriptionRecurring!
$idempotencyKey: String!
) {
updateSubscriptionRecurring(
recurring: $recurring
idempotencyKey: $idempotencyKey
) {
id
plan
recurring
nextBillAt
}
}

View File

@ -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;

View File

@ -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"
}

View File

@ -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",

View File

@ -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:*"

View File

@ -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",

View File

@ -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:*",

View File

@ -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"