first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

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

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

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

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

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

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

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

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

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

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

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

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