manual improvement 2 & add telegram bot

This commit is contained in:
hamid
2026-07-27 23:58:16 +03:30
parent baa3cc63cd
commit e6a8f93a1e
57 changed files with 4181 additions and 2484 deletions
@@ -1,9 +1,24 @@
'use client';
import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppIcon,
AppLoading,
CategoryTile,
ErrorState,
FormSection,
PageHeader,
RhfTextField,
StepperHeader,
SurfaceCard,
VariantCard,
} from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ApiError } from '@/lib/api/errors';
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
import {
@@ -23,7 +38,7 @@ import {
} from '@/services/catalog/types';
interface VariantBuilderProps {
/** `null` = create (3-step stepper); a variant = edit (category/options locked, price form only). */
/** `null` = create (stepped flow); a variant = edit (category/options locked, price form only). */
initial: NurseServiceVariant | null;
onDone: () => void;
onCancel: () => void;
@@ -35,15 +50,40 @@ const DEFAULT_UNIT: PriceUnit = 'per_hour';
const MAX_PRICE_DIGITS = 12;
const MAX_DURATION_DIGITS = 4;
type StepKey = 'category' | 'options' | 'price';
interface VariantFormValues {
categoryId: number | null;
/** Option-group id → chosen value id. One field, so a category switch clears the whole answer set. */
options: Record<number, number>;
priceToman: string;
priceUnit: PriceUnit;
duration: string;
/** `null` until the nurse types over the auto-generated name — blank means "let the server name it". */
displayNameOverride: string | null;
}
/**
* The nurse variant builder (`CreateVariant` / `UpdateVariant`).
*
* **Create** is a 3-step stepper: pick category → answer required/optional option groups → price +
* unit + duration. Every `is_required` group must be answered before advancing; the price is entered
* in **Toman** and converted to an IRR digit-string at the field boundary (`tomanToRial`, integer-safe,
* never a float); the estimated total is shown only from `price` × `sessionCount`, never `price` alone;
* `display_name` auto-generates from the chosen labels and is editable (left blank ⇒ the server
* generates it). A duplicate identical listing (`409`) shows a friendly inline warning.
* **Create** walks category → options → price. Two things make the flow deterministic where it
* previously was not:
*
* 1. **A step that has nothing to ask is not shown.** Categories with no option groups used to get a
* middle step whose entire content was "این دسته گزینه‌ای برای تنظیم ندارد" plus a Next button.
* The step list is now derived from the loaded groups, so those categories go straight to pricing.
* 2. **Advancing is gated *before* the tap, not after it.** The old Next button was always enabled and
* surfaced an error only once pressed, from a separate `optionsError` flag. Now the unanswered
* required groups are named under the button while it is disabled, so the blocker is visible
* without probing for it.
*
* The final step is a review as well as a form: the chosen category and options are recapped as chips
* beside the live `VariantCard`, so the listing can be checked without stepping backwards.
*
* Money still crosses the field boundary exactly once — the price is entered in **Toman** and
* converted to an IRR digit-string via `tomanToRial` (integer-safe, never a float); the estimated
* total is shown only from `price` × `sessionCount`, never `price` alone. A duplicate identical
* listing (`409`) shows a friendly inline warning that offers to edit the colliding listing instead.
*
* **Edit** locks the category + option-set (changing them would change identity) and edits only
* price/unit/duration/display via `update`.
@@ -61,21 +101,29 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const updateVariant = useUpdateVariant();
const submitting = createVariant.isPending || updateVariant.isPending;
// --- Create-only state (category → options) ---
const [activeStep, setActiveStep] = useState(0);
const [categoryId, setCategoryId] = useState<number | null>(initial?.serviceCategoryId ?? null);
const [selectedOptions, setSelectedOptions] = useState<Record<number, number>>({});
const [optionsError, setOptionsError] = useState(false);
// --- Shared price state (both create step 3 and edit) ---
// Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10.
const [priceToman, setPriceToman] = useState(initial ? String(rialToToman(initial.price)) : '');
const [priceUnit, setPriceUnit] = useState<PriceUnit>(initial?.priceUnit ?? DEFAULT_UNIT);
const [durationStr, setDurationStr] = useState(initial?.sessionCount ? String(initial.sessionCount) : '');
const [displayNameOverride, setDisplayNameOverride] = useState<string | null>(null);
const [priceError, setPriceError] = useState(false);
const [step, setStep] = useState<StepKey>(isEdit ? 'price' : 'category');
const [duplicate, setDuplicate] = useState(false);
const form = useForm<VariantFormValues>({
mode: 'onTouched',
defaultValues: {
categoryId: initial?.serviceCategoryId ?? null,
options: {},
// Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10.
priceToman: initial ? String(rialToToman(initial.price)) : '',
priceUnit: initial?.priceUnit ?? DEFAULT_UNIT,
duration: initial?.sessionCount ? String(initial.sessionCount) : '',
displayNameOverride: null,
},
});
const { control, handleSubmit, setValue } = form;
const categoryId = useWatch({ control, name: 'categoryId' });
const selectedOptions = useWatch({ control, name: 'options' });
const priceToman = useWatch({ control, name: 'priceToman' });
const priceUnit = useWatch({ control, name: 'priceUnit' });
const duration = useWatch({ control, name: 'duration' });
const displayNameOverride = useWatch({ control, name: 'displayNameOverride' });
const categoriesQuery = useServiceCategories();
const categories = categoriesQuery.data?.items ?? [];
const optionGroupsQuery = useCategoryOptionGroups(isEdit ? null : categoryId);
@@ -85,6 +133,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null);
// A category with no option groups has no question to ask, so its step doesn't exist. Until the
// groups for the chosen category have actually loaded the answer is unknown, and the assumption is
// "there is an options step" — that way the step count only ever collapses for a category proven to
// have none, instead of starting at two and growing the moment a category is tapped.
const optionsStepUnknown = categoryId == null || optionGroupsQuery.isLoading || optionGroupsQuery.isFetching;
const hasOptionsStep = !isEdit && (optionsStepUnknown || groups.length > 0);
const effectiveStep: StepKey = step === 'options' && !hasOptionsStep ? 'price' : step;
const visibleSteps: StepKey[] = hasOptionsStep ? ['category', 'options', 'price'] : ['category', 'price'];
const stepLabels: Record<StepKey, string> = {
category: t('step_category'),
options: t('step_options'),
price: t('step_price'),
};
// Reuses the already-cached offerings list (MyServicesList holds the same query) to resolve which
// existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly.
const myVariantsQuery = useMyVariants();
@@ -118,53 +181,51 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0);
const irr = priceValid ? tomanToRial(priceToman) : null;
const durationInt = durationStr ? Number(durationStr) : 0;
const durationInt = duration ? Number(duration) : 0;
const sessionCount = durationInt > 0 ? durationInt : null;
const selectCategory = (id: number) => {
if (id === categoryId) return;
// Switching category invalidates the previous category's option answers + auto-name.
setCategoryId(id);
setSelectedOptions({});
setDisplayNameOverride(null);
setOptionsError(false);
setValue('categoryId', id, { shouldDirty: true });
setValue('options', {});
setValue('displayNameOverride', null);
};
const changeOption = (groupId: number, valueId: number | null) => {
setOptionsError(false);
const next = { ...selectedOptions };
if (valueId == null) delete next[groupId];
else next[groupId] = valueId;
// A manual displayName override is intentionally left untouched; the auto-name preview tracks
// option changes only while the field hasn't been overridden (displayValue = override ?? autoName).
setSelectedOptions((prev) => {
const next = { ...prev };
if (valueId == null) delete next[groupId];
else next[groupId] = valueId;
return next;
});
setValue('options', next, { shouldDirty: true });
};
const goNextFromOptions = () => {
if (missingRequiredGroups.length > 0) {
setOptionsError(true);
const goNext = () => {
if (effectiveStep === 'category') {
setStep(hasOptionsStep ? 'options' : 'price');
return;
}
setActiveStep(2);
setStep('price');
};
const validatePrice = () => {
if (!priceValid) {
setPriceError(true);
return false;
const goBack = () => {
if (effectiveStep === 'price' && !isEdit) {
setStep(hasOptionsStep ? 'options' : 'category');
return;
}
return true;
setStep('category');
};
const submit = () => {
if (!validatePrice() || irr == null) return;
const displayName = displayNameOverride?.trim() ? displayNameOverride.trim() : undefined;
const submit = (values: VariantFormValues) => {
// `handleSubmit` has already enforced the price rule; this is the type narrowing that lets the
// IRR string be passed on, not a second gate.
if (irr == null) return;
const displayName = values.displayNameOverride?.trim() ? values.displayNameOverride.trim() : undefined;
if (isEdit) {
updateVariant.mutate(
{ id: initial.id, input: { price: irr, priceUnit, sessionCount, displayName } },
{ id: initial.id, input: { price: irr, priceUnit: values.priceUnit, sessionCount, displayName } },
{
onSuccess: () => {
enqueueSnackbar(t('saved_toast'), { variant: 'success' });
@@ -176,12 +237,19 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
return;
}
const options: VariantOptionSelection[] = Object.entries(selectedOptions).map(([groupId, valueId]) => ({
const options: VariantOptionSelection[] = Object.entries(values.options).map(([groupId, valueId]) => ({
optionGroupId: Number(groupId),
optionValueId: valueId,
}));
createVariant.mutate(
{ serviceCategoryId: categoryId as number, options, price: irr, priceUnit, sessionCount, displayName },
{
serviceCategoryId: values.categoryId as number,
options,
price: irr,
priceUnit: values.priceUnit,
sessionCount,
displayName,
},
{
onSuccess: () => {
enqueueSnackbar(t('created_toast'), { variant: 'success' });
@@ -196,13 +264,13 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
);
};
// Step 3's live preview — the actual VariantCard, so the nurse sees the listing they're composing,
// not just an abstract price readout.
// The price step's live preview — the actual VariantCard, so the nurse sees the listing they're
// composing, not just an abstract price readout.
const previewVariant: NurseServiceVariant = {
id: 0,
serviceCategoryId: categoryId ?? 0,
categoryNameFa: selectedCategory?.nameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? '',
categoryNameFa: selectedCategory?.nameFa ?? initial?.categoryNameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? initial?.categoryNameEn ?? '',
price: irr ?? '0',
priceUnit,
sessionCount,
@@ -211,68 +279,119 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
options: [],
};
/** Category + chosen options, so the last step doubles as a review of the first two. */
const recapChips = isEdit
? initial.options.map((option) => ({
key: String(option.optionGroupId),
label: `${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`,
}))
: groups.flatMap((group) => {
const value = group.values.find((candidate) => candidate.id === selectedOptions[group.id]);
return value
? [{ key: String(group.id), label: `${pickCatalogName(group, locale)}: ${pickCatalogName(value, locale)}` }]
: [];
});
const priceStep = (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('price_label')}
value={priceToman}
onChange={(event) => {
setPriceToman(digitsOnly(event.target.value).slice(0, MAX_PRICE_DIGITS));
if (priceError) setPriceError(false);
if (duplicate) setDuplicate(false);
}}
error={priceError}
helperText={priceError ? t('price_required') : t('price_hint')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
<FormSection title={t('section_recap_title')} description={t('section_recap_description')} icon="category">
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{selectedCategory
? pickCatalogName(selectedCategory, locale)
: initial
? pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)
: ''}
</Typography>
{isEdit ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
) : null}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{recapChips.length > 0 ? (
recapChips.map((chip) => (
<Chip key={chip.key} size="small" label={chip.label} sx={{ bgcolor: 'var(--bal-primary-soft)' }} />
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</FormSection>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
select
label={t('unit_label')}
value={priceUnit}
onChange={(event) => setPriceUnit(event.target.value as PriceUnit)}
fullWidth
>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</TextField>
<TextField
label={t('duration_label')}
value={durationStr}
onChange={(event) => setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))}
helperText={t('duration_hint')}
<FormSection title={t('section_price_title')} description={t('price_hint')} icon="earnings">
<RhfTextField<VariantFormValues>
name="priceToman"
label={t('price_label')}
transform={(raw) => digitsOnly(raw).slice(0, MAX_PRICE_DIGITS)}
rules={{
validate: (value) => {
const raw = String(value ?? '');
return (raw.length > 0 && BigInt(raw) > BigInt(0)) || t('price_required');
},
}}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
</Stack>
{irr ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<VariantFormValues> name="priceUnit" select label={t('unit_label')} fullWidth>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</RhfTextField>
<RhfTextField<VariantFormValues>
name="duration"
label={t('duration_label')}
helperText={t('duration_hint')}
transform={(raw) => digitsOnly(raw).slice(0, MAX_DURATION_DIGITS)}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
</Stack>
{!sessionCount && irr ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</FormSection>
<FormSection title={t('section_listing_title')} description={t('display_name_hint')} icon="services">
{/* Not `RhfTextField`: what is *stored* is the override alone (blank ⇒ the server generates
the name), while what is *shown* falls back to the live auto-generated name. One field,
two values — the one case in this form where the display value isn't the form value. */}
<Controller
control={control}
name="displayNameOverride"
render={({ field }) => (
<TextField
label={t('display_name_label')}
name={field.name}
inputRef={field.ref}
value={field.value ?? autoName}
onChange={(event) => field.onChange(event.target.value)}
onBlur={field.onBlur}
fullWidth
/>
)}
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
{/* Shown even before a price is entered — the preview is what "comprehensive" means here:
the nurse should see the shape of the listing while composing it, not only once it's valid. */}
<VariantCard variant={previewVariant} interactive={false} />
{!sessionCount ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</Stack>
) : null}
<TextField
label={t('display_name_label')}
value={displayValue}
onChange={(event) => setDisplayNameOverride(event.target.value)}
helperText={t('display_name_hint')}
fullWidth
/>
</FormSection>
{duplicate ? (
<AccentCard tone="warning" padding="sm">
@@ -301,157 +420,70 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
</Stack>
);
// --- Edit mode: locked category + options summary, then the price form ---
if (isEdit) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_edit_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 0.5 }}>
{initial.options.length > 0 ? (
initial.options.map((option) => (
<Chip
key={option.optionGroupId}
size="small"
label={`${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`}
sx={{ bgcolor: 'var(--bal-primary-soft)' }}
/>
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</Paper>
{priceStep}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{tc('cancel')}
</AppButton>
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
{submitting ? tc('saving') : t('submit_save')}
</AppButton>
</Stack>
</Box>
);
}
// --- Create mode: the 3-step stepper ---
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_add_title')}
</Typography>
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<PageHeader title={isEdit ? t('builder_edit_title') : t('builder_add_title')} />
<StepperHeader
steps={[t('step_category'), t('step_options'), t('step_price')]}
activeStep={activeStep}
/>
{isEdit ? null : (
<StepperHeader
steps={visibleSteps.map((key) => stepLabels[key])}
activeStep={visibleSteps.indexOf(effectiveStep)}
/>
)}
{activeStep === 0 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('category_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('category_subtitle')}
</Typography>
</Box>
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()}>
{tc('retry')}
</AppButton>
</Stack>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={categoryId === category.id}
onClick={() => selectCategory(category.id)}
/>
))}
</Box>
)}
</Stack>
) : null}
{effectiveStep === 'category' ? (
<FormSection title={t('category_title')} description={t('category_subtitle')} icon="category">
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
<ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => categoriesQuery.refetch()} />
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={categoryId === category.id}
onClick={() => selectCategory(category.id)}
/>
))}
</Box>
)}
</FormSection>
) : null}
{activeStep === 1 ? (
<Stack sx={{ gap: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('options_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_subtitle')}
</Typography>
</Box>
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : optionGroupsQuery.isError ? (
// A failed fetch must never read as "this category has zero options" — that would let the
// nurse skip required options entirely. Block progression until the retry succeeds.
<ErrorState
message={t('options_error')}
retryLabel={tc('retry')}
onRetry={() => optionGroupsQuery.refetch()}
/>
) : groups.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_none')}
</Typography>
) : (
groups.map((group) => {
const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null;
return (
{effectiveStep === 'options' ? (
<FormSection title={t('options_title')} description={t('options_subtitle')} icon="tune">
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : optionGroupsQuery.isError ? (
// A failed fetch must never read as "this category has zero options" — that would let the
// nurse skip required options entirely. Block progression until the retry succeeds.
<ErrorState message={t('options_error')} retryLabel={tc('retry')} onRetry={() => optionGroupsQuery.refetch()} />
) : (
groups.map((group) => (
<Stack key={group.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pickCatalogName(group, locale)}
</Typography>
{/* The required badge turns red on a blocked advance to point at the unanswered group. */}
<Chip
size="small"
label={group.isRequired ? t('required_badge') : t('optional_badge')}
sx={{
bgcolor: isMissing
? 'var(--bal-error)'
: group.isRequired
? 'var(--bal-primary-soft)'
: 'var(--bal-divider)',
color: isMissing
? 'var(--bal-error-contrast)'
: group.isRequired
? 'var(--bal-primary)'
: 'text.secondary',
bgcolor: group.isRequired ? 'var(--bal-primary-soft)' : 'var(--bal-divider)',
color: group.isRequired ? 'var(--bal-primary)' : 'text.secondary',
fontWeight: 500,
}}
/>
@@ -463,76 +495,64 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
<Chip
key={value.id}
label={pickCatalogName(value, locale)}
onClick={() => changeOption(group.id, selected ? null : value.id)}
aria-pressed={selected}
clickable
color={selected ? 'primary' : 'default'}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
onClick={() => changeOption(group.id, selected ? null : value.id)}
/>
);
})}
</Stack>
</Stack>
);
})
)}
))
)}
</FormSection>
) : null}
{optionsError && missingRequiredGroups.length > 0 ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 500 }}>
{t('options_incomplete')}
</Typography>
) : null}
</Stack>
) : null}
{effectiveStep === 'price' ? priceStep : null}
{activeStep === 2 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('price_title')}
</Typography>
</Box>
{priceStep}
</Stack>
) : null}
{/* Names what is still missing while the button is disabled, instead of revealing it on tap. */}
{effectiveStep === 'options' && missingRequiredGroups.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('options_missing_named', {
groups: missingRequiredGroups.map((group) => pickCatalogName(group, locale)).join('),
})}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', mt: 1 }}>
<AppButton
variant="text"
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)}
disabled={submitting}
>
{activeStep === 0 ? tc('cancel') : tc('back')}
</AppButton>
<SurfaceCard padding="sm" sx={{ position: 'sticky', bottom: 'var(--bal-chrome-bottom, 0px)', zIndex: 1 }}>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
<AppButton
variant="text"
onClick={effectiveStep === 'category' || isEdit ? onCancel : goBack}
disabled={submitting}
>
{effectiveStep === 'category' || isEdit ? tc('cancel') : tc('back')}
</AppButton>
{activeStep === 0 ? (
<AppButton
color="primary"
variant="contained"
onClick={() => setActiveStep(1)}
disabled={categoryId == null}
>
{t('next')}
</AppButton>
) : activeStep === 1 ? (
<AppButton
color="primary"
variant="contained"
onClick={goNextFromOptions}
disabled={optionGroupsQuery.isError}
>
{t('next')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
{submitting ? tc('saving') : t('submit_create')}
</AppButton>
)}
</Stack>
</Box>
{effectiveStep === 'price' ? (
<AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
{submitting ? tc('saving') : isEdit ? t('submit_save') : t('submit_create')}
</AppButton>
) : (
<AppButton
color="primary"
variant="contained"
onClick={goNext}
disabled={
effectiveStep === 'category'
? categoryId == null
: optionGroupsQuery.isError || missingRequiredGroups.length > 0
}
>
{t('next')}
</AppButton>
)}
</Stack>
</SurfaceCard>
</Box>
</FormProvider>
);
};