@@ -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>
|
||||
);
|
||||
}
|
||||
41
common/resources/client/admin/plans/plans-index-page-filters.ts
Executable file
41
common/resources/client/admin/plans/plans-index-page-filters.ts
Executable file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '../../datatable/filters/backend-filter';
|
||||
import {message} from '../../i18n/message';
|
||||
import {
|
||||
createdAtFilter,
|
||||
updatedAtFilter,
|
||||
} from '@common/datatable/filters/timestamp-filters';
|
||||
|
||||
export const PlansIndexPageFilters: BackendFilter[] = [
|
||||
{
|
||||
key: 'subscriptions',
|
||||
label: message('Subscriptions'),
|
||||
description: message('Whether plan has any active subscriptions'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: '01',
|
||||
options: [
|
||||
{
|
||||
key: '01',
|
||||
label: message('Has active subscriptions'),
|
||||
value: {value: '*', operator: FilterOperator.has},
|
||||
},
|
||||
{
|
||||
key: '02',
|
||||
label: message('Does not have active subscriptions'),
|
||||
value: {value: '*', operator: FilterOperator.doesntHave},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
createdAtFilter({
|
||||
description: message('Date plan was created'),
|
||||
}),
|
||||
updatedAtFilter({
|
||||
description: message('Date plan was last updated'),
|
||||
}),
|
||||
];
|
||||
164
common/resources/client/admin/plans/plans-index-page.tsx
Executable file
164
common/resources/client/admin/plans/plans-index-page.tsx
Executable file
@@ -0,0 +1,164 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {DataTablePage} from '../../datatable/page/data-table-page';
|
||||
import {IconButton} from '../../ui/buttons/icon-button';
|
||||
import {EditIcon} from '../../icons/material/Edit';
|
||||
import {FormattedDate} from '../../i18n/formatted-date';
|
||||
import {ColumnConfig} from '../../datatable/column-config';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';
|
||||
import softwareEngineerSvg from './../tags/software-engineer.svg';
|
||||
import {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';
|
||||
import {Product} from '../../billing/product';
|
||||
import {NameWithAvatar} from '../../datatable/column-templates/name-with-avatar';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {FormattedPrice} from '../../i18n/formatted-price';
|
||||
import {SyncIcon} from '../../icons/material/Sync';
|
||||
import {useSyncProducts} from './requests/use-sync-products';
|
||||
import {Tooltip} from '../../ui/tooltip/tooltip';
|
||||
import {useDeleteProduct} from './requests/use-delete-product';
|
||||
import {DeleteIcon} from '../../icons/material/Delete';
|
||||
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '../../ui/overlays/dialog/confirmation-dialog';
|
||||
import {useNavigate} from '../../utils/hooks/use-navigate';
|
||||
import {PlansIndexPageFilters} from './plans-index-page-filters';
|
||||
|
||||
const columnConfig: ColumnConfig<Product>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
allowsSorting: true,
|
||||
visibleInMode: 'all',
|
||||
header: () => <Trans message="Name" />,
|
||||
body: product => {
|
||||
const price = product.prices[0];
|
||||
return (
|
||||
<NameWithAvatar
|
||||
label={product.name}
|
||||
description={
|
||||
product.free ? (
|
||||
<Trans message="Free" />
|
||||
) : (
|
||||
<FormattedPrice price={price} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
allowsSorting: true,
|
||||
maxWidth: 'max-w-100',
|
||||
header: () => <Trans message="Created" />,
|
||||
body: product => <FormattedDate date={product.created_at} />,
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
allowsSorting: true,
|
||||
maxWidth: 'max-w-100',
|
||||
header: () => <Trans message="Last updated" />,
|
||||
body: product => <FormattedDate date={product.updated_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
visibleInMode: 'all',
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
maxWidth: 'max-w-84',
|
||||
body: product => {
|
||||
return (
|
||||
<Fragment>
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
elementType={Link}
|
||||
to={`/admin/plans/${product.id}/edit`}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<DeleteProductButton product={product} />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function PlansIndexPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<DataTablePage
|
||||
endpoint="billing/products"
|
||||
title={<Trans message="Subscription plans" />}
|
||||
columns={columnConfig}
|
||||
actions={<Actions />}
|
||||
enableSelection={false}
|
||||
filters={PlansIndexPageFilters}
|
||||
onRowAction={item => {
|
||||
navigate(`/admin/plans/${item.id}/edit`);
|
||||
}}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={softwareEngineerSvg}
|
||||
title={<Trans message="No plans have been created yet" />}
|
||||
filteringTitle={<Trans message="No matching plans" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteProductButtonProps {
|
||||
product: Product;
|
||||
}
|
||||
function DeleteProductButton({product}: DeleteProductButtonProps) {
|
||||
const deleteProduct = useDeleteProduct();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={confirmed => {
|
||||
if (confirmed) {
|
||||
deleteProduct.mutate({productId: product.id});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip label={<Trans message="Delete plan" />}>
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
disabled={deleteProduct.isPending}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ConfirmationDialog
|
||||
title={<Trans message="Delete plan" />}
|
||||
body={<Trans message="Are you sure you want to delete this plan?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
const syncPlans = useSyncProducts();
|
||||
return (
|
||||
<Fragment>
|
||||
<Tooltip label={<Trans message="Sync plans with Stripe & PayPal" />}>
|
||||
<IconButton
|
||||
color="primary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={syncPlans.isPending}
|
||||
onClick={() => {
|
||||
syncPlans.mutate();
|
||||
}}
|
||||
>
|
||||
<SyncIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<DataTableAddItemButton elementType={Link} to="/admin/plans/new">
|
||||
<Trans message="Add new plan" />
|
||||
</DataTableAddItemButton>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
44
common/resources/client/admin/plans/requests/use-create-product.ts
Executable file
44
common/resources/client/admin/plans/requests/use-create-product.ts
Executable file
@@ -0,0 +1,44 @@
|
||||
import {Product} from '../../../billing/product';
|
||||
import {useTrans} from '../../../i18n/use-trans';
|
||||
import {useNavigate} from '../../../utils/hooks/use-navigate';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '../../../ui/toast/toast';
|
||||
import {message} from '../../../i18n/message';
|
||||
import {apiClient, queryClient} from '../../../http/query-client';
|
||||
import {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';
|
||||
import {Price} from '../../../billing/price';
|
||||
import {onFormQueryError} from '../../../errors/on-form-query-error';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
|
||||
const endpoint = 'billing/products';
|
||||
|
||||
export interface CreateProductPayload
|
||||
extends Omit<Partial<Product>, 'feature_list' | 'prices'> {
|
||||
feature_list: {value: string}[];
|
||||
prices: Omit<Price, 'id'>[];
|
||||
}
|
||||
|
||||
export function useCreateProduct(form: UseFormReturn<CreateProductPayload>) {
|
||||
const {trans} = useTrans();
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateProductPayload) => createProduct(payload),
|
||||
onSuccess: () => {
|
||||
toast(trans(message('Plan created')));
|
||||
queryClient.invalidateQueries({queryKey: [endpoint]});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('billing/products'),
|
||||
});
|
||||
navigate('/admin/plans');
|
||||
},
|
||||
onError: err => onFormQueryError(err, form),
|
||||
});
|
||||
}
|
||||
|
||||
function createProduct(payload: CreateProductPayload): Promise<Response> {
|
||||
const backendPayload = {
|
||||
...payload,
|
||||
feature_list: payload.feature_list.map(feature => feature.value),
|
||||
};
|
||||
return apiClient.post(endpoint, backendPayload).then(r => r.data);
|
||||
}
|
||||
34
common/resources/client/admin/plans/requests/use-delete-product.ts
Executable file
34
common/resources/client/admin/plans/requests/use-delete-product.ts
Executable file
@@ -0,0 +1,34 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '../../../http/query-client';
|
||||
import {BackendResponse} from '../../../http/backend-response/backend-response';
|
||||
import {toast} from '../../../ui/toast/toast';
|
||||
import {useTrans} from '../../../i18n/use-trans';
|
||||
import {message} from '../../../i18n/message';
|
||||
import {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';
|
||||
import {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';
|
||||
|
||||
const endpoint = (id: number) => `billing/products/${id}`;
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
productId: number;
|
||||
}
|
||||
|
||||
export function useDeleteProduct() {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => updateProduct(payload),
|
||||
onSuccess: () => {
|
||||
toast(trans(message('Plan deleted')));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('billing/products'),
|
||||
});
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function updateProduct({productId}: Payload): Promise<Response> {
|
||||
return apiClient.delete(endpoint(productId)).then(r => r.data);
|
||||
}
|
||||
23
common/resources/client/admin/plans/requests/use-product.ts
Executable file
23
common/resources/client/admin/plans/requests/use-product.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {Product} from '@common/billing/product';
|
||||
|
||||
const Endpoint = (id: number | string) => `billing/products/${id}`;
|
||||
|
||||
export interface FetchRoleResponse extends BackendResponse {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export function useProduct() {
|
||||
const {productId} = useParams();
|
||||
return useQuery({
|
||||
queryKey: [Endpoint(productId!)],
|
||||
queryFn: () => fetchProduct(productId!),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchProduct(productId: number | string): Promise<FetchRoleResponse> {
|
||||
return apiClient.get(Endpoint(productId)).then(response => response.data);
|
||||
}
|
||||
24
common/resources/client/admin/plans/requests/use-sync-products.ts
Executable file
24
common/resources/client/admin/plans/requests/use-sync-products.ts
Executable file
@@ -0,0 +1,24 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient} from '../../../http/query-client';
|
||||
import {useTrans} from '../../../i18n/use-trans';
|
||||
import {BackendResponse} from '../../../http/backend-response/backend-response';
|
||||
import {toast} from '../../../ui/toast/toast';
|
||||
import {message} from '../../../i18n/message';
|
||||
import {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useSyncProducts() {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: () => syncPlans(),
|
||||
onSuccess: () => {
|
||||
toast(trans(message('Plans synced')));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err, message('Could not sync plans')),
|
||||
});
|
||||
}
|
||||
|
||||
function syncPlans(): Promise<Response> {
|
||||
return apiClient.post('billing/products/sync').then(r => r.data);
|
||||
}
|
||||
52
common/resources/client/admin/plans/requests/use-update-product.ts
Executable file
52
common/resources/client/admin/plans/requests/use-update-product.ts
Executable file
@@ -0,0 +1,52 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '../../../http/query-client';
|
||||
import {BackendResponse} from '../../../http/backend-response/backend-response';
|
||||
import {toast} from '../../../ui/toast/toast';
|
||||
import {useTrans} from '../../../i18n/use-trans';
|
||||
import {message} from '../../../i18n/message';
|
||||
import {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';
|
||||
import {Product} from '../../../billing/product';
|
||||
import {useNavigate} from '../../../utils/hooks/use-navigate';
|
||||
import {CreateProductPayload} from './use-create-product';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '../../../errors/on-form-query-error';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export interface UpdateProductPayload extends CreateProductPayload {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const Endpoint = (id: number) => `billing/products/${id}`;
|
||||
|
||||
export function useUpdateProduct(form: UseFormReturn<UpdateProductPayload>) {
|
||||
const {trans} = useTrans();
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: (payload: UpdateProductPayload) => updateProduct(payload),
|
||||
onSuccess: response => {
|
||||
toast(trans(message('Plan updated')));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [Endpoint(response.product.id)],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('billing/products'),
|
||||
});
|
||||
navigate('/admin/plans');
|
||||
},
|
||||
onError: err => onFormQueryError(err, form),
|
||||
});
|
||||
}
|
||||
|
||||
function updateProduct({
|
||||
id,
|
||||
...payload
|
||||
}: UpdateProductPayload): Promise<Response> {
|
||||
const backendPayload = {
|
||||
...payload,
|
||||
feature_list: payload.feature_list.map(feature => feature.value),
|
||||
};
|
||||
return apiClient.put(Endpoint(id), backendPayload).then(r => r.data);
|
||||
}
|
||||
Reference in New Issue
Block a user