@@ -0,0 +1,46 @@
|
||||
import {message} from '../../../i18n/message';
|
||||
|
||||
export const BillingPeriodPresets = [
|
||||
{
|
||||
key: 'day1',
|
||||
label: message('Daily'),
|
||||
interval: 'day',
|
||||
interval_count: 1,
|
||||
},
|
||||
{
|
||||
key: 'week1',
|
||||
label: message('Weekly'),
|
||||
interval: 'week',
|
||||
interval_count: 1,
|
||||
},
|
||||
{
|
||||
key: 'month1',
|
||||
label: message('Monthly'),
|
||||
interval: 'month',
|
||||
interval_count: 1,
|
||||
},
|
||||
{
|
||||
key: 'month3',
|
||||
label: message('Every 3 months'),
|
||||
interval: 'month',
|
||||
interval_count: 3,
|
||||
},
|
||||
{
|
||||
key: 'month6',
|
||||
label: message('Every 6 months'),
|
||||
interval: 'month',
|
||||
interval_count: 6,
|
||||
},
|
||||
{
|
||||
key: 'year1',
|
||||
label: message('Yearly'),
|
||||
interval: 'year',
|
||||
interval_count: 1,
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
label: message('Custom'),
|
||||
interval: null,
|
||||
interval_count: null,
|
||||
},
|
||||
];
|
||||
31
common/resources/client/admin/plans/crupdate-plan-page/create-plan-page.tsx
Executable file
31
common/resources/client/admin/plans/crupdate-plan-page/create-plan-page.tsx
Executable file
@@ -0,0 +1,31 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {CrupdateResourceLayout} from '../../crupdate-resource-layout';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import {CrupdatePlanForm} from './crupdate-plan-form';
|
||||
import {
|
||||
CreateProductPayload,
|
||||
useCreateProduct,
|
||||
} from '../requests/use-create-product';
|
||||
|
||||
export function CreatePlanPage() {
|
||||
const form = useForm<CreateProductPayload>({
|
||||
defaultValues: {
|
||||
free: false,
|
||||
recommended: false,
|
||||
},
|
||||
});
|
||||
const createProduct = useCreateProduct(form);
|
||||
|
||||
return (
|
||||
<CrupdateResourceLayout
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
createProduct.mutate(values);
|
||||
}}
|
||||
title={<Trans message="Create new plan" />}
|
||||
isLoading={createProduct.isPending}
|
||||
>
|
||||
<CrupdatePlanForm />
|
||||
</CrupdateResourceLayout>
|
||||
);
|
||||
}
|
||||
230
common/resources/client/admin/plans/crupdate-plan-page/crupdate-plan-form.tsx
Executable file
230
common/resources/client/admin/plans/crupdate-plan-page/crupdate-plan-form.tsx
Executable file
@@ -0,0 +1,230 @@
|
||||
import {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import React, {Fragment, ReactNode} from 'react';
|
||||
import {useFieldArray, useFormContext} from 'react-hook-form';
|
||||
import {Accordion, AccordionItem} from '../../../ui/accordion/accordion';
|
||||
import {FormattedPrice} from '../../../i18n/formatted-price';
|
||||
import {FormPermissionSelector} from '../../../auth/ui/permission-selector';
|
||||
import {PriceForm} from './price-form';
|
||||
import {Button} from '../../../ui/buttons/button';
|
||||
import {AddIcon} from '../../../icons/material/Add';
|
||||
import {IconButton} from '../../../ui/buttons/icon-button';
|
||||
import {CloseIcon} from '../../../icons/material/Close';
|
||||
import {CreateProductPayload} from '../requests/use-create-product';
|
||||
import {FormSwitch} from '../../../ui/forms/toggle/switch';
|
||||
import {FormSelect} from '../../../ui/forms/select/select';
|
||||
import {Item} from '../../../ui/forms/listbox/item';
|
||||
import {FormFileSizeField} from '../../../ui/forms/input-field/file-size-field';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {LinkStyle} from '../../../ui/buttons/external-link';
|
||||
|
||||
export function CrupdatePlanForm() {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
name="name"
|
||||
label={<Trans message="Name" />}
|
||||
className="mb-20"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<FormTextField
|
||||
name="description"
|
||||
label={<Trans message="Description" />}
|
||||
className="mb-20"
|
||||
inputElementType="textarea"
|
||||
rows={4}
|
||||
/>
|
||||
<FormSelect
|
||||
name="position"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Position in pricing table" />}
|
||||
className="mb-20"
|
||||
>
|
||||
<Item value={0}>
|
||||
<Trans message="First" />
|
||||
</Item>
|
||||
<Item value={1}>
|
||||
<Trans message="Second" />
|
||||
</Item>
|
||||
<Item value={2}>
|
||||
<Trans message="Third" />
|
||||
</Item>
|
||||
<Item value={3}>
|
||||
<Trans message="Fourth" />
|
||||
</Item>
|
||||
<Item value={4}>
|
||||
<Trans message="Fifth" />
|
||||
</Item>
|
||||
</FormSelect>
|
||||
<FormFileSizeField
|
||||
className="mb-30"
|
||||
name="available_space"
|
||||
label={<Trans message="Allowed storage space" />}
|
||||
description={
|
||||
<Trans
|
||||
values={{
|
||||
a: parts => (
|
||||
<Link
|
||||
className={LinkStyle}
|
||||
target="_blank"
|
||||
to="/admin/settings/uploading"
|
||||
>
|
||||
{parts}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
message="Total storage space all user uploads are allowed to take up."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormSwitch
|
||||
name="recommended"
|
||||
className="mb-20"
|
||||
description={
|
||||
<Trans message="Plan will be displayed more prominently on pricing page." />
|
||||
}
|
||||
>
|
||||
<Trans message="Recommend" />
|
||||
</FormSwitch>
|
||||
<FormSwitch
|
||||
name="hidden"
|
||||
className="mb-20"
|
||||
description={
|
||||
<Trans message="Plan will not be shown on pricing or upgrade pages." />
|
||||
}
|
||||
>
|
||||
<Trans message="Hidden" />
|
||||
</FormSwitch>
|
||||
<FormSwitch
|
||||
name="free"
|
||||
className="mb-20"
|
||||
description={
|
||||
<Trans message="Will be assigned to all users, if they are not subscribed already." />
|
||||
}
|
||||
>
|
||||
<Trans message="Free" />
|
||||
</FormSwitch>
|
||||
<Header>
|
||||
<Trans message="Feature list" />
|
||||
</Header>
|
||||
<FeatureListForm />
|
||||
<PricingListForm />
|
||||
<Header>
|
||||
<Trans message="Permissions" />
|
||||
</Header>
|
||||
<FormPermissionSelector name="permissions" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
function Header({children}: HeaderProps) {
|
||||
return <h2 className="mt-40 mb-20 text-base font-semibold">{children}</h2>;
|
||||
}
|
||||
|
||||
function FeatureListForm() {
|
||||
const {fields, append, remove} = useFieldArray<CreateProductPayload>({
|
||||
name: 'feature_list',
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div key={field.id} className="flex gap-10 mb-10">
|
||||
<FormTextField
|
||||
name={`feature_list.${index}.value`}
|
||||
size="sm"
|
||||
className="flex-auto"
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="text"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
append({value: ''});
|
||||
}}
|
||||
>
|
||||
<Trans message="Add another line" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingListForm() {
|
||||
const {
|
||||
watch,
|
||||
formState: {errors},
|
||||
} = useFormContext<CreateProductPayload>();
|
||||
const {fields, append, remove} = useFieldArray<
|
||||
CreateProductPayload,
|
||||
'prices',
|
||||
'key'
|
||||
>({
|
||||
name: 'prices',
|
||||
keyName: 'key',
|
||||
});
|
||||
|
||||
// if plan is marked as free, hide pricing form
|
||||
if (watch('free')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Header>
|
||||
<Trans message="Pricing" />
|
||||
</Header>
|
||||
{errors.prices?.message && (
|
||||
<div className="text-sm text-danger mb-20">{errors.prices.message}</div>
|
||||
)}
|
||||
<Accordion variant="outline" className="mb-10">
|
||||
{fields.map((field, index) => (
|
||||
<AccordionItem
|
||||
label={<FormattedPrice price={field} />}
|
||||
key={field.key}
|
||||
>
|
||||
<PriceForm
|
||||
index={index}
|
||||
onRemovePrice={() => {
|
||||
remove(index);
|
||||
}}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
<Button
|
||||
variant="text"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
append({
|
||||
currency: 'USD',
|
||||
amount: 1,
|
||||
interval_count: 1,
|
||||
interval: 'month',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Add another price" />
|
||||
</Button>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
49
common/resources/client/admin/plans/crupdate-plan-page/edit-plan-page.tsx
Executable file
49
common/resources/client/admin/plans/crupdate-plan-page/edit-plan-page.tsx
Executable file
@@ -0,0 +1,49 @@
|
||||
import {FullPageLoader} from '../../../ui/progress/full-page-loader';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {CrupdateResourceLayout} from '../../crupdate-resource-layout';
|
||||
import {useProduct} from '../requests/use-product';
|
||||
import {Product} from '../../../billing/product';
|
||||
import {CrupdatePlanForm} from './crupdate-plan-form';
|
||||
import {
|
||||
UpdateProductPayload,
|
||||
useUpdateProduct,
|
||||
} from '../requests/use-update-product';
|
||||
|
||||
export function EditPlanPage() {
|
||||
const query = useProduct();
|
||||
|
||||
if (query.status !== 'success') {
|
||||
return <FullPageLoader />;
|
||||
}
|
||||
|
||||
return <PageContent product={query.data.product} />;
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
product: Product;
|
||||
}
|
||||
function PageContent({product}: PageContentProps) {
|
||||
const form = useForm<UpdateProductPayload>({
|
||||
defaultValues: {
|
||||
...product,
|
||||
feature_list: product.feature_list.map(f => ({value: f})),
|
||||
},
|
||||
});
|
||||
const updateProduct = useUpdateProduct(form);
|
||||
|
||||
return (
|
||||
<CrupdateResourceLayout
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
updateProduct.mutate(values);
|
||||
}}
|
||||
title={
|
||||
<Trans message="Edit “:name“ plan" values={{name: product.name}} />
|
||||
}
|
||||
isLoading={updateProduct.isPending}
|
||||
>
|
||||
<CrupdatePlanForm />
|
||||
</CrupdateResourceLayout>
|
||||
);
|
||||
}
|
||||
210
common/resources/client/admin/plans/crupdate-plan-page/price-form.tsx
Executable file
210
common/resources/client/admin/plans/crupdate-plan-page/price-form.tsx
Executable file
@@ -0,0 +1,210 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {Product} from '@common/billing/product';
|
||||
import React, {Fragment, useMemo, useState} from 'react';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {FormSelect, Select} from '@common/ui/forms/select/select';
|
||||
import {Price} from '@common/billing/price';
|
||||
import {BillingPeriodPresets} from '@common/admin/plans/crupdate-plan-page/billing-period-presets';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
|
||||
interface PriceFormProps {
|
||||
index: number;
|
||||
onRemovePrice: () => void;
|
||||
}
|
||||
export function PriceForm({index, onRemovePrice}: PriceFormProps) {
|
||||
const {trans} = useTrans();
|
||||
const query = useValueLists(['currencies']);
|
||||
const currencies = useMemo(() => {
|
||||
return query.data?.currencies ? Object.values(query.data.currencies) : [];
|
||||
}, [query.data]);
|
||||
const {watch, getValues} = useFormContext<Product>();
|
||||
const isNewProduct = !watch('id');
|
||||
const isNewPrice = watch(`prices.${index}.id`) == null;
|
||||
const subscriberCount = watch(`prices.${index}.subscriptions_count`) || 0;
|
||||
|
||||
// select billing period preset based on price "interval" and "interval_count"
|
||||
const [billingPeriodPreset, setBillingPeriodPreset] = useState(() => {
|
||||
const interval = getValues(`prices.${index}.interval`);
|
||||
const intervalCount = getValues(`prices.${index}.interval_count`);
|
||||
const preset = BillingPeriodPresets.find(
|
||||
p => p.key === `${interval}${intervalCount}`
|
||||
);
|
||||
return preset ? preset.key : 'custom';
|
||||
});
|
||||
|
||||
const allowPriceChanges = isNewProduct || isNewPrice || !subscriberCount;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{!allowPriceChanges && (
|
||||
<p className="text-muted text-sm max-w-500 mb-20">
|
||||
<Trans
|
||||
message="This price can't modified or deleted, because it has [one 1 subscriber|other :count subscribers]. You can instead add a new price."
|
||||
values={{count: subscriberCount}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FormTextField
|
||||
required
|
||||
disabled={!allowPriceChanges}
|
||||
label={<Trans message="Amount" />}
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.01}
|
||||
name={`prices.${index}.amount`}
|
||||
className="mb-20"
|
||||
/>
|
||||
<FormSelect
|
||||
required
|
||||
disabled={!allowPriceChanges}
|
||||
label={<Trans message="Currency" />}
|
||||
name={`prices.${index}.currency`}
|
||||
items={currencies}
|
||||
showSearchField
|
||||
searchPlaceholder={trans(message('Search currencies'))}
|
||||
selectionMode="single"
|
||||
className="mb-20"
|
||||
>
|
||||
{item => (
|
||||
<Item
|
||||
value={item.code}
|
||||
key={item.code}
|
||||
>{`${item.code}: ${item.name}`}</Item>
|
||||
)}
|
||||
</FormSelect>
|
||||
<BillingPeriodSelect
|
||||
disabled={!allowPriceChanges}
|
||||
index={index}
|
||||
value={billingPeriodPreset}
|
||||
onValueChange={setBillingPeriodPreset}
|
||||
/>
|
||||
{billingPeriodPreset === 'custom' && (
|
||||
<CustomBillingPeriodField disabled={!allowPriceChanges} index={index} />
|
||||
)}
|
||||
<div className="text-right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="danger"
|
||||
disabled={!allowPriceChanges}
|
||||
onClick={() => {
|
||||
onRemovePrice();
|
||||
}}
|
||||
>
|
||||
<Trans message="Delete price" />
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface BillingPeriodSelectProps {
|
||||
index: number;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
function BillingPeriodSelect({
|
||||
index,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}: BillingPeriodSelectProps) {
|
||||
const {setValue: setFormValue} = useFormContext<Product>();
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={<Trans message="Billing period" />}
|
||||
disabled={disabled}
|
||||
className="mb-20"
|
||||
selectionMode="single"
|
||||
selectedValue={value}
|
||||
onSelectionChange={value => {
|
||||
onValueChange(value as string);
|
||||
if (value === 'custom') {
|
||||
} else {
|
||||
const preset = BillingPeriodPresets.find(p => p.key === value);
|
||||
if (preset) {
|
||||
setFormValue(
|
||||
`prices.${index}.interval`,
|
||||
preset.interval as Price['interval']
|
||||
);
|
||||
setFormValue(
|
||||
`prices.${index}.interval_count`,
|
||||
preset.interval_count as number
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{BillingPeriodPresets.map(preset => (
|
||||
<Item key={preset.key} value={preset.key}>
|
||||
<Trans {...preset.label} />
|
||||
</Item>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomBillingPeriodFieldProps {
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
}
|
||||
function CustomBillingPeriodField({
|
||||
index,
|
||||
disabled,
|
||||
}: CustomBillingPeriodFieldProps) {
|
||||
const {watch} = useFormContext<Product>();
|
||||
const interval = watch(`prices.${index}.interval`);
|
||||
let maxIntervalCount: number;
|
||||
|
||||
if (interval === 'day') {
|
||||
maxIntervalCount = 365;
|
||||
} else if (interval === 'week') {
|
||||
maxIntervalCount = 52;
|
||||
} else {
|
||||
maxIntervalCount = 12;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex border rounded w-min">
|
||||
<div className="px-18 flex items-center text-sm">
|
||||
<Trans message="Every" />
|
||||
</div>
|
||||
<FormTextField
|
||||
inputShadow="shadow-none"
|
||||
inputBorder="border-none"
|
||||
className="border-l border-r w-80"
|
||||
name={`prices.${index}.interval_count`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxIntervalCount}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
<FormSelect
|
||||
inputShadow="shadow-none"
|
||||
inputBorder="border-none"
|
||||
name={`prices.${index}.interval`}
|
||||
selectionMode="single"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Item value="day">
|
||||
<Trans message="Days" />
|
||||
</Item>
|
||||
<Item value="week">
|
||||
<Trans message="Weeks" />
|
||||
</Item>
|
||||
<Item value="month">
|
||||
<Trans message="Months" />
|
||||
</Item>
|
||||
</FormSelect>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user