commit 9203141730979089707a3ae2b77428c561c3fa85 parent 24cc7acd687d4c2bd875b9d70ed1cab1a29c2fb8 Author: Florian Dold <dold@taler.net> Date: Wed, 4 Nov 2026 23:55:38 +0100 merchant web UI: integrate the opt-in discount stamp and pass wizard Expose guided setup through advanced Personalization preferences. Integrate the selectors, rounding controls, stamp presets, step validation, and review while retaining classic editing and existing token semantics. Clear corrected field errors, keep product-selection errors with their selectors, and default new families to no stamp on reward redemption. Cover the wizard and classic payloads, navigation, validation, and creation retries. Diffstat:
9 files changed, 3043 insertions(+), 1527 deletions(-)
diff --git a/packages/taler-merchant-webui/src/routes/CreateSubscriptionRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateSubscriptionRoute.tsx @@ -32,7 +32,11 @@ export function CreateSubscriptionRoute({ const { tokenFamilies, createTokenFamily, updateTokenFamily } = useTokenFamilies(); const { resource: familyResource } = useTokenFamilyDetails(editId); - const { categories, resource: categoriesResource } = useCategories(); + const { + categories, + resource: categoriesResource, + createOrGetCategory, + } = useCategories(); const { products, resource: productsResource } = useInventory(); const { primaryCurrency, @@ -46,6 +50,7 @@ export function CreateSubscriptionRoute({ familyResource={familyResource} categories={categories ?? []} categoriesResource={categoriesResource} + onCreateCategory={createOrGetCategory} products={products ?? []} productsResource={productsResource} primaryCurrency={primaryCurrency} diff --git a/packages/taler-merchant-webui/src/routes/SubscriptionsRoute.tsx b/packages/taler-merchant-webui/src/routes/SubscriptionsRoute.tsx @@ -15,75 +15,24 @@ */ import type { VNode } from "preact"; -import { - mapRemoteResource, - useTokenFamilies, - type TokenFamilyItem, -} from "../api/hooks.js"; +import { mapRemoteResource, useTokenFamilies } from "../api/hooks.js"; import { SubscriptionsScreen, type SubscriptionItem, } from "../screens/SubscriptionsScreen.js"; import { useTranslation } from "../context/translation.js"; -import { parseTokenSemantics } from "../utils/tokenSemantics.js"; +import { subscriptionItem } from "../screens/token-setup/copy.js"; export function SubscriptionsRoute(): VNode { const { t } = useTranslation(); const { tokenFamilies, resource, isLoading, deleteTokenFamily } = useTokenFamilies(); - const toItem = (tf: TokenFamilyItem): SubscriptionItem => { - const parsed = parseTokenSemantics(tf.kind, tf.extraData); - const semantic = parsed.status === "valid" ? parsed.semantics : undefined; - const benefitSummary = - semantic?.benefitType === "percentage" - ? // Translators: Keep the literal percent sign immediately after the - // numeric placeholder. - t`${semantic.percentage}% off` - : semantic?.benefitType === "flat" - ? t`Up to ${semantic.amounts.join(", ")} off` - : semantic?.benefitType === "free_item" - ? semantic.priceSelection === "most_expensive" - ? t`Highest-priced item free` - : t`Lowest-priced item free` - : semantic?.benefitType === "none" - ? t`No redemption benefit` - : undefined; - const ruleSummary = - semantic?.kind === "discount" - ? semantic.benefitType === "none" - ? t`No redemption benefit; earns one token on qualifying orders` - : semantic.requiredTokens === 1 - ? t`${benefitSummary} for 1 token; earns one on qualifying orders` - : t`${benefitSummary} for ${semantic.requiredTokens} tokens; earns one on qualifying orders` - : (benefitSummary ?? - (parsed.status === "invalid" - ? t`Invalid automatic checkout rule` - : undefined)); - const selectorSummary = - semantic?.benefitType === "none" - ? undefined - : semantic?.productSelectors === "*" - ? t`All merchant purchases` - : semantic?.productSelectors - .map((selector) => selector.name) - .join(", "); - return { - id: tf.slug, - name: tf.name, - description: tf.description, - kind: tf.kind, - canBeUsed: tf.validBefore ? t`Until ${tf.validBefore}` : t`Always`, - validUntil: tf.validBefore, - duration: tf.duration || "30d", - extraData: tf.extraData, - ruleSummary, - selectorSummary, - }; - }; - const items: SubscriptionItem[] = tokenFamilies.map(toItem); + const items: SubscriptionItem[] = tokenFamilies.map((tf) => + subscriptionItem(t, tf), + ); const itemsResource = mapRemoteResource(resource, (families) => - families.map(toItem), + families.map((tf) => subscriptionItem(t, tf)), ); return ( diff --git a/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx @@ -16,8 +16,26 @@ import type { VNode } from "preact"; import { session } from "../stores/session.js"; -import { parseStrictDate } from "../utils/runtimeValidation.js"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { minimumSpendLabel, validityPeriodLabel } from "./token-setup/copy.js"; +import { ReservedSpace } from "./token-setup/ReservedSpace.js"; +import { TokenSetupReview } from "./token-setup/TokenSetupReview.js"; +import { PercentageRewardEditor } from "./token-setup/PercentageRewardEditor.js"; +import { TokenSelector } from "./token-setup/TokenSelector.js"; +import { preferences } from "../stores/preferences.js"; +import { + restoreTokenKind, + prepareSubscription, + slugify, + currencyFractionalStep, + type SubscriptionDraft, + type TokenSetupStep, + type DraftError, +} from "./token-setup/model.js"; +export { + isFutureDateInput, + currencyFractionalStep, +} from "./token-setup/model.js"; +import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { useLocation } from "wouter-preact"; import { Header } from "../ui/Header.js"; import type { SubscriptionItem } from "./SubscriptionsScreen.js"; @@ -27,28 +45,24 @@ import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { formatErrorMessage } from "../utils/errors.js"; import { DisclosureChevron } from "../ui/DisclosureChevron.js"; -import { - Amounts, - type AmountString, - type CurrencySpecification, -} from "@gnu-taler/taler-util"; +import { type CurrencySpecification } from "@gnu-taler/taler-util"; import type { CategoryItem, ProductItem } from "../types/domain.js"; import type { RemoteResource } from "../api/contracts.js"; import { ReadErrorBanner } from "../ui/ReadErrorBanner.js"; import { InitialLoadingState } from "../ui/InitialLoadingState.js"; import { AmountInput } from "../ui/AmountInput.js"; import { - normalizePercentage, - normalizeDecimal, parseTokenSemantics, - writeTokenSemantics, type CategorySnapshot, type ProductSnapshot, type ProductSelector, - type TokenSemantics, } from "../utils/tokenSemantics.js"; +const stampCountPresets = ["5", "10", "15", "20"]; + export interface CreateSubscriptionScreenProps { + /** Explicit mode for isolated previews; production uses browser preferences. */ + setupMode?: "classic" | "wizard"; /** List data is display-only and is never used to hydrate an editor. */ families?: TokenFamilyItem[]; familyResource?: RemoteResource<TokenFamilyItem>; @@ -58,6 +72,7 @@ export interface CreateSubscriptionScreenProps { initialItem?: SubscriptionItem; onSave?: (item: SubscriptionItem) => void; categories?: CategoryItem[]; + onCreateCategory?: (name: string) => Promise<CategoryItem>; categoriesResource?: RemoteResource<CategoryItem[]>; products?: ProductItem[]; productsResource?: RemoteResource<ProductItem[]>; @@ -68,68 +83,8 @@ export interface CreateSubscriptionScreenProps { const EMPTY_CURRENCY_SPECIFICATIONS: Record<string, CurrencySpecification> = {}; -export function isFutureDateInput(value: string, nowMs = Date.now()): boolean { - try { - return parseStrictDate(value, "date").getTime() > nowMs; - } catch { - return false; - } -} - -export function currencyFractionalStep( - currency: string, - specifications: Record<string, CurrencySpecification> = {}, -): string { - const entry = Object.entries(specifications).find( - ([candidate]) => candidate.toUpperCase() === currency.toUpperCase(), - )?.[1]; - const digits = entry?.num_fractional_input_digits; - if ( - !Number.isSafeInteger(digits) || - digits === undefined || - digits < 0 || - digits > 8 - ) { - return "0.01"; - } - return digits === 0 ? "1" : `0.${"0".repeat(digits - 1)}1`; -} - -interface SubscriptionDraft { - name: string; - slugId: string; - description: string; - kind: "subscription" | "discount"; - validFrom: string; - validUntil: string; - useValidFrom: boolean; - useValidUntil: boolean; - duration: string; - stepGranularity: string; - selectedCategories: CategorySnapshot[]; - selectedProducts: ProductSnapshot[]; - redemptionWildcard: boolean; - benefitType: "none" | "percentage" | "flat" | "free_item"; - freeItemPriceSelection: "cheapest" | "most_expensive"; - percentage: string; - roundingMode: "down" | "nearest" | "up"; - roundingPrecision: string; - showRoundingOptions: boolean; - flatAmounts: string[]; - requiredTokens: string; - issuanceWildcard: boolean; - issuanceCategories: CategorySnapshot[]; - issuanceProducts: ProductSnapshot[]; - minimumPurchase: string; - issueOnRedemption: boolean; - showAdvanced: boolean; - manualId: boolean; - roundingPrecisionIsDefault: boolean; - flatAmountsAreDefault: boolean; -} - /** Decode stored rules once when the editable form mounts. */ -function initialSubscriptionDraft( +export function initialSubscriptionDraft( existingItem: SubscriptionItem | TokenFamilyItem | undefined, primaryCurrency: string, defaultRoundingPrecision: string, @@ -153,7 +108,6 @@ function initialSubscriptionDraft( percentage: "", roundingMode: "down", roundingPrecision: defaultRoundingPrecision, - showRoundingOptions: false, flatAmounts: [`${primaryCurrency}:10`], requiredTokens: "", issuanceWildcard: false, @@ -227,11 +181,6 @@ function initialSubscriptionDraft( draft.roundingMode = parsed.semantics.rounding.mode; draft.roundingPrecision = parsed.semantics.rounding.precision; draft.roundingPrecisionIsDefault = false; - draft.showRoundingOptions = - parsed.semantics.rounding.mode !== "down" || - parsed.semantics.rounding.precision !== defaultRoundingPrecision; - } else { - draft.showRoundingOptions = false; } } else if (parsed.semantics.benefitType === "flat") { draft.flatAmounts = parsed.semantics.amounts; @@ -290,19 +239,19 @@ export function CreateSubscriptionScreen( <Header onBack={() => setLocation("/subscriptions")} backLabel={t`Discounts & Passes`} - title={t`Edit Discount or Pass`} - subtitle={t`Choose how discounts are earned and redeemed, and how long they remain usable.`} + title={t`Edit Discount Stamps or Pass`} + subtitle={t`Choose which purchases earn discount stamps, the reward customers receive, and when discount stamps expire.`} /> {familyResource?.error ? ( <ReadErrorBanner resource={familyResource} - title={t`Discount or pass details could not be loaded`} + title={t`Discount stamp or pass details could not be loaded`} /> ) : familyResource?.isLoading ? ( <InitialLoadingState>{t`Loading…`}</InitialLoadingState> ) : ( <ApiErrorBanner - error={t`Discount or pass details could not be loaded`} + error={t`Discount stamp or pass details could not be loaded`} /> )} </div> @@ -315,11 +264,11 @@ export function CreateSubscriptionScreen( <Header onBack={() => setLocation("/subscriptions")} backLabel={t`Discounts & Passes`} - title={t`Edit Discount or Pass`} - subtitle={t`Choose how discounts are earned and redeemed, and how long they remain usable.`} + title={t`Edit Discount Stamps or Pass`} + subtitle={t`Choose which purchases earn discount stamps, the reward customers receive, and when discount stamps expire.`} /> <ApiErrorBanner - error={t`This discount or pass uses rules this portal cannot edit safely.`} + error={t`These discount stamps or this pass use settings this portal cannot edit safely.`} /> </div> ); @@ -338,6 +287,7 @@ export function CreateSubscriptionScreen( } function CreateSubscriptionForm({ + setupMode, onCreateFamily, onUpdateFamily, editId, @@ -345,6 +295,7 @@ function CreateSubscriptionForm({ familyResource, onSave, categories = [], + onCreateCategory, categoriesResource, products = [], productsResource, @@ -382,6 +333,22 @@ function CreateSubscriptionForm({ const isEditing = Boolean(editId || initialItem); const existingItem = initialItem || familyResource?.data; + const [wizard] = useState( + () => + !isEditing && + (setupMode + ? setupMode === "wizard" + : preferences.value.advancedTools && + preferences.value.tokenSetupWizard), + ); + const [step, setStep] = useState<TokenSetupStep>("basics"); + const [returnToReview, setReturnToReview] = useState(false); + const [reviewTarget, setReviewTarget] = useState<string>(); + const formRef = useRef<HTMLFormElement>(null); + const pendingFocus = useRef<string | null>(null); + const submitting = useRef(false); + const [expiryOptions, setExpiryOptions] = useState(false); + const [initialDraft] = useState(() => initialSubscriptionDraft( existingItem, @@ -415,12 +382,16 @@ function CreateSubscriptionForm({ const [selectedProducts, setSelectedProducts] = useState<ProductSnapshot[]>( initialDraft.selectedProducts, ); - const [redemptionWildcard, setRedemptionWildcard] = useState<boolean>( + const [redemptionAllPurchases, setRedemptionAllPurchases] = useState<boolean>( initialDraft.redemptionWildcard, ); const [benefitType, setBenefitType] = useState< "none" | "percentage" | "flat" | "free_item" >(initialDraft.benefitType); + // Free items always require explicit eligibility. Preserve the scope choice + // for percentage/flat rewards, but never carry it into a free-item draft. + const redemptionWildcard = + benefitType !== "free_item" && redemptionAllPurchases; const [freeItemPriceSelection, setFreeItemPriceSelection] = useState< "cheapest" | "most_expensive" >(initialDraft.freeItemPriceSelection); @@ -434,9 +405,7 @@ function CreateSubscriptionForm({ const roundingPrecisionIsDefault = useRef( initialDraft.roundingPrecisionIsDefault, ); - const [showRoundingOptions, setShowRoundingOptions] = useState<boolean>( - initialDraft.showRoundingOptions, - ); + const [roundingPrecisionOpen, setRoundingPrecisionOpen] = useState(false); const [flatAmounts, setFlatAmounts] = useState<string[]>( initialDraft.flatAmounts, ); @@ -444,6 +413,15 @@ function CreateSubscriptionForm({ const [requiredTokens, setRequiredTokens] = useState<string>( initialDraft.requiredTokens, ); + const [customStampCountSelected, setCustomStampCountSelected] = useState( + initialDraft.requiredTokens !== "" && + !stampCountPresets.includes(initialDraft.requiredTokens), + ); + const [customStampCount, setCustomStampCount] = useState( + stampCountPresets.includes(initialDraft.requiredTokens) + ? "" + : initialDraft.requiredTokens, + ); const [issuanceWildcard, setIssuanceWildcard] = useState<boolean>( initialDraft.issuanceWildcard, ); @@ -456,9 +434,8 @@ function CreateSubscriptionForm({ const [minimumPurchase, setMinimumPurchase] = useState<string>( initialDraft.minimumPurchase, ); - const [issueOnRedemption, setIssueOnRedemption] = useState<boolean>( - initialDraft.issueOnRedemption, - ); + // New families default to false; preserve existing semantics when editing. + const issueOnRedemption = initialDraft.issueOnRedemption; const [showAdvanced, setShowAdvanced] = useState<boolean>( initialDraft.showAdvanced, ); @@ -470,6 +447,7 @@ function CreateSubscriptionForm({ rawError, setRawError, feedbackId, + field: errorField, fieldProps, fieldError, } = useFormFeedback(); @@ -491,12 +469,6 @@ function CreateSubscriptionForm({ ? parseTokenSemantics(existingItem.kind, existingExtraData) : { status: "absent" as const }; - const slugify = (text: string) => - text - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); - const handleNameChange = (val: string) => { setName(val); if (!manualId && !isEditing) { @@ -518,345 +490,204 @@ function CreateSubscriptionForm({ duration === "30d"; const showPrivacyWarning = isFineStep && isLongValidity; - const handleSubmit = async (e: Event) => { - e.preventDefault(); - if (!primaryCurrency) { - setErrorMsg(t`Currency configuration is unavailable.`); - return; - } - if (existingSemantics.status === "invalid") { - setErrorMsg( - t`This discount or pass uses rules this portal cannot edit safely.`, - ); - return; - } - const trimmedName = name.trim(); - const trimmedDesc = description.trim(); - const slug = (slugId || `token_${slugify(trimmedName)}`).trim(); - - if (!trimmedName) { - setErrorMsg( - t`Please enter a name for this discount or pass.`, - "sub_name_input", - ); - return; - } - - if (!trimmedDesc) { - setErrorMsg( - t`Please enter a description for this discount or pass.`, - "sub_desc_input", - ); - return; - } - - if (!/^[a-zA-Z0-9_-]+$/.test(slug)) { - setErrorMsg( - t`The identifier can only contain letters, numbers, underscores, and hyphens (no spaces or special characters).`, - "sub_slug_input", - ); - return; - } - - if (useValidFrom && !validFrom) { - setErrorMsg(t`Please choose a "Valid From" date.`, "sub_valid_from"); - return; - } - - if (useValidUntil && !validUntil) { - setErrorMsg(t`Please choose a "Valid Until" date.`, "sub_valid_until"); - return; + const draft: SubscriptionDraft = { + ...initialDraft, + roundingPrecisionIsDefault: roundingPrecisionIsDefault.current, + flatAmountsAreDefault: flatAmountsAreDefault.current, + name, + slugId, + description, + kind, + validFrom, + validUntil, + useValidFrom, + useValidUntil, + duration, + stepGranularity, + selectedCategories, + selectedProducts, + redemptionWildcard, + benefitType, + freeItemPriceSelection, + percentage, + roundingMode, + roundingPrecision, + flatAmounts, + requiredTokens, + issuanceWildcard, + issuanceCategories, + issuanceProducts, + minimumPurchase, + issueOnRedemption, + }; + const prepareDraft = (value = draft) => + prepareSubscription(value, { + t, + primaryCurrency, + supportedCurrencies, + displayCategories, + displayProducts, + isEditing, + existingExtraData, + authoritativeValidUntil: + existingItem && + ("validBefore" in existingItem + ? existingItem.validBefore + : (existingItem as SubscriptionItem).validUntil), + }); + // Revalidate only feedback already shown. Fixing that problem clears both + // its inline guidance and the footer, without reporting the next problem or + // dismissing an unrelated failed save. + const validationResolved = Boolean( + errorField && + !rawError && + !prepareDraft().errors.some((problem) => problem.field === errorField), + ); + useEffect(() => { + if (validationResolved) setErrorMsg(""); + }, [validationResolved, setErrorMsg]); + const steps: TokenSetupStep[] = + kind === "discount" + ? ["basics", "earning", "benefit", "timing", "review"] + : ["basics", "benefit", "timing", "review"]; + const stepLabels: Record<TokenSetupStep, string> = { + basics: t`Details`, + earning: t`Collecting discount stamps`, + benefit: kind === "discount" ? t`Customer reward` : t`Pass benefit`, + timing: t`Validity`, + review: t`Review`, + }; + const showProblem = (problem: DraftError) => { + if (wizard) setStep(problem.step); + if ( + ["sub_slug_input", "sub_valid_from", "sub_valid_until"].includes( + problem.field, + ) + ) + setShowAdvanced(true); + if (problem.field === "sub_granularity") setExpiryOptions(true); + if (problem.field === "sub_discount_rounding_precision") + setRoundingPrecisionOpen(true); + pendingFocus.current = problem.field; + setErrorMsg(problem.message, problem.field); + }; + const lastFocusedStep = useRef(step); + useLayoutEffect(() => { + if (!wizard) return; + const field = pendingFocus.current; + const stepChanged = lastFocusedStep.current !== step; + lastFocusedStep.current = step; + if (!field && !stepChanged) return; + const root = formRef.current; + const target = + (field + ? (root?.querySelector<HTMLElement>(`[id="${field}"]`) ?? + root?.querySelector<HTMLElement>(`[data-field="${field}"]`)) + : undefined) ?? root?.querySelector<HTMLElement>("#token-step-heading"); + if (target) { + (target.matches("input, select, textarea, button") + ? target + : (target.querySelector<HTMLElement>( + "input, select, textarea, button", + ) ?? target) + ).focus({ preventScroll: true }); + if (field) target.scrollIntoView?.({ block: "nearest" }); + pendingFocus.current = null; } - - if (useValidFrom && useValidUntil) { - let fromMs: number; - let untilMs: number; - try { - fromMs = parseStrictDate(validFrom, "validFrom").getTime(); - untilMs = parseStrictDate(validUntil, "validUntil").getTime(); - } catch { - setErrorMsg(t`Enter valid calendar dates.`, "sub_valid_until"); - return; - } - if (untilMs <= fromMs) { - setErrorMsg( - t`"Valid Until" date must be after "Valid From" date.`, - "sub_valid_until", - ); + }, [step, feedbackId, wizard]); + const navigateStep = (next: TokenSetupStep) => { + if (submitting.current) return; + if (steps.indexOf(next) > steps.indexOf(step)) { + const problems = prepareDraft().errors; + const problem = steps + .slice(0, steps.indexOf(next)) + .flatMap((s) => problems.filter((p) => p.step === s))[0]; + if (problem) { + showProblem(problem); return; } } - - const authoritativeValidUntil = - existingItem && - ("validBefore" in existingItem - ? existingItem.validBefore - : (existingItem as SubscriptionItem).validUntil); - if ( - useValidUntil && - (!isEditing || validUntil !== authoritativeValidUntil) - ) { - if (!isFutureDateInput(validUntil)) { - setErrorMsg( - t`"Valid Until" date must be in the future.`, - "sub_valid_until", - ); - return; - } + setErrorMsg(""); + if (next === "review" && returnToReview) { + pendingFocus.current = `token-review-${reviewTarget}`; + setReturnToReview(false); } - - const allowedGranularities = ["1m", "1h", "1d", "7d", "30d", "90d", "365d"]; - if (!allowedGranularities.includes(stepGranularity)) { - setErrorMsg( - t`Validity granularity must be 1 minute, 1 hour, 1 day, 7 days, 30 days, 90 days, or 365 days.`, - "sub_granularity", - ); + setRoundingPrecisionOpen(false); + setStep(next); + }; + const editReview = (next: TokenSetupStep, field?: string) => { + if (submitting.current) return; + setReturnToReview(true); + setReviewTarget(field ?? next); + if (field === "sub_slug_input") setShowAdvanced(true); + if (next === "timing") setExpiryOptions(true); + pendingFocus.current = field ?? "token-step-heading"; + navigateStep(next); + if (field === "sub_discount_rounding_precision") + setRoundingPrecisionOpen(true); + }; + const branchDrafts = useRef< + Partial<Record<SubscriptionDraft["kind"], SubscriptionDraft>> + >({}); + const otherKind = kind === "discount" ? "subscription" : "discount"; + const changeKind = (next: SubscriptionDraft["kind"]) => { + if (next === kind) return; + if (!wizard) { + setKind(next); + if (next === "subscription" && benefitType === "free_item") + setBenefitType("percentage"); return; } + branchDrafts.current[kind] = draft; + const restored = restoreTokenKind( + draft, + branchDrafts.current[next] ?? initialDraft, + next, + ); + setKind(next); + setBenefitType(restored.benefitType); + setPercentage(restored.percentage); + setFlatAmounts(restored.flatAmounts); + setFreeItemPriceSelection(restored.freeItemPriceSelection); + setSelectedCategories(restored.selectedCategories); + setSelectedProducts(restored.selectedProducts); + setRedemptionAllPurchases(restored.redemptionWildcard); + setRoundingMode(restored.roundingMode); + setRoundingPrecision(restored.roundingPrecision); + setRoundingPrecisionOpen(false); + roundingPrecisionIsDefault.current = restored.roundingPrecisionIsDefault; + flatAmountsAreDefault.current = restored.flatAmountsAreDefault; + setErrorMsg(""); + }; - if ( - benefitType !== "none" && - !redemptionWildcard && - selectedCategories.length === 0 && - selectedProducts.length === 0 - ) { - setErrorMsg( - t`Select at least one product category or inventory product.`, - "redemption-products", - ); + const handleSubmit = async (e: Event) => { + e.preventDefault(); + if (submitting.current) return; + if (wizard && step !== "review") { + navigateStep(returnToReview ? "review" : steps[steps.indexOf(step) + 1]!); return; } - if ( - benefitType !== "none" && - !redemptionWildcard && - selectedCategories.some( - ({ id }) => !displayCategories.some((category) => category.id === id), - ) - ) { - setErrorMsg( - t`Remove unavailable categories before saving this rule.`, - "redemption-products", - ); + if (!primaryCurrency) { + setErrorMsg(t`Currency configuration is unavailable.`); return; } - if ( - benefitType !== "none" && - !redemptionWildcard && - selectedProducts.some( - ({ id }) => !displayProducts.some((product) => product.id === id), - ) - ) { + if (existingSemantics.status === "invalid") { setErrorMsg( - t`Remove unavailable products before saving this rule.`, - "redemption-products", + t`These discount stamps or this pass use settings this portal cannot edit safely.`, ); return; } - - const refreshCategoryNames = ( - selected: CategorySnapshot[], - ): CategorySnapshot[] => - selected.map(({ id }) => { - const category = displayCategories.find( - (candidate) => candidate.id === id, - )!; - return { id, name: category.name }; - }); - const refreshProductNames = ( - selected: ProductSnapshot[], - ): ProductSnapshot[] => - selected.map(({ id }) => { - const product = displayProducts.find( - (candidate) => candidate.id === id, - )!; - return { id, name: product.name }; - }); - const productSelectors = ( - selectedCategorySnapshots: CategorySnapshot[], - selectedProductSnapshots: ProductSnapshot[], - ): ProductSelector[] => [ - ...refreshCategoryNames(selectedCategorySnapshots).map((snapshot) => ({ - type: "category" as const, - ...snapshot, - })), - ...refreshProductNames(selectedProductSnapshots).map((snapshot) => ({ - type: "product" as const, - ...snapshot, - })), - ]; - - const benefit = (() => { - if (benefitType === "none") { - return { benefitType } as const; - } - if (benefitType === "free_item") { - return { benefitType, priceSelection: freeItemPriceSelection } as const; - } - if (benefitType === "percentage") { - const normalized = normalizePercentage(percentage); - const normalizedPrecision = normalizeDecimal(roundingPrecision); - if (!normalized) { - setErrorMsg( - t`Enter a percentage greater than 0 and no more than 100, with up to eight decimal places.`, - "sub_discount_percentage", - ); - return undefined; - } - if (!normalizedPrecision || normalizedPrecision === "0") { - setErrorMsg( - t`Enter a positive rounding precision with up to eight decimal places.`, - "sub_discount_rounding_precision", - ); - return undefined; - } - return { - benefitType, - percentage: normalized, - rounding: { mode: roundingMode, precision: normalizedPrecision }, - } as const; - } - if (flatAmounts.length === 0) { - setErrorMsg(t`Add at least one currency cap.`, "currency-caps"); - return undefined; - } - const normalizedAmounts: AmountString[] = []; - const seenCurrencies = new Set<string>(); - for (const rawAmount of flatAmounts) { - const normalizedAmount = rawAmount.trim(); - if ( - !Amounts.checkString(normalizedAmount) || - !Amounts.isNonZero(normalizedAmount) - ) { - setErrorMsg( - t`Enter a positive amount for every currency cap.`, - "currency-caps", - ); - return undefined; - } - const currency = Amounts.currencyOf(normalizedAmount).toUpperCase(); - if (!supportedCurrencies.includes(currency)) { - setErrorMsg( - t`Remove or change currency caps that are no longer supported by the merchant.`, - "currency-caps", - ); - return undefined; - } - if (seenCurrencies.has(currency)) { - setErrorMsg(t`Use each currency only once.`, "currency-caps"); - return undefined; - } - seenCurrencies.add(currency); - normalizedAmounts.push(normalizedAmount as AmountString); - } - return { benefitType, amounts: normalizedAmounts } as const; - })(); - if (!benefit) return; - - let semantics: TokenSemantics; - if (kind === "subscription") { - if (benefit.benefitType === "free_item") { - setErrorMsg( - t`Free-item benefits are only available for discounts.`, - "benefit-type", - ); - return; - } - semantics = { - kind, - ...benefit, - productSelectors: - benefit.benefitType === "none" || redemptionWildcard - ? "*" - : productSelectors(selectedCategories, selectedProducts), - }; - } else { - const required = - benefit.benefitType === "none" ? 1 : Number(requiredTokens); - if ( - benefit.benefitType !== "none" && - (!/^\d+$/.test(requiredTokens.trim()) || - !Number.isSafeInteger(required) || - required <= 0) - ) { - setErrorMsg( - t`Enter a positive whole-number redemption threshold.`, - "sub_required_tokens", - ); - return; - } - if ( - !issuanceWildcard && - issuanceCategories.length === 0 && - issuanceProducts.length === 0 - ) { - setErrorMsg( - t`Select at least one issuance category or inventory product, or choose all merchant purchases.`, - "issuance-products", - ); - return; - } - if ( - !issuanceWildcard && - issuanceCategories.some( - ({ id }) => !displayCategories.some((category) => category.id === id), - ) - ) { - setErrorMsg( - t`Remove unavailable categories before saving this rule.`, - "issuance-products", - ); - return; - } - if ( - !issuanceWildcard && - issuanceProducts.some( - ({ id }) => !displayProducts.some((product) => product.id === id), - ) - ) { - setErrorMsg( - t`Remove unavailable products before saving this rule.`, - "issuance-products", - ); - return; - } - let normalizedMinimum: AmountString | undefined; - if (minimumPurchase.trim()) { - const value = minimumPurchase.includes(":") - ? minimumPurchase.trim() - : `${primaryCurrency}:${minimumPurchase.trim()}`; - if ( - !Amounts.checkString(value) || - !Amounts.isNonZero(value) || - !supportedCurrencies.includes(Amounts.currencyOf(value).toUpperCase()) - ) { - setErrorMsg( - t`Enter a positive minimum purchase in a supported merchant currency.`, - "sub_minimum_purchase", - ); - return; - } - normalizedMinimum = value as AmountString; - } - semantics = { - kind, - ...benefit, - requiredTokens: required, - productSelectors: - benefit.benefitType === "none" || redemptionWildcard - ? "*" - : productSelectors(selectedCategories, selectedProducts), - issuance: { - productSelectors: issuanceWildcard - ? "*" - : productSelectors(issuanceCategories, issuanceProducts), - minimumPurchase: normalizedMinimum, - issueOnRedemption, - }, - }; + const prepared = prepareDraft(); + const problem = prepared.errors[0]; + if (problem) { + showProblem(problem); + return; } - const extraData = writeTokenSemantics(existingExtraData, semantics); + if (!prepared.payload) return; + const { slug, extraData } = prepared.payload; setErrorMsg(""); + submitting.current = true; setIsSubmitting(true); try { @@ -866,7 +697,9 @@ function CreateSubscriptionForm({ name, description, kind, - canBeUsed: useValidUntil ? t`Until ${validUntil}` : t`Always`, + canBeUsed: useValidUntil + ? t`Ends at 00:00 UTC on ${validUntil}` + : t`No end date`, validFrom: useValidFrom ? validFrom : undefined, validUntil: useValidUntil ? validUntil : undefined, duration, @@ -884,916 +717,883 @@ function CreateSubscriptionForm({ extraData, }); } else { - await onCreateFamily?.({ - slug, - name, - description, - kind, - duration, - stepGranularity, - validFrom: useValidFrom ? validFrom : undefined, - validUntil: useValidUntil ? validUntil : undefined, - extraData, - }); + await onCreateFamily?.(prepared.payload); } + submitting.current = false; setIsSubmitting(false); setLocation("/subscriptions"); } catch (err: unknown) { setErrorMsg( - formatErrorMessage(err, t`Failed to create discount or pass`, t), + formatErrorMessage(err, t`Could not save discount stamps or pass`, t), ); setRawError(err); + submitting.current = false; setIsSubmitting(false); } }; + const renderProductScope = ( + scope: "issuance" | "redemption", + all: boolean, + onChange: (all: boolean) => void, + ): VNode => ( + <div class="grid gap-2 sm:grid-cols-2"> + {[true, false].map((anyProduct) => ( + <label + key={String(anyProduct)} + class={`flex items-start gap-2 rounded-lg border p-3 cursor-pointer ${all === anyProduct ? "border-blue-500 bg-blue-50" : "border-gray-200"}`} + > + <input + type="radio" + name={`token-${scope}-scope`} + value={anyProduct ? "any" : "selected"} + aria-label={ + anyProduct + ? t`All purchases` + : t`Purchases with matching products` + } + checked={all === anyProduct} + onChange={() => onChange(anyProduct)} + /> + <span> + <span class="block text-sm font-semibold text-gray-900"> + {anyProduct + ? t`All purchases` + : t`Purchases with matching products`} + </span> + {anyProduct && ( + <span class="block text-xs text-gray-500"> + {t`Also applies to purchases entered without selecting products.`} + </span> + )} + </span> + </label> + ))} + </div> + ); + const renderCategorySelector = ( selected: CategorySnapshot[], setSelected: (next: CategorySnapshot[]) => void, - selectedClass: string, emptyMessage: string, - ): VNode => { - const unavailable = selected.filter( - ({ id }) => !displayCategories.some((category) => category.id === id), - ); - const unavailableOptions = unavailable.map((snapshot) => ( - <label - key={snapshot.id} - class="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 cursor-pointer" - > - <input - type="checkbox" - checked - onChange={() => - setSelected(selected.filter(({ id }) => id !== snapshot.id)) - } - /> - <span class="text-sm font-medium text-amber-900">{t`${snapshot.name} (unavailable category #${snapshot.id})`}</span> - </label> - )); - return ( - <div class="space-y-2"> - {categoriesResource && ( - <ReadErrorBanner - resource={categoriesResource} - title={t`Could not load product categories`} - /> - )} - {categoriesInitiallyLoading ? ( - <InitialLoadingState>{t`Loading…`}</InitialLoadingState> - ) : categoriesUnavailable ? ( - unavailableOptions - ) : displayCategories.length > 0 ? ( - <> - <div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> - {displayCategories.map((category) => { - const checked = selected.some(({ id }) => id === category.id); - return ( - <label - key={category.id} - class={`flex items-center gap-2 rounded-lg border p-3 cursor-pointer ${checked ? selectedClass : "border-gray-200"}`} - > - <input - type="checkbox" - checked={checked} - onChange={() => - setSelected( - checked - ? selected.filter(({ id }) => id !== category.id) - : [ - ...selected, - { id: category.id, name: category.name }, - ], - ) - } - /> - <span class="text-sm font-medium text-gray-900"> - {category.name} - </span> - </label> - ); - })} - </div> - {unavailableOptions} - </> - ) : ( - <> - {unavailableOptions} - <p class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900"> - {emptyMessage} - </p> - </> - )} - </div> - ); - }; - + ): VNode => ( + <TokenSelector + selected={selected} + onChange={setSelected} + options={displayCategories} + onCreateCategory={onCreateCategory} + resource={categoriesResource} + loading={categoriesInitiallyLoading} + unavailable={categoriesUnavailable} + emptyMessage={emptyMessage} + label={t`Search categories`} + addLabel={t`+ Add product category`} + /> + ); const renderProductSelector = ( selected: ProductSnapshot[], setSelected: (next: ProductSnapshot[]) => void, - selectedClass: string, emptyMessage: string, - ): VNode => { - const unavailable = selected.filter( - ({ id }) => !displayProducts.some((product) => product.id === id), - ); - const unavailableOptions = unavailable.map((snapshot) => ( - <label - key={snapshot.id} - class="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 cursor-pointer" - > - <input - type="checkbox" - checked - onChange={() => - setSelected(selected.filter(({ id }) => id !== snapshot.id)) - } - /> - <span class="text-sm font-medium text-amber-900">{t`${snapshot.name} (unavailable product ${snapshot.id})`}</span> - </label> - )); + ): VNode => ( + <TokenSelector + selected={selected} + onChange={setSelected} + options={displayProducts} + resource={productsResource} + loading={productsInitiallyLoading} + unavailable={productsUnavailable} + emptyMessage={emptyMessage} + label={t`Search products`} + addLabel={t`+ Add inventory product`} + /> + ); + + const nameHelp = (value: SubscriptionDraft["kind"]) => + value === "subscription" + ? t`Customers see this pass name in their wallets and purchase receipts.` + : t`Customers see this discount stamp name when paying and in their wallets.`; + const titleFor = (value: SubscriptionDraft["kind"]) => + isEditing + ? value === "subscription" + ? t`Edit Pass` + : t`Edit Discount Stamps` + : value === "subscription" + ? t`Create Pass` + : t`Create Discount Stamps`; + const subtitleFor = (value: SubscriptionDraft["kind"]) => + value === "subscription" + ? t`Choose what the pass provides and when it expires.` + : t`Choose which purchases earn discount stamps, the reward customers receive, and when discount stamps expire.`; + const renderProgress = (value: SubscriptionDraft["kind"], sizing = false) => { + const entries: TokenSetupStep[] = + value === "discount" + ? ["basics", "earning", "benefit", "timing", "review"] + : ["basics", "benefit", "timing", "review"]; return ( - <div class="space-y-2"> - {productsResource && ( - <ReadErrorBanner - resource={productsResource} - title={t`Could not load inventory products`} - /> - )} - {productsInitiallyLoading ? ( - <InitialLoadingState>{t`Loading…`}</InitialLoadingState> - ) : productsUnavailable ? ( - unavailableOptions - ) : displayProducts.length > 0 ? ( - <> - <div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> - {displayProducts.map((product) => { - const checked = selected.some(({ id }) => id === product.id); - return ( + <ol class="flex flex-wrap gap-2"> + {entries.map((entry, index) => { + const label = ( + <> + {index + 1}.{" "} + {entry === "benefit" + ? value === "discount" + ? t`Customer reward` + : t`Pass benefit` + : stepLabels[entry]} + </> + ); + const className = `block rounded-lg px-3 py-2 text-sm ${step === entry ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700"}`; + return ( + <li key={entry}> + {sizing ? ( + <span class={className}>{label}</span> + ) : ( + <button + type="button" + disabled={isSubmitting} + aria-current={step === entry ? "step" : undefined} + onClick={() => navigateStep(entry)} + class={className} + > + {label} + </button> + )} + </li> + ); + })} + </ol> + ); + }; + + const renderStepHeading = (labelId?: string) => ( + <h2 + id="token-step-heading" + tabIndex={-1} + class="text-lg font-bold text-gray-900 focus:outline-none" + > + <span id={labelId}>{stepLabels[step]}</span> + </h2> + ); + + const renderBasics = () => ( + <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> + {wizard ? ( + renderStepHeading() + ) : ( + <h2 class="text-base font-bold text-gray-900">{t`1. Details`}</h2> + )} + + <div class="space-y-4"> + {/* Decide the kind first because it changes the meaning and copy of + every field that follows. */} + <div> + <span class="block text-xs font-bold text-gray-700 mb-2">{t`What would you like to offer?`}</span> + {wizard ? ( + <fieldset> + <legend class="sr-only">{t`What would you like to offer?`}</legend> + <div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> + {(["discount", "subscription"] as const).map((option) => ( <label - key={product.id} - class={`flex items-center gap-2 rounded-lg border p-3 cursor-pointer ${checked ? selectedClass : "border-gray-200"}`} + key={option} + aria-label={ + option === "discount" ? t`Discount stamps` : t`Pass` + } + class={`flex items-start gap-3 rounded-xl border p-4 cursor-pointer ${kind === option ? "border-taler-brand bg-blue-50/70 ring-2 ring-blue-500/20" : "border-gray-200"}`} > <input - type="checkbox" - aria-label={product.name} - checked={checked} - onChange={() => - setSelected( - checked - ? selected.filter(({ id }) => id !== product.id) - : [ - ...selected, - { id: product.id, name: product.name }, - ], - ) - } + type="radio" + name="token-kind" + value={option} + checked={kind === option} + onChange={() => changeKind(option)} + class="mt-1" /> - <span class="min-w-0"> - <span class="block truncate text-sm font-medium text-gray-900"> - {product.name} + <span> + <span class="block text-sm font-bold"> + {option === "discount" ? t`Discount stamps` : t`Pass`} </span> - <span class="block truncate font-mono text-2xs text-gray-500"> - {product.id} + <span class="block text-xs text-gray-500 mt-1"> + {option === "discount" + ? t`Customers collect discount stamps in their wallet and exchange them for a reward.` + : t`Customers keep their pass and can reuse it until it expires.`} </span> </span> </label> - ); - })} + ))} + </div> + </fieldset> + ) : ( + <div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> + <button + type="button" + disabled={isEditing} + aria-pressed={kind === "discount"} + onClick={() => changeKind("discount")} + class={`p-4 text-left rounded-xl border transition-all flex items-start space-x-3 ${ + kind === "discount" + ? "border-taler-brand bg-blue-50/70 ring-2 ring-blue-500/20 font-semibold" + : "border-gray-200 bg-white hover:border-gray-300" + } ${isEditing ? "opacity-60 cursor-not-allowed" : ""}`} + > + <span class="text-2xl">🎟️</span> + <div> + <div class="text-sm font-bold text-gray-900">{t`Discount stamps`}</div> + <div class="text-xs text-gray-500 mt-1 leading-snug"> + {t`Customers collect discount stamps in their wallet and exchange them for a reward.`} + </div> + </div> + </button> + <button + type="button" + disabled={isEditing} + aria-pressed={kind === "subscription"} + onClick={() => changeKind("subscription")} + class={`p-4 text-left rounded-xl border transition-all flex items-start space-x-3 ${ + kind === "subscription" + ? "border-taler-brand bg-blue-50/70 ring-2 ring-blue-500/20 font-semibold" + : "border-gray-200 bg-white hover:border-gray-300" + } ${isEditing ? "opacity-60 cursor-not-allowed" : ""}`} + > + <span class="text-2xl">📅</span> + <div> + {/* A customer access pass (noun), not a request to pass or skip something. */} + <div class="text-sm font-bold text-gray-900">{t`Pass`}</div> + <div class="text-xs text-gray-500 mt-1 leading-snug"> + {t`Customers keep their pass and can reuse it until it expires.`} + </div> + </div> + </button> </div> - {unavailableOptions} - </> - ) : ( - <> - {unavailableOptions} - <p class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900"> - {emptyMessage} + )} + {isEditing && ( + <p class="text-2xs text-amber-800 bg-amber-50 p-2 rounded border border-amber-200 mt-2 font-medium"> + {t`Cannot be changed because customers may already hold discount stamps or passes with these settings.`} </p> - </> + )} + </div> + + <div> + <label + htmlFor="sub_name_input" + class="block text-xs font-bold text-gray-700 mb-1" + > + {wizard ? ( + <ReservedSpace + text + alternative={ + <> + {kind === "discount" + ? t`Pass name` + : t`Discount stamp name`}{" "} + <span class="text-red-500">*</span> + </> + } + > + {kind === "discount" ? t`Discount stamp name` : t`Pass name`}{" "} + <span class="text-red-500">*</span> + </ReservedSpace> + ) : ( + <> + {kind === "discount" ? t`Discount stamp name` : t`Pass name`}{" "} + <span class="text-red-500">*</span> + </> + )} + </label> + <input + {...fieldProps("sub_name_input")} + id="sub_name_input" + type="text" + required + placeholder={ + kind === "subscription" + ? t`e.g. Monthly Digital Supporter Pass` + : t`e.g. Coffee Club discount stamps` + } + value={name} + onInput={(e) => + handleNameChange((e.target as HTMLInputElement).value) + } + class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> + {fieldError("sub_name_input")} + <p class="text-xs text-gray-500 mt-1"> + {wizard ? ( + <ReservedSpace text alternative={nameHelp(otherKind)}> + {nameHelp(kind)} + </ReservedSpace> + ) : ( + nameHelp(kind) + )} + </p> + </div> + + <div> + <label + htmlFor="sub_desc_input" + class="block text-xs font-bold text-gray-700 mb-1" + > + {t`Description`} <span class="text-red-500">*</span> + </label> + <textarea + {...fieldProps("sub_desc_input")} + id="sub_desc_input" + required + rows={2} + placeholder={ + kind === "subscription" + ? t`e.g. Unlimited digital article access for 30 days...` + : t`e.g. Grants 10% off espresso purchases at participating locations...` + } + value={description} + onInput={(e) => + setDescription((e.target as HTMLTextAreaElement).value) + } + class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> + {fieldError("sub_desc_input")} + <p class="text-xs text-gray-500 mt-1">{t`Shown to customers. The settings below determine the actual reward or access; writing a description does not configure it.`}</p> + {(wizard || kind === "subscription") && ( + <p + aria-hidden={kind !== "subscription" ? "true" : undefined} + class={`mt-2 text-xs text-gray-600 ${kind !== "subscription" ? "invisible" : ""}`} + >{t`To give customers this pass, create an order and use Advanced editing → Customer tokens issued. Creating this offer alone does not distribute passes.`}</p> + )} + </div> + </div> + {wizard && renderAdvanced()} + </div> + ); + const renderRules = () => ( + <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> + <div> + {wizard ? ( + renderStepHeading( + kind === "discount" + ? step === "earning" + ? "earning_rules_heading" + : "redemption_rules_heading" + : undefined, + ) + ) : ( + <h2 + id="discount_rules_heading" + class="text-base font-bold text-gray-900" + > + {kind === "discount" + ? t`2. Discount stamps and rewards` + : t`2. Pass benefit`} + </h2> + )} + {!wizard && ( + <p class="text-xs text-gray-500 mt-1"> + {kind === "discount" + ? t`Choose how customers collect discount stamps and use their reward.` + : t`Choose what the pass provides and where customers can use it.`} + </p> )} </div> - ); - }; - const roundingModeSummary = - roundingMode === "down" - ? t`Round down` - : roundingMode === "nearest" - ? t`Round to nearest` - : t`Round up`; + {(!wizard || step === "benefit") && ( + <section + aria-labelledby={ + kind === "discount" + ? "redemption_rules_heading" + : wizard + ? "token-step-heading" + : "discount_rules_heading" + } + class="space-y-4" + > + {!wizard && kind === "discount" && ( + <div> + <h3 + id="redemption_rules_heading" + class="text-sm font-bold text-gray-900" + > + {t`Customer reward`} + </h3> + <p class="text-xs text-gray-500 mt-1"> + {t`Choose the reward and the purchases it applies to.`} + </p> + </div> + )} - return ( - <div class="space-y-6"> - <Header - onBack={() => setLocation("/subscriptions")} - backLabel={t`Discounts & Passes`} - title={ - isEditing - ? kind === "subscription" - ? t`Edit Pass` - : t`Edit Discount` - : kind === "subscription" - ? t`Create Pass` - : t`Create Discount` - } - subtitle={ - kind === "subscription" - ? t`Choose how long pass access lasts and how expiry times protect customer privacy.` - : t`Choose how discounts are earned and redeemed, and how long they remain usable.` - } - /> - - {!primaryCurrency && configResource?.error && ( - <ReadErrorBanner - resource={configResource} - title={t`Currency configuration could not be loaded`} - /> - )} - {!primaryCurrency && configResource?.isLoading && ( - <InitialLoadingState>{t`Loading currency configuration…`}</InitialLoadingState> - )} - - <form onSubmit={handleSubmit} class="space-y-6 max-w-3xl"> - {/* Section 1: Basic Information & Kind */} - <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> - <h2 class="text-base font-bold text-gray-900">{t`1. Basic Information`}</h2> - - <div class="space-y-4"> - {/* Decide the kind first because it changes the meaning and copy of - every field that follows. */} - <div> - <span class="block text-xs font-bold text-gray-700 mb-2">{t`Kind`}</span> - <div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> - <button - type="button" - disabled={isEditing} - onClick={() => setKind("discount")} - class={`p-4 text-left rounded-xl border transition-all flex items-start space-x-3 ${ - kind === "discount" - ? "border-taler-brand bg-blue-50/70 ring-2 ring-blue-500/20 font-semibold" - : "border-gray-200 bg-white hover:border-gray-300" - } ${isEditing ? "opacity-60 cursor-not-allowed" : ""}`} - > - <span class="text-2xl">🎟️</span> - <div> - <div class="text-sm font-bold text-gray-900">{t`Discount`}</div> - <div class="text-xs text-gray-500 mt-1 leading-snug"> - {t`Promotional or loyalty benefit accepted towards purchases.`} - </div> - </div> - </button> - <button - type="button" - disabled={isEditing} - onClick={() => { - setKind("subscription"); - if (benefitType === "free_item") - setBenefitType("percentage"); - }} - class={`p-4 text-left rounded-xl border transition-all flex items-start space-x-3 ${ - kind === "subscription" - ? "border-taler-brand bg-blue-50/70 ring-2 ring-blue-500/20 font-semibold" - : "border-gray-200 bg-white hover:border-gray-300" - } ${isEditing ? "opacity-60 cursor-not-allowed" : ""}`} - > - <span class="text-2xl">📅</span> - <div> - {/* A customer access pass (noun), not a request to pass or skip something. */} - <div class="text-sm font-bold text-gray-900">{t`Pass`}</div> - <div class="text-xs text-gray-500 mt-1 leading-snug"> - {t`Time-based access pass (e.g. monthly press access, member portal).`} - </div> - </div> - </button> - </div> - {isEditing && ( - <p class="text-2xs text-amber-800 bg-amber-50 p-2 rounded border border-amber-200 mt-2 font-medium"> - {t`🔒 Cannot be changed — the discounts and passes already issued rely on it.`} - </p> - )} - </div> - - <div> + <fieldset data-field="benefit-type" tabIndex={-1} class="space-y-3"> + <legend + class={ + wizard ? "sr-only" : "block text-xs font-bold text-gray-700" + } + > + {kind === "discount" ? t`Customer reward` : t`Pass benefit`} + </legend> + {fieldError("benefit-type")} + <div + class={`grid grid-cols-1 ${kind === "discount" ? "sm:grid-cols-4" : "sm:grid-cols-3"} gap-3`} + > <label - htmlFor="sub_name_input" - class="block text-xs font-bold text-gray-700 mb-1" + class={`rounded-lg border p-3 cursor-pointer ${benefitType === "none" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} > - {t`Name`} <span class="text-red-500">*</span> + <input + type="radio" + name="discount_type" + value="none" + checked={benefitType === "none"} + onChange={() => setBenefitType("none")} + class="mr-2" + /> + <span class="text-sm font-semibold"> + {kind === "discount" + ? t`No reward yet` + : t`Access only (no checkout discount)`} + </span> </label> - <input - {...fieldProps("sub_name_input")} - id="sub_name_input" - type="text" - required - placeholder={ - kind === "subscription" - ? t`e.g. Monthly Digital Supporter Pass` - : t`e.g. 10% Coffee Club Discount` - } - value={name} - onInput={(e) => - handleNameChange((e.target as HTMLInputElement).value) - } - class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" - /> - {fieldError("sub_name_input")} - <p class="text-xs text-gray-500 mt-1"> - {kind === "subscription" - ? t`What pass holders see in their wallets and contract receipts.` - : t`Discount name displayed during payment checkout and in wallets.`} - </p> - </div> - - <div> <label - htmlFor="sub_desc_input" - class="block text-xs font-bold text-gray-700 mb-1" + class={`rounded-lg border p-3 cursor-pointer ${benefitType === "percentage" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} > - {t`Description`} + <input + type="radio" + name="discount_type" + value="percentage" + checked={benefitType === "percentage"} + onChange={() => setBenefitType("percentage")} + class="mr-2" + /> + <span class="text-sm font-semibold">{t`Percentage off`}</span> </label> - <textarea - {...fieldProps("sub_desc_input")} - id="sub_desc_input" - rows={2} - placeholder={ - kind === "subscription" - ? t`e.g. Unlimited digital article access for 30 days...` - : t`e.g. Grants 10% off espresso purchases at participating locations...` - } - value={description} - onInput={(e) => - setDescription((e.target as HTMLTextAreaElement).value) - } - class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" - /> - {fieldError("sub_desc_input")} - <p class="text-xs text-gray-500 mt-1">{t`Detailed terms or redemption rules shown to customers.`}</p> - </div> - </div> - </div> - - {/* Section 2: automatic checkout semantics */} - <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> - <div> - <h2 - id="discount_rules_heading" - class="text-base font-bold text-gray-900" - > - {kind === "discount" - ? t`2. Discount rules` - : t`2. Redemption benefit`} - </h2> - <p class="text-xs text-gray-500 mt-1"> - {kind === "discount" - ? t`Configure how customers redeem this discount and how they earn new discounts.` - : t`Choose the benefit and products where this token can be redeemed.`} - </p> - </div> - - <section - aria-labelledby={ - kind === "discount" - ? "redemption_rules_heading" - : "discount_rules_heading" - } - class="space-y-4" - > - {kind === "discount" && ( - <div> - <h3 - id="redemption_rules_heading" - class="text-sm font-bold text-gray-900" - > - {t`Redeeming discounts`} - </h3> - <p class="text-xs text-gray-500 mt-1"> - {t`Choose what customers receive and which purchases accept this discount.`} - </p> - </div> - )} - - <fieldset class="space-y-3"> - <legend class="block text-xs font-bold text-gray-700">{t`Benefit calculation`}</legend> - {fieldError("benefit-type")} - <div - class={`grid grid-cols-1 ${kind === "discount" ? "sm:grid-cols-4" : "sm:grid-cols-3"} gap-3`} + <label + class={`rounded-lg border p-3 cursor-pointer ${benefitType === "flat" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} > + <input + type="radio" + name="discount_type" + value="flat" + checked={benefitType === "flat"} + onChange={() => setBenefitType("flat")} + class="mr-2" + /> + <span class="text-sm font-semibold">{t`Fixed amount off`}</span> + </label> + {kind === "discount" && ( <label - class={`rounded-lg border p-3 cursor-pointer ${benefitType === "none" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} + class={`rounded-lg border p-3 cursor-pointer ${benefitType === "free_item" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} > <input type="radio" name="discount_type" - value="none" - checked={benefitType === "none"} - onChange={() => setBenefitType("none")} + value="free_item" + checked={benefitType === "free_item"} + onChange={() => setBenefitType("free_item")} class="mr-2" /> - <span class="text-sm font-semibold">{t`No redemption benefit`}</span> + <span class="text-sm font-semibold">{t`Free item`}</span> </label> - <label - class={`rounded-lg border p-3 cursor-pointer ${benefitType === "percentage" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} - > + )} + </div> + {kind === "discount" && benefitType !== "none" && ( + <fieldset + data-field="sub_required_tokens" + tabIndex={-1} + {...fieldProps("sub_required_tokens")} + class="space-y-2" + > + <legend class="text-xs font-bold text-gray-700"> + {t`Number of discount stamps needed`} + </legend> + <div class="flex flex-wrap gap-2"> + {[...stampCountPresets, "custom"].map((value) => { + const selected = + value === "custom" + ? customStampCountSelected + : !customStampCountSelected && requiredTokens === value; + return ( + <label key={value} class="cursor-pointer"> + <input + type="radio" + name="stamp_count" + value={value} + checked={selected} + onChange={() => { + setCustomStampCountSelected(value === "custom"); + setRequiredTokens( + value === "custom" ? customStampCount : value, + ); + }} + class="peer sr-only" + /> + <span + class={`inline-flex min-w-11 items-center justify-center rounded-full border px-4 py-2 text-sm font-semibold peer-focus-visible:ring-2 peer-focus-visible:ring-purple-500 peer-focus-visible:ring-offset-2 ${selected ? "border-purple-500 bg-purple-50 text-purple-900" : "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"}`} + > + {value === "custom" ? t`Custom` : value} + </span> + </label> + ); + })} + </div> + {customStampCountSelected && ( <input - type="radio" - name="discount_type" - value="percentage" - checked={benefitType === "percentage"} - onChange={() => setBenefitType("percentage")} - class="mr-2" + {...fieldProps("sub_required_tokens")} + id="sub_required_tokens" + aria-label={t`Number of discount stamps needed`} + type="number" + min="1" + step="1" + value={requiredTokens} + onInput={(event) => { + const value = (event.target as HTMLInputElement).value; + setCustomStampCount(value); + setRequiredTokens(value); + }} + class="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg" /> - <span class="text-sm font-semibold">{t`Percentage benefit`}</span> - </label> - <label - class={`rounded-lg border p-3 cursor-pointer ${benefitType === "flat" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} + )} + {fieldError("sub_required_tokens")} + </fieldset> + )} + {benefitType === "none" ? ( + <p class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-700"> + {kind === "subscription" + ? t`Your website or service must check the pass to grant access. This setting does not configure access or give passes to customers.` + : t`Customers can collect discount stamps, but cannot yet exchange them for a reward at checkout.`} + </p> + ) : benefitType === "percentage" ? ( + <PercentageRewardEditor + percentage={percentage} + onPercentageChange={setPercentage} + mode={roundingMode} + onModeChange={setRoundingMode} + precision={roundingPrecision} + onPrecisionChange={(value) => { + roundingPrecisionIsDefault.current = false; + setRoundingPrecision(value); + }} + currency={primaryCurrency} + precisionOpen={roundingPrecisionOpen} + onPrecisionOpenChange={setRoundingPrecisionOpen} + fieldProps={fieldProps} + fieldError={fieldError} + /> + ) : benefitType === "flat" ? ( + <div + role="group" + aria-label={t`Discount amounts`} + data-field="currency-caps" + tabIndex={-1} + {...fieldProps("currency-caps")} + class="space-y-2" + > + <span class="block text-xs font-bold text-gray-700">{t`Discount amounts`}</span> + <p class="text-xs text-gray-500">{t`The discount cannot exceed the eligible purchase amount. Unused value is not carried forward.`}</p> + {fieldError("currency-caps")} + {flatAmounts.map((amount, index) => { + const currency = amount.includes(":") + ? amount.slice(0, amount.indexOf(":")).toUpperCase() + : primaryCurrency.toUpperCase(); + const usedByOtherRows = new Set( + flatAmounts + .filter((_, candidateIndex) => candidateIndex !== index) + .map((candidate) => + candidate.split(":", 1)[0]?.toUpperCase(), + ), + ); + const availableCurrencies = supportedCurrencies.filter( + (candidate) => + !usedByOtherRows.has(candidate) || candidate === currency, + ); + const unsupported = !supportedCurrencies.includes(currency); + return ( + <div + key={`${index}-${currency}`} + class="flex items-start gap-2" + > + <AmountInput + id={`sub_discount_flat_${index}`} + value={amount} + primaryCurrency={currency} + currencies={availableCurrencies} + step={currencyFractionalStep( + currency, + currencySpecifications, + )} + preserveCurrencyWhenEmpty + preserveExplicitCurrency + onChange={(value) => { + flatAmountsAreDefault.current = false; + setFlatAmounts((current) => + current.map((entry, candidateIndex) => + candidateIndex === index ? value : entry, + ), + ); + }} + class="w-56" + /> + <button + type="button" + disabled={flatAmounts.length === 1} + onClick={() => { + flatAmountsAreDefault.current = false; + setFlatAmounts((current) => + current.filter( + (_, candidateIndex) => candidateIndex !== index, + ), + ); + }} + class="mt-1 px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-50 rounded-lg disabled:text-gray-400 disabled:hover:bg-transparent" + > + {t`Remove`} + </button> + {unsupported && ( + <span class="mt-2 text-xs font-medium text-amber-700">{t`Unsupported currency`}</span> + )} + </div> + ); + })} + <button + type="button" + disabled={supportedCurrencies.every((currency) => + flatAmounts.some( + (amount) => + amount.split(":", 1)[0]?.toUpperCase() === currency, + ), + )} + onClick={() => { + const nextCurrency = supportedCurrencies.find( + (currency) => + !flatAmounts.some( + (amount) => + amount.split(":", 1)[0]?.toUpperCase() === currency, + ), + ); + if (!nextCurrency) return; + flatAmountsAreDefault.current = false; + setFlatAmounts((current) => [ + ...current, + `${nextCurrency}:`, + ]); + }} + class="px-3 py-2 text-xs font-bold text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 disabled:text-gray-400 disabled:border-gray-200 disabled:hover:bg-transparent" > - <input - type="radio" - name="discount_type" - value="flat" - checked={benefitType === "flat"} - onChange={() => setBenefitType("flat")} - class="mr-2" - /> - <span class="text-sm font-semibold">{t`Capped flat benefit`}</span> - </label> - {kind === "discount" && ( + {t`Add discount amount for another currency`} + </button> + </div> + ) : ( + <fieldset class="space-y-2"> + <legend class="block text-xs font-bold text-gray-700">{t`Which item is free?`}</legend> + <div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> <label - class={`rounded-lg border p-3 cursor-pointer ${benefitType === "free_item" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} + class={`rounded-lg border p-3 cursor-pointer ${freeItemPriceSelection === "cheapest" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} > <input type="radio" - name="discount_type" - value="free_item" - checked={benefitType === "free_item"} - onChange={() => setBenefitType("free_item")} + name="free_item_price_selection" + value="cheapest" + checked={freeItemPriceSelection === "cheapest"} + onChange={() => setFreeItemPriceSelection("cheapest")} class="mr-2" /> - <span class="text-sm font-semibold">{t`Free item`}</span> + <span class="text-sm font-semibold">{t`Lowest-priced eligible item`}</span> </label> - )} - </div> - {benefitType === "none" ? ( - <p class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-700"> - {t`No automatic redemption choice is created. Discounts can still be earned through the rules below.`} - </p> - ) : benefitType === "percentage" ? ( - <div class="space-y-3"> - <div> - <label - htmlFor="sub_discount_percentage" - class="block text-xs font-bold text-gray-700 mb-1" - >{t`Percentage`}</label> - <div class="flex items-center gap-2"> - <input - {...fieldProps("sub_discount_percentage")} - id="sub_discount_percentage" - type="text" - inputMode="decimal" - value={percentage} - onInput={(e) => - setPercentage((e.target as HTMLInputElement).value) - } - class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" - /> - <span class="text-sm text-gray-600">%</span> - </div> - {fieldError("sub_discount_percentage")} - </div> - <div class="rounded-lg border border-gray-200"> - <button - type="button" - aria-expanded={showRoundingOptions} - aria-controls="sub_discount_rounding_options" - onClick={() => - setShowRoundingOptions(!showRoundingOptions) + <label + class={`rounded-lg border p-3 cursor-pointer ${freeItemPriceSelection === "most_expensive" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} + > + <input + type="radio" + name="free_item_price_selection" + value="most_expensive" + checked={freeItemPriceSelection === "most_expensive"} + onChange={() => + setFreeItemPriceSelection("most_expensive") } - class="flex w-full items-center justify-between gap-3 px-3 py-2 text-left" - > - <span> - <span class="block text-xs font-bold text-gray-700">{t`Rounding options`}</span> - <span class="block text-2xs text-gray-500 mt-0.5"> - {t`Current: ${roundingModeSummary}; precision ${roundingPrecision}`} - </span> - </span> - <DisclosureChevron open={showRoundingOptions} /> - </button> - {showRoundingOptions && ( - <div - id="sub_discount_rounding_options" - class="grid grid-cols-1 sm:grid-cols-2 gap-3 border-t border-gray-100 p-3" - > - <div> - <label - htmlFor="sub_discount_rounding_mode" - class="block text-xs font-bold text-gray-700 mb-1" - >{t`Rounding mode`}</label> - <select - id="sub_discount_rounding_mode" - value={roundingMode} - onChange={(e) => - setRoundingMode( - (e.target as HTMLSelectElement).value as - | "down" - | "nearest" - | "up", - ) - } - class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white" - > - <option value="down">{t`Round down`}</option> - <option value="nearest">{t`Round to nearest`}</option> - <option value="up">{t`Round up`}</option> - </select> - </div> - <div> - <label - htmlFor="sub_discount_rounding_precision" - class="block text-xs font-bold text-gray-700 mb-1" - >{t`Rounding precision`}</label> - <input - {...fieldProps("sub_discount_rounding_precision")} - id="sub_discount_rounding_precision" - type="text" - inputMode="decimal" - value={roundingPrecision} - onInput={(e) => { - roundingPrecisionIsDefault.current = false; - setRoundingPrecision( - (e.target as HTMLInputElement).value, - ); - }} - class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg font-mono" - /> - {fieldError("sub_discount_rounding_precision")} - <p class="text-2xs text-gray-500 mt-1">{t`Currency units, for example 0.01 or 0.05.`}</p> - </div> - </div> - )} - </div> + class="mr-2" + /> + <span class="text-sm font-semibold">{t`Highest-priced eligible item`}</span> + </label> </div> - ) : benefitType === "flat" ? ( + <p class="text-xs text-gray-500">{t`One item from the selected products is free.`}</p> + </fieldset> + )} + </fieldset> + + {benefitType !== "none" && ( + <fieldset class="space-y-3"> + <legend class="block text-xs font-bold text-gray-700"> + {kind === "discount" + ? t`Which purchases can use this reward?` + : t`Which purchases can use this pass?`} + </legend> + {benefitType !== "free_item" && + renderProductScope( + "redemption", + redemptionWildcard, + setRedemptionAllPurchases, + )} + + {!redemptionWildcard && ( <div - role="group" - aria-label={t`Maximum benefit amounts`} - {...fieldProps("currency-caps")} - class="space-y-2" + data-field="redemption-products" + tabIndex={-1} + {...fieldProps("redemption-products")} + class="space-y-4" > - <span class="block text-xs font-bold text-gray-700">{t`Maximum benefit amounts`}</span> - {fieldError("currency-caps")} - {flatAmounts.map((amount, index) => { - const currency = amount.includes(":") - ? amount.slice(0, amount.indexOf(":")).toUpperCase() - : primaryCurrency.toUpperCase(); - const usedByOtherRows = new Set( - flatAmounts - .filter((_, candidateIndex) => candidateIndex !== index) - .map((candidate) => - candidate.split(":", 1)[0]?.toUpperCase(), - ), - ); - const availableCurrencies = supportedCurrencies.filter( - (candidate) => - !usedByOtherRows.has(candidate) || - candidate === currency, - ); - const unsupported = !supportedCurrencies.includes(currency); - return ( - <div - key={`${index}-${currency}`} - class="flex items-start gap-2" - > - <AmountInput - id={`sub_discount_flat_${index}`} - value={amount} - primaryCurrency={currency} - currencies={availableCurrencies} - step={currencyFractionalStep( - currency, - currencySpecifications, - )} - preserveCurrencyWhenEmpty - preserveExplicitCurrency - onChange={(value) => { - flatAmountsAreDefault.current = false; - setFlatAmounts((current) => - current.map((entry, candidateIndex) => - candidateIndex === index ? value : entry, - ), - ); - }} - class="w-56" - /> - <button - type="button" - disabled={flatAmounts.length === 1} - onClick={() => { - flatAmountsAreDefault.current = false; - setFlatAmounts((current) => - current.filter( - (_, candidateIndex) => candidateIndex !== index, - ), - ); - }} - class="mt-1 px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-50 rounded-lg disabled:text-gray-400 disabled:hover:bg-transparent" - > - {t`Remove`} - </button> - {unsupported && ( - <span class="mt-2 text-xs font-medium text-amber-700">{t`Unsupported currency`}</span> - )} - </div> - ); - })} - <button - type="button" - disabled={supportedCurrencies.every((currency) => - flatAmounts.some( - (amount) => - amount.split(":", 1)[0]?.toUpperCase() === currency, - ), + {fieldError("redemption-products")} + <fieldset class="space-y-2"> + <legend class="text-xs font-medium text-gray-700">{t`Product categories`}</legend> + {renderCategorySelector( + selectedCategories, + setSelectedCategories, + t`No product categories are available. Create a category or select an individual product.`, )} - onClick={() => { - const nextCurrency = supportedCurrencies.find( - (currency) => - !flatAmounts.some( - (amount) => - amount.split(":", 1)[0]?.toUpperCase() === - currency, - ), - ); - if (!nextCurrency) return; - flatAmountsAreDefault.current = false; - setFlatAmounts((current) => [ - ...current, - `${nextCurrency}:`, - ]); - }} - class="px-3 py-2 text-xs font-bold text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 disabled:text-gray-400 disabled:border-gray-200 disabled:hover:bg-transparent" - > - {t`Add currency cap`} - </button> + </fieldset> + <fieldset class="space-y-2"> + <legend class="text-xs font-medium text-gray-700">{t`Individual products`}</legend> + {renderProductSelector( + selectedProducts, + setSelectedProducts, + t`No inventory products are available. Add a product or select a product category.`, + )} + </fieldset> </div> - ) : ( - <fieldset class="space-y-2"> - <legend class="block text-xs font-bold text-gray-700">{t`Free item policy`}</legend> - <div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> - <label - class={`rounded-lg border p-3 cursor-pointer ${freeItemPriceSelection === "cheapest" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} - > - <input - type="radio" - name="free_item_price_selection" - value="cheapest" - checked={freeItemPriceSelection === "cheapest"} - onChange={() => setFreeItemPriceSelection("cheapest")} - class="mr-2" - /> - <span class="text-sm font-semibold">{t`Lowest-priced eligible item`}</span> - </label> - <label - class={`rounded-lg border p-3 cursor-pointer ${freeItemPriceSelection === "most_expensive" ? "border-purple-500 bg-purple-50" : "border-gray-200"}`} - > - <input - type="radio" - name="free_item_price_selection" - value="most_expensive" - checked={freeItemPriceSelection === "most_expensive"} - onChange={() => - setFreeItemPriceSelection("most_expensive") - } - class="mr-2" - /> - <span class="text-sm font-semibold">{t`Highest-priced eligible item`}</span> - </label> - </div> - <p class="text-xs text-gray-500">{t`One unit of the selected eligible item is free.`}</p> - </fieldset> )} </fieldset> + )} + </section> + )} - {kind === "discount" && benefitType !== "none" && ( - <div> - <label - htmlFor="sub_required_tokens" - class="block text-xs font-bold text-gray-700 mb-1" - >{t`Discounts required to redeem`}</label> - <input - {...fieldProps("sub_required_tokens")} - id="sub_required_tokens" - type="number" - min="1" - step="1" - value={requiredTokens} - onInput={(event) => - setRequiredTokens((event.target as HTMLInputElement).value) - } - class="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg" - /> - {fieldError("sub_required_tokens")} - </div> - )} - - {benefitType !== "none" && ( - <fieldset - {...fieldProps("redemption-products")} - class="space-y-3" - > - <legend class="block text-xs font-bold text-gray-700"> - {t`Products where the benefit applies`} - </legend> - {fieldError("redemption-products")} - - <label - class={`flex items-start gap-2 rounded-lg border p-3 cursor-pointer ${redemptionWildcard ? "border-blue-500 bg-blue-50" : "border-gray-200"}`} - > - <input - type="checkbox" - aria-label={t`Apply benefit to all merchant purchases`} - checked={redemptionWildcard} - onChange={(event) => - setRedemptionWildcard( - (event.target as HTMLInputElement).checked, - ) - } - /> - <span> - <span class="block text-sm font-semibold text-gray-900"> - {t`Apply benefit to all merchant purchases`} - </span> - <span class="block text-xs text-gray-500"> - {t`The token can be redeemed on any line item and on amount-only purchases.`} - </span> - </span> - </label> - - {!redemptionWildcard && ( - <div class="space-y-4"> - <section class="space-y-2"> - <h4 class="text-xs font-bold text-gray-700">{t`Product categories`}</h4> - {renderCategorySelector( - selectedCategories, - setSelectedCategories, - "border-blue-500 bg-blue-50", - t`No product categories are available. Create a category or select an individual product.`, - )} - </section> - <section class="space-y-2"> - <h4 class="text-xs font-bold text-gray-700">{t`Individual inventory products`}</h4> - {renderProductSelector( - selectedProducts, - setSelectedProducts, - "border-blue-500 bg-blue-50", - t`No inventory products are available. Add a product or select a product category.`, - )} - </section> - </div> - )} - </fieldset> + {kind === "discount" && (!wizard || step === "earning") && ( + <section + aria-labelledby="earning_rules_heading" + class={`space-y-4 ${wizard ? "" : "border-t border-gray-200 pt-4"}`} + > + <div> + {!wizard && ( + <h3 + id="earning_rules_heading" + class="text-sm font-bold text-gray-900" + >{t`Collecting discount stamps`}</h3> )} - </section> - - {kind === "discount" && ( - <section - aria-labelledby="earning_rules_heading" - class="space-y-4 border-t border-gray-200 pt-4" + <p + class={ + wizard ? "text-sm text-gray-600" : "text-xs text-gray-500 mt-1" + } > - <div> - <h3 - id="earning_rules_heading" - class="text-sm font-bold text-gray-900" - >{t`Earning discounts`}</h3> - <p class="text-xs text-gray-500 mt-1">{t`Each qualifying paid order earns exactly one discount.`}</p> - </div> - <fieldset {...fieldProps("issuance-products")} class="space-y-3"> - <legend class="block text-xs font-bold text-gray-700"> - {t`Products where discounts are earned`} - </legend> + {t`Each qualifying paid purchase earns one discount stamp, regardless of how many eligible items it contains.`} + </p> + </div> + <fieldset class="space-y-3"> + <legend class="block text-xs font-bold text-gray-700"> + {t`Which purchases earn a discount stamp?`} + </legend> + {renderProductScope( + "issuance", + issuanceWildcard, + setIssuanceWildcard, + )} + {!issuanceWildcard && ( + <div + data-field="issuance-products" + tabIndex={-1} + {...fieldProps("issuance-products")} + class="space-y-4" + > {fieldError("issuance-products")} - <label - class={`flex items-start gap-2 rounded-lg border p-3 cursor-pointer ${issuanceWildcard ? "border-blue-500 bg-blue-50" : "border-gray-200"}`} - > - <input - type="checkbox" - aria-label={t`Earn discounts on all merchant purchases`} - checked={issuanceWildcard} - onChange={(event) => - setIssuanceWildcard( - (event.target as HTMLInputElement).checked, - ) - } - /> - <span> - <span class="block text-sm font-semibold text-gray-900">{t`Earn discounts on all merchant purchases`}</span> - <span class="block text-xs text-gray-500">{t`Also supports amount-only and ad-hoc purchases.`}</span> - </span> - </label> - {!issuanceWildcard && ( - <div class="space-y-4"> - <section class="space-y-2"> - <h4 class="text-xs font-bold text-gray-700">{t`Product categories`}</h4> - {renderCategorySelector( - issuanceCategories, - setIssuanceCategories, - "border-emerald-500 bg-emerald-50", - t`No product categories are available. Create a category or select an individual product.`, - )} - </section> - <section class="space-y-2"> - <h4 class="text-xs font-bold text-gray-700">{t`Individual inventory products`}</h4> - {renderProductSelector( - issuanceProducts, - setIssuanceProducts, - "border-emerald-500 bg-emerald-50", - t`No inventory products are available. Add a product or select a product category.`, - )} - </section> - </div> - )} - </fieldset> - <div> - <div> - <AmountInput - {...fieldProps("sub_minimum_purchase")} - id="sub_minimum_purchase" - value={minimumPurchase} - label={t`Minimum qualifying purchase (optional)`} - primaryCurrency={primaryCurrency} - currencies={supportedCurrencies} - step={currencyFractionalStep( - minimumPurchase.includes(":") - ? minimumPurchase.slice(0, minimumPurchase.indexOf(":")) - : primaryCurrency, - currencySpecifications, - )} - preserveCurrencyWhenEmpty - preserveExplicitCurrency - onChange={setMinimumPurchase} - class="w-64" - /> - {fieldError("sub_minimum_purchase")} - </div> + <fieldset class="space-y-2"> + <legend class="text-xs font-medium text-gray-700">{t`Product categories`}</legend> + {renderCategorySelector( + issuanceCategories, + setIssuanceCategories, + t`No product categories are available. Create a category or select an individual product.`, + )} + </fieldset> + <fieldset class="space-y-2"> + <legend class="text-xs font-medium text-gray-700">{t`Individual products`}</legend> + {renderProductSelector( + issuanceProducts, + setIssuanceProducts, + t`No inventory products are available. Add a product or select a product category.`, + )} + </fieldset> </div> - <label class="flex items-start gap-2 text-sm text-gray-800"> - <input - type="checkbox" - aria-label={t`Earn a discount when redeeming this same discount`} - checked={issueOnRedemption} - onChange={(event) => - setIssueOnRedemption( - (event.target as HTMLInputElement).checked, - ) - } - /> - <span> - <span class="block font-semibold">{t`Earn a discount when redeeming this same discount`}</span> - <span class="block text-xs text-gray-500">{t`Off by default so redemption does not immediately replace an earned discount.`}</span> - </span> - </label> - </section> - )} - </div> - - {/* Section 3: Time Window & Duration */} - <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> - <h2 class="text-base font-bold text-gray-900"> - {kind === "subscription" - ? t`3. Duration & Privacy` - : t`3. Discount Validity`} - </h2> - - <div class="max-w-sm"> + )} + </fieldset> + <div> <div> - <label - htmlFor="sub_duration" - class="block text-xs font-bold text-gray-700 mb-1" - > - {kind === "subscription" - ? t`Pass Duration` - : t`Discount Lifetime`} - </label> - <select - id="sub_duration" - value={duration} - onChange={(e) => - setDuration((e.target as HTMLSelectElement).value) - } - class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white" - > - <option value="1d">{t`1 Day`}</option> - <option value="7d">{t`7 Days`}</option> - <option value="30d">{t`30 Days`}</option> - <option value="90d">{t`90 Days (Quarter)`}</option> - <option value="365d">{t`365 Days (1 Year)`}</option> - </select> - <p class="text-2xs text-gray-500 mt-1"> - {kind === "subscription" - ? t`How long pass access lasts once activated.` - : t`How long an issued discount remains redeemable.`} - </p> + <AmountInput + {...fieldProps("sub_minimum_purchase")} + id="sub_minimum_purchase" + value={minimumPurchase} + label={minimumSpendLabel(t, issuanceWildcard)} + primaryCurrency={primaryCurrency} + currencies={supportedCurrencies} + step={currencyFractionalStep( + minimumPurchase.includes(":") + ? minimumPurchase.slice(0, minimumPurchase.indexOf(":")) + : primaryCurrency, + currencySpecifications, + )} + preserveCurrencyWhenEmpty + preserveExplicitCurrency + onChange={setMinimumPurchase} + class="w-64" + /> + {fieldError("sub_minimum_purchase")} + {!issuanceWildcard && ( + <p class="mt-1 text-xs text-gray-500">{t`Other products in the same purchase do not count toward this minimum.`}</p> + )} </div> </div> + </section> + )} + </div> + ); + const renderTiming = () => ( + <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> + {wizard ? ( + renderStepHeading() + ) : ( + <h2 class="text-base font-bold text-gray-900">{t`3. Validity`}</h2> + )} + + <div class="max-w-sm"> + <div> + <label + htmlFor="sub_duration" + class="block text-xs font-bold text-gray-700 mb-1" + > + {kind === "subscription" + ? t`Pass validity period` + : t`Discount stamp validity period`} + </label> + <select + id="sub_duration" + value={duration} + onChange={(e) => setDuration((e.target as HTMLSelectElement).value)} + class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white" + > + <option value="1d">{t`1 Day`}</option> + <option value="7d">{t`7 Days`}</option> + <option value="30d">{t`30 Days`}</option> + <option value="90d">{t`90 Days (Quarter)`}</option> + <option value="365d">{t`365 Days (1 Year)`}</option> + </select> + <p class="text-2xs text-gray-500 mt-1"> + {kind === "subscription" + ? t`Passes share expiry dates. The time available to use a pass can be shorter than this period.` + : t`Discount stamps share expiry dates. The time available to use a discount stamp can be shorter than this period.`} + </p> + </div> + </div> + {wizard && ( + <button + type="button" + class="text-sm text-blue-700" + aria-expanded={expiryOptions} + aria-controls="expiry-options" + onClick={() => setExpiryOptions(!expiryOptions)} + >{t`Shared expiry dates: ${validityPeriodLabel(t, stepGranularity)}`}</button> + )} + {(!wizard || expiryOptions) && ( + <div id="expiry-options"> + {" "} {/* Granularity Setting */} <div class="border-t border-gray-100 pt-4 space-y-2"> <label @@ -1801,8 +1601,8 @@ function CreateSubscriptionForm({ class="block text-xs font-bold text-gray-700 mb-1" > {kind === "subscription" - ? t`Group pass expiry times by` - : t`Group discount expiry times by`} + ? t`Shared expiry dates` + : t`Shared expiry dates`} </label> <select {...fieldProps("sub_granularity")} @@ -1826,20 +1626,20 @@ function CreateSubscriptionForm({ <p class="text-xs text-gray-600 leading-relaxed bg-gray-50 p-3 rounded-lg border border-gray-200"> {kind === "subscription" ? ( <span> - <strong>{t`Why group expiry times?`}</strong>{" "} - {t`Passes started in the same period expire together. A wider period makes it harder to single out a customer from a precise timestamp.`} + <strong>{t`Why share expiry dates?`}</strong>{" "} + {t`Passes issued in the same period expire together. Wider groups help protect customer privacy, but can shorten the time available to use a pass.`} </span> ) : ( <span> - <strong>{t`Shared expiry time:`}</strong>{" "} - {t`Discounts issued in the same period expire together.`} + <strong>{t`Shared expiry dates:`}</strong>{" "} + {t`Discount stamps issued in the same period expire together. Wider groups help protect customer privacy, but can shorten the time available to use a discount stamp.`} </span> )} </p> {isEditing && ( <p class="text-2xs text-amber-800 bg-amber-50 p-2 rounded border border-amber-200 mt-1 font-medium"> - {t`🔒 Cannot be changed — the discounts and passes already issued rely on it.`} + {t`Cannot be changed because customers may already hold discount stamps or passes with these settings.`} </p> )} @@ -1850,176 +1650,301 @@ function CreateSubscriptionForm({ )} </div> </div> - - {/* Section 4: Advanced Options */} - <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> - <div class="flex items-center justify-between"> - <div> - <h2 class="text-base font-bold text-gray-900">{t`4. Advanced Options`}</h2> - <p class="text-xs text-gray-500 mt-0.5">{t`Validity window and technical identifier override.`}</p> - </div> - <button - type="button" - onClick={() => setShowAdvanced(!showAdvanced)} - class="text-xs font-bold text-blue-600 hover:underline" + )} + {wizard && renderAdvanced()} + </div> + ); + const renderAdvanced = () => ( + <section + aria-labelledby="token-advanced-heading" + class={ + wizard + ? "border-t border-gray-200 pt-4 space-y-4" + : "bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4" + } + > + <div class="flex items-center justify-between"> + <div> + {wizard ? ( + <h3 + id="token-advanced-heading" + class="text-sm font-semibold text-gray-900" > - <span class="inline-flex items-center gap-1"> - <DisclosureChevron open={showAdvanced} /> - {showAdvanced - ? t`Hide advanced options` - : t`Show advanced options`} - </span> - </button> - </div> + {step === "timing" + ? t`Start and end dates (optional)` + : t`Technical identifier`} + </h3> + ) : ( + <> + <h2 + id="token-advanced-heading" + class="text-base font-bold text-gray-900" + >{t`4. Advanced Options`}</h2> + <p class="text-xs text-gray-500 mt-0.5">{t`Start and end dates, and the technical identifier.`}</p> + </> + )} + </div> + {(!wizard || step === "basics") && ( + <button + type="button" + aria-expanded={showAdvanced} + aria-controls="token-advanced-options" + onClick={() => setShowAdvanced(!showAdvanced)} + class="text-xs font-bold text-blue-600 hover:underline" + > + <span class="inline-flex items-center gap-1"> + <DisclosureChevron open={showAdvanced} /> + {showAdvanced + ? t`Hide advanced options` + : t`Show advanced options`} + </span> + </button> + )} + </div> - {showAdvanced && ( - <div class="pt-2 space-y-4 border-t border-gray-100"> - <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> - <div class="rounded-lg border border-gray-200 p-3 space-y-3"> - <label class="flex items-start gap-2 cursor-pointer"> + {(showAdvanced || (wizard && step === "timing")) && ( + <div + id="token-advanced-options" + class={ + wizard ? "space-y-4" : "pt-2 space-y-4 border-t border-gray-100" + } + > + {(!wizard || step === "timing") && ( + <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> + <div class="rounded-lg border border-gray-200 p-3 space-y-3"> + <label class="flex items-start gap-2 cursor-pointer"> + <input + id="sub_valid_from_enabled" + type="checkbox" + aria-label={t`Set a start date`} + checked={useValidFrom} + onChange={(e) => + setUseValidFrom((e.target as HTMLInputElement).checked) + } + /> + <span> + <span class="block text-sm font-semibold text-gray-900">{t`Start date`}</span> + <span class="block text-xs text-gray-500">{t`Starts now unless you choose a date.`}</span> + </span> + </label> + {useValidFrom && ( + <div> + <label + htmlFor="sub_valid_from" + class="block text-xs font-bold text-gray-700 mb-1" + > + {t`Start date`} + </label> <input - id="sub_valid_from_enabled" - type="checkbox" - aria-label={t`Set an explicit Valid From date`} - checked={useValidFrom} + {...fieldProps("sub_valid_from")} + id="sub_valid_from" + type="date" + required + value={validFrom} onChange={(e) => - setUseValidFrom((e.target as HTMLInputElement).checked) + setValidFrom((e.target as HTMLInputElement).value) } + class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> - <span> - <span class="block text-sm font-semibold text-gray-900">{t`Valid From`}</span> - <span class="block text-xs text-gray-500">{t`By default, validity starts at the current time.`}</span> - </span> - </label> - {useValidFrom && ( - <div> - <label - htmlFor="sub_valid_from" - class="block text-xs font-bold text-gray-700 mb-1" - > - {t`First valid date`} - </label> - <input - {...fieldProps("sub_valid_from")} - id="sub_valid_from" - type="date" - required - value={validFrom} - onChange={(e) => - setValidFrom((e.target as HTMLInputElement).value) - } - class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" - /> - {fieldError("sub_valid_from")} - <p class="text-2xs text-gray-500 mt-1"> - {kind === "subscription" - ? t`First date this pass can be issued or used.` - : t`First date this discount can be issued or used.`} - </p> - </div> - )} - </div> + {fieldError("sub_valid_from")} + <p class="text-2xs text-gray-500 mt-1"> + {kind === "subscription" + ? t`Starts at 00:00 UTC on this date.` + : t`Starts at 00:00 UTC on this date.`} + </p> + </div> + )} + </div> - <div class="rounded-lg border border-gray-200 p-3 space-y-3"> - <label class="flex items-start gap-2 cursor-pointer"> + <div class="rounded-lg border border-gray-200 p-3 space-y-3"> + <label class="flex items-start gap-2 cursor-pointer"> + <input + id="sub_valid_until_enabled" + type="checkbox" + aria-label={t`Set an end date`} + checked={useValidUntil} + onChange={(e) => + setUseValidUntil((e.target as HTMLInputElement).checked) + } + /> + <span> + <span class="block text-sm font-semibold text-gray-900">{t`End date`}</span> + <span class="block text-xs text-gray-500">{t`By default, there is no end date.`}</span> + </span> + </label> + {useValidUntil && ( + <div> + <label + htmlFor="sub_valid_until" + class="block text-xs font-bold text-gray-700 mb-1" + > + {t`End date`} + </label> <input - id="sub_valid_until_enabled" - type="checkbox" - aria-label={t`Set an explicit Valid Until date`} - checked={useValidUntil} + {...fieldProps("sub_valid_until")} + id="sub_valid_until" + type="date" + required + value={validUntil} onChange={(e) => - setUseValidUntil((e.target as HTMLInputElement).checked) + setValidUntil((e.target as HTMLInputElement).value) } + class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> - <span> - <span class="block text-sm font-semibold text-gray-900">{t`Valid Until`}</span> - <span class="block text-xs text-gray-500">{t`By default, there is no end date.`}</span> - </span> - </label> - {useValidUntil && ( - <div> - <label - htmlFor="sub_valid_until" - class="block text-xs font-bold text-gray-700 mb-1" - > - {t`Last valid date`} - </label> - <input - {...fieldProps("sub_valid_until")} - id="sub_valid_until" - type="date" - required - value={validUntil} - onChange={(e) => - setValidUntil((e.target as HTMLInputElement).value) - } - class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" - /> - {fieldError("sub_valid_until")} - <p class="text-2xs text-gray-500 mt-1"> - {kind === "subscription" - ? t`Cut-off date after which no new passes can start.` - : t`Cut-off date after which no new discounts can start.`} - </p> - </div> - )} - </div> + {fieldError("sub_valid_until")} + <p class="text-2xs text-gray-500 mt-1"> + {kind === "subscription" + ? t`An expiry limit, not just a cutoff for issuing passes. Ends at 00:00 UTC at the start of this date. Changing it does not rewrite expiry dates already assigned to passes.` + : t`An expiry limit, not just a cutoff for collecting discount stamps. Ends at 00:00 UTC at the start of this date. Changing it does not rewrite expiry dates already assigned to discount stamps.`} + </p> + </div> + )} </div> + </div> + )} + {(!wizard || step === "basics") && ( + <div class={wizard ? "" : "pt-2 border-t border-gray-100"}> + <label + htmlFor="sub_slug_input" + class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between" + > + <span>{t`Identifier (ID)`}</span> + {manualId && !isEditing && ( + <button + type="button" + onClick={() => { + setManualId(false); + setSlugId(`token_${slugify(name)}`); + }} + class="text-2xs text-blue-600 hover:underline capitalize font-normal" + > + {t`Reset to suggested`} + </button> + )} + </label> + <input + {...fieldProps("sub_slug_input")} + id="sub_slug_input" + type="text" + disabled={isEditing} + value={slugId} + onInput={(e) => + handleSlugIdChange((e.target as HTMLInputElement).value) + } + class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg font-mono disabled:bg-gray-100 disabled:text-gray-500" + /> + {fieldError("sub_slug_input")} + <p class="text-xs text-gray-500 mt-1"> + {t`Used by connected software. Keep the suggested value unless your integration needs a specific identifier. Cannot be changed later.`} + </p> + </div> + )} + </div> + )} + </section> + ); - <div class="pt-2 border-t border-gray-100"> - <label - htmlFor="sub_slug_input" - class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between" - > - <span>{t`Identifier (ID)`}</span> - {manualId && !isEditing && ( - <button - type="button" - onClick={() => { - setManualId(false); - setSlugId(`token_${slugify(name)}`); - }} - class="text-2xs text-blue-600 hover:underline capitalize font-normal" - > - {t`Reset to suggested`} - </button> - )} - </label> - <input - {...fieldProps("sub_slug_input")} - id="sub_slug_input" - type="text" - disabled={isEditing} - value={slugId} - onInput={(e) => - handleSlugIdChange((e.target as HTMLInputElement).value) - } - class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg font-mono disabled:bg-gray-100 disabled:text-gray-500" - /> - {fieldError("sub_slug_input")} - <p class="text-xs text-gray-500 mt-1"> - {t`Unique identifier in backend contracts. Cannot be changed later.`} - </p> - </div> + const cancelAction = ( + <button + type="button" + onClick={() => setLocation("/subscriptions")} + class="px-5 py-2.5 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors" + > + {t`Cancel`} + </button> + ); + + return ( + <div class="space-y-6"> + <Header + onBack={() => setLocation("/subscriptions")} + backLabel={t`Discounts & Passes`} + title={ + wizard ? ( + <ReservedSpace text alternative={titleFor(otherKind)}> + {titleFor(kind)} + </ReservedSpace> + ) : ( + titleFor(kind) + ) + } + subtitle={ + wizard ? ( + <ReservedSpace text alternative={subtitleFor(otherKind)}> + {subtitleFor(kind)} + </ReservedSpace> + ) : ( + subtitleFor(kind) + ) + } + /> + + {!primaryCurrency && configResource?.error && ( + <ReadErrorBanner + resource={configResource} + title={t`Currency configuration could not be loaded`} + /> + )} + {!primaryCurrency && configResource?.isLoading && ( + <InitialLoadingState>{t`Loading currency configuration…`}</InitialLoadingState> + )} + + <form + ref={formRef} + style={wizard ? { overflowAnchor: "none" } : undefined} + noValidate={wizard} + onSubmit={handleSubmit} + class={`space-y-6 max-w-3xl ${wizard ? "token-setup-wizard" : ""}`} + > + {wizard && ( + <nav aria-label={t`Setup progress`}> + <ReservedSpace alternative={renderProgress(otherKind, true)}> + {renderProgress(kind)} + </ReservedSpace> + </nav> + )} + <div class="space-y-6"> + {(!wizard || step !== "review") && ( + <div data-token-setup-fields class="space-y-6"> + {(!wizard || step === "basics") && renderBasics()} + {(!wizard || step === "earning" || step === "benefit") && + renderRules()} + {(!wizard || step === "timing") && renderTiming()} + {!wizard && renderAdvanced()} </div> )} + {wizard && step === "review" && ( + <TokenSetupReview + heading={renderStepHeading()} + draft={draft} + primaryCurrency={primaryCurrency} + expandTechnicalDetails={ + reviewTarget === "sub_slug_input" || + reviewTarget === "sub_discount_rounding_precision" + } + onEdit={editReview} + /> + )} </div> {/* Form Footer */} <FormActionFooter + leadingActions={wizard ? cancelAction : undefined} error={rawError} validationError={rawError ? undefined : errorMsg} feedbackId={feedbackId} pending={isSubmitting} - title={t`Could not save rule`} + title={t`Could not save discount stamps or pass`} > - <button - type="button" - onClick={() => setLocation("/subscriptions")} - class="px-5 py-2.5 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors" - > - {t`Cancel`} - </button> + {!wizard && cancelAction} + {wizard && step !== "basics" && ( + <button + type="button" + disabled={isSubmitting} + onClick={() => navigateStep(steps[steps.indexOf(step) - 1]!)} + class="px-5 py-2.5 text-sm font-medium text-gray-700" + >{t`Back`}</button> + )} <button type="submit" disabled={!primaryCurrency || isSubmitting} @@ -2027,9 +1952,15 @@ function CreateSubscriptionForm({ > {isSubmitting ? t`Saving...` - : isEditing - ? t`Save Changes` - : t`Create Discount / Pass`} + : wizard && step !== "review" + ? returnToReview + ? t`Return to review` + : t`Continue` + : isEditing + ? t`Save Changes` + : kind === "discount" + ? t`Create discount stamps` + : t`Create pass`} </button> </FormActionFooter> </form> diff --git a/packages/taler-merchant-webui/src/screens/PersonalizationScreen.tsx b/packages/taler-merchant-webui/src/screens/PersonalizationScreen.tsx @@ -36,6 +36,9 @@ export function PersonalizationScreen(): VNode { const [draftAdvancedTools, setDraftAdvancedTools] = useState<boolean>( preferences.value.advancedTools, ); + const [draftTokenSetupWizard, setDraftTokenSetupWizard] = useState( + preferences.value.tokenSetupWizard, + ); const [saveMessage, setSaveMessage] = useState<string | null>(null); const notifySaved = ( @@ -50,6 +53,7 @@ export function PersonalizationScreen(): VNode { const handleSavePreferences = () => { updatePreference("dateFormat", draftDateFormat); updatePreference("advancedTools", draftAdvancedTools); + updatePreference("tokenSetupWizard", draftTokenSetupWizard); notifySaved(t`✓ All preferences saved successfully to this browser`); }; @@ -131,6 +135,29 @@ export function PersonalizationScreen(): VNode { </div> </div> + {draftAdvancedTools && ( + <div class="space-y-1"> + <label + aria-label={t`Use guided discount and pass setup`} + htmlFor="pref-token-setup-wizard" + class="flex items-start gap-3 text-sm text-gray-700" + > + <input + id="pref-token-setup-wizard" + type="checkbox" + checked={draftTokenSetupWizard} + onChange={(e) => + setDraftTokenSetupWizard(e.currentTarget.checked) + } + /> + <span> + <span class="block font-semibold">{t`Use guided discount and pass setup`}</span> + <span class="block text-xs text-gray-500">{t`Create discounts and passes step by step, with a review before saving. Requires advanced tools.`}</span> + </span> + </label> + </div> + )} + {/* Explicit Save Action */} <FormActionFooter variant="inline"> {saveMessage && ( diff --git a/packages/taler-merchant-webui/src/screens/SubscriptionsScreen.tsx b/packages/taler-merchant-webui/src/screens/SubscriptionsScreen.tsx @@ -105,15 +105,15 @@ export function SubscriptionsScreen({ <div> <Header title={t`Discounts & Passes`} - subtitle={t`Manage customer discounts and time-based access passes.`} - primaryActionText={t`+ Create discount or pass`} + subtitle={t`Manage discount stamps and reusable passes.`} + primaryActionText={t`+ Create discount stamps or pass`} onPrimaryAction={handleCreateClick} /> {itemsResource && ( <ReadErrorBanner resource={itemsResource} - title={t`Could not load discounts and passes`} + title={t`Could not load discount stamps and passes`} className="mb-4" /> )} @@ -125,9 +125,9 @@ export function SubscriptionsScreen({ [ { key: "all", - label: `${t`All discounts and passes`} (${displayList.length})`, + label: `${t`All discount stamps and passes`} (${displayList.length})`, }, - { key: "discount", label: t`Discounts` }, + { key: "discount", label: t`Discount stamps` }, { key: "subscription", label: t`Passes` }, ] as const ).map((tab) => ( @@ -155,16 +155,16 @@ export function SubscriptionsScreen({ ) : initialLoadFailed ? null : displayList.length === 0 ? ( <div class="p-8 text-center bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl space-y-3"> <span class="text-3xl block">🎫</span> - <h2 class="text-base font-bold text-gray-900">{t`No discounts or passes yet`}</h2> + <h2 class="text-base font-bold text-gray-900">{t`No discount stamps or passes yet`}</h2> <p class="text-sm text-gray-500 max-w-md mx-auto leading-relaxed"> - {t`Define a discount customers can earn and redeem, or a pass they can use repeatedly for a set time.`} + {t`Create discount stamps customers can collect and exchange for a reward, or a pass they can use until it expires.`} </p> <button type="button" onClick={handleCreateClick} class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700" > - {t`+ Create discount or pass`} + {t`+ Create discount stamps or pass`} </button> </div> ) : ( @@ -173,7 +173,7 @@ export function SubscriptionsScreen({ <div class="w-full sm:w-64 ml-auto"> <input type="search" - aria-label={t`Search discounts and passes`} + aria-label={t`Search discount stamps and passes`} placeholder={t`Search name or ID...`} value={searchQuery} onInput={(e) => @@ -230,13 +230,15 @@ export function SubscriptionsScreen({ )} <dl class="mt-4 grid grid-cols-2 gap-3 text-xs"> <div> - <dt class="text-gray-500">{t`Kind`}</dt> + <dt class="text-gray-500">{t`Offer type`}</dt> <dd class="mt-0.5 font-medium text-gray-800"> - {item.kind === "subscription" ? t`Pass` : t`Discount`} + {item.kind === "subscription" + ? t`Pass` + : t`Discount stamps`} </dd> </div> <div> - <dt class="text-gray-500">{t`Can be used`}</dt> + <dt class="text-gray-500">{t`Validity limit`}</dt> <dd class="mt-0.5 font-medium text-gray-800"> {item.canBeUsed} </dd> @@ -252,8 +254,8 @@ export function SubscriptionsScreen({ <thead class="bg-gray-50 text-gray-500 font-semibold"> <tr> <th class="px-4 py-3 text-left">{t`Name & ID`}</th> - <th class="px-4 py-3 text-left">{t`Kind`}</th> - <th class="px-4 py-3 text-left">{t`Can be used`}</th> + <th class="px-4 py-3 text-left">{t`Offer type`}</th> + <th class="px-4 py-3 text-left">{t`Validity limit`}</th> <th class="w-14 px-3 py-3 text-right"> <span class="sr-only">{t`Actions`}</span> </th> @@ -297,7 +299,9 @@ export function SubscriptionsScreen({ {/* Words, not pictograms: at chip size the 🎟️ read as a red blob and the 📅 read as the number 17. */} <span class="text-xs font-semibold text-gray-700"> - {item.kind === "subscription" ? t`Pass` : t`Discount`} + {item.kind === "subscription" + ? t`Pass` + : t`Discount stamps`} </span> </td> <td class="px-4 py-3 text-gray-700 text-xs font-medium"> @@ -334,9 +338,9 @@ export function SubscriptionsScreen({ isOpen={Boolean(deletingItem)} onClose={() => setDeletingItem(null)} title={deletingItem ? t`Delete "${deletingItem.name}"?` : ""} - description={t`Are you sure you want to delete this discount or pass? Outstanding discounts or passes already held by customers will stop being accepted at checkout. This cannot be undone.`} + description={t`Delete these discount stamps or this pass? Discount stamps or passes customers already hold will stop being accepted at checkout. This cannot be undone.`} onConfirm={handleDeleteConfirm} - confirmLabel={t`Delete Discount / Pass`} + confirmLabel={t`Delete discount stamps or pass`} isPending={isDeleting} error={deleteError} /> diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx @@ -3026,7 +3026,7 @@ test("CreateDeviceScreen hydrates a delayed algorithm-2 edit exactly once", asyn document.body.removeChild(container); }); -test("SubscriptionsScreen uses Discounts & Passes terminology with discounts first", () => { +test("SubscriptionsScreen uses Discount stamps & Passes terminology with discounts first", () => { const container = document.createElement("div"); document.body.appendChild(container); @@ -3037,8 +3037,8 @@ test("SubscriptionsScreen uses Discounts & Passes terminology with discounts fir (button) => button.textContent?.trim(), ); assert.deepStrictEqual(tabs, [ - "All discounts and passes (0)", - "Discounts", + "All discount stamps and passes (0)", + "Discount stamps", "Passes", ]); assert.doesNotMatch(container.innerHTML, /How subscriptions and passes work/); @@ -3152,7 +3152,8 @@ test("CreateSubscriptionScreen derives precision from currency metadata without null, ); const roundingButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Rounding options"), + (button) => + button.getAttribute("aria-label")?.startsWith("Rounding increment:"), ); assert.ok(roundingButton); assert.strictEqual(roundingButton.getAttribute("aria-expanded"), "false"); @@ -3332,7 +3333,8 @@ test("CreateSubscriptionScreen edits multi-currency flat caps", async () => { ], ); const addButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Add currency cap"), + (button) => + button.textContent?.includes("Add discount amount for another currency"), ); assert.strictEqual(addButton?.disabled, true); @@ -3354,35 +3356,37 @@ test("CreateSubscriptionScreen gives discount redemption and earning rules paral const container = document.createElement("div"); document.body.appendChild(container); render(<CreateSubscriptionScreen categories={[]} />, container); - assert.match(container.textContent ?? "", /Create Discount/); + assert.match(container.textContent ?? "", /Create Discount Stamps/); const kindButtons = Array.from(container.querySelectorAll("button")).filter( (button) => - button.textContent?.includes("Promotional or loyalty benefit") || - button.textContent?.includes("Time-based access pass"), + button.textContent?.includes("Customers collect discount stamps") || + button.textContent?.includes("Customers keep their pass"), ); assert.strictEqual(kindButtons.length, 2); - assert.match(kindButtons[0]!.textContent ?? "", /^🎟️Discount/); + assert.match(kindButtons[0]!.textContent ?? "", /^🎟️Discount stamps/); assert.match(kindButtons[1]!.textContent ?? "", /^📅Pass/); assert.match(kindButtons[0]!.className, /border-taler-brand/); assert.doesNotMatch(kindButtons[0]!.className, /purple/); - assert.match(container.textContent ?? "", /2\. Discount rules/); - assert.match(container.textContent ?? "", /Redeeming discounts/); - assert.match(container.textContent ?? "", /Earning discounts/); + assert.match(container.textContent ?? "", /2\. Discount stamps and rewards/); + assert.match(container.textContent ?? "", /Customer reward/); + assert.match(container.textContent ?? "", /Collecting discount stamps/); const legends = Array.from(container.querySelectorAll("legend")).map( (legend) => legend.textContent?.trim(), ); - assert.ok(legends.includes("Benefit calculation")); - assert.ok(legends.includes("Products where the benefit applies")); - assert.ok(legends.includes("Products where discounts are earned")); + assert.ok(legends.includes("Customer reward")); + assert.ok(legends.includes("Which purchases can use this reward?")); + assert.ok(legends.includes("Which purchases earn a discount stamp?")); const redeemingSection = Array.from( container.querySelectorAll("section"), - ).find((section) => section.textContent?.includes("Redeeming discounts")); + ).find((section) => section.textContent?.includes("Customer reward")); const earningSection = Array.from(container.querySelectorAll("section")).find( - (section) => section.textContent?.includes("Earning discounts"), + (section) => section.textContent?.includes("Collecting discount stamps"), + ); + assert.ok( + redeemingSection?.querySelector('[data-field="sub_required_tokens"]'), ); - assert.ok(redeemingSection?.querySelector("#sub_required_tokens")); assert.strictEqual( earningSection?.querySelector("#sub_required_tokens"), null, @@ -3402,9 +3406,12 @@ test("CreateSubscriptionScreen gives discount redemption and earning rules paral act(() => kindButtons[1]!.click()); assert.match(kindButtons[1]!.className, /border-taler-brand/); assert.doesNotMatch(kindButtons[1]!.className, /purple/); - assert.match(container.textContent ?? "", /2\. Redemption benefit/); - assert.doesNotMatch(container.textContent ?? "", /Redeeming discounts/); - assert.doesNotMatch(container.textContent ?? "", /Earning discounts/); + assert.match(container.textContent ?? "", /2\. Pass benefit/); + assert.doesNotMatch(container.textContent ?? "", /Customer reward/); + assert.doesNotMatch( + container.textContent ?? "", + /Collecting discount stamps/, + ); assert.strictEqual(container.querySelector("#sub_required_tokens"), null); render(null, container); @@ -3426,10 +3433,10 @@ test("CreateSubscriptionScreen keeps validity dates behind independent advanced act(() => advancedButton.click()); const fromEnabled = container.querySelector( - 'input[aria-label="Set an explicit Valid From date"]', + 'input[aria-label="Set a start date"]', ) as HTMLInputElement; const untilEnabled = container.querySelector( - 'input[aria-label="Set an explicit Valid Until date"]', + 'input[aria-label="Set an end date"]', ) as HTMLInputElement; assert.strictEqual(fromEnabled.checked, false); assert.strictEqual(untilEnabled.checked, false); @@ -3487,10 +3494,10 @@ test("CreateSubscriptionScreen loads, submits, and clears explicit validity date act(() => advancedButton.click()); const fromEnabled = container.querySelector( - 'input[aria-label="Set an explicit Valid From date"]', + 'input[aria-label="Set a start date"]', ) as HTMLInputElement; const untilEnabled = container.querySelector( - 'input[aria-label="Set an explicit Valid Until date"]', + 'input[aria-label="Set an end date"]', ) as HTMLInputElement; assert.strictEqual(fromEnabled.checked, true); assert.strictEqual(untilEnabled.checked, true); @@ -3511,7 +3518,7 @@ test("CreateSubscriptionScreen loads, submits, and clears explicit validity date }); assert.strictEqual(saved.validFrom, "2026-09-01"); assert.strictEqual(saved.validUntil, undefined); - assert.strictEqual(saved.canBeUsed, "Always"); + assert.strictEqual(saved.canBeUsed, "No end date"); render(null, container); document.body.removeChild(container); @@ -3560,10 +3567,13 @@ test("CreateSubscriptionScreen saves nested semantics and preserves unrelated ex await new Promise((resolve) => setTimeout(resolve, 0)); }); const roundingButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Rounding options"), + (button) => + button.getAttribute("aria-label")?.startsWith("Rounding increment:"), ); assert.ok(roundingButton); - assert.strictEqual(roundingButton.getAttribute("aria-expanded"), "true"); + assert.strictEqual(roundingButton.getAttribute("aria-expanded"), "false"); + assert.match(roundingButton.textContent!, /0\.05/); + act(() => roundingButton.click()); assert.strictEqual( ( container.querySelector( @@ -3572,13 +3582,7 @@ test("CreateSubscriptionScreen saves nested semantics and preserves unrelated ex ).value, "0.05", ); - const categoryCheckbox = Array.from(container.querySelectorAll("label")) - .find((label) => label.textContent?.includes("Drinks")) - ?.querySelector('input[type="checkbox"]') as HTMLInputElement | undefined; - assert.ok(categoryCheckbox); - if (!categoryCheckbox.checked) { - act(() => categoryCheckbox.click()); - } + assert.ok(container.querySelector('button[aria-label="Remove Drinks (1)"]')); const form = container.querySelector("form") as HTMLFormElement; assert.ok(form); @@ -3627,7 +3631,7 @@ test("CreateSubscriptionScreen refreshes independent redemption and issuance cat initialItem={{ id: "coffee-five", name: "Coffee five", - description: "Five stamps earn a benefit", + description: "Five discount stamps earn a benefit", kind: "discount", canBeUsed: "Always", extraData: { @@ -3734,7 +3738,7 @@ test("CreateSubscriptionScreen can save discounts and passes without a redemptio act(() => noneRadio.click()); assert.strictEqual( container.querySelector( - '[aria-label="Apply benefit to all merchant purchases"]', + '[aria-labelledby="redemption_rules_heading"] [aria-label="All purchases"]', ), null, ); @@ -3804,10 +3808,10 @@ test("CreateSubscriptionScreen keeps all-purchase redemption separate from earni await new Promise((resolve) => setTimeout(resolve, 0)); }); const redemption = container.querySelector( - 'input[aria-label="Apply benefit to all merchant purchases"]', + 'section[aria-labelledby="redemption_rules_heading"] [aria-label="All purchases"]', ) as HTMLInputElement; const earning = container.querySelector( - 'input[aria-label="Earn discounts on all merchant purchases"]', + 'section[aria-labelledby="earning_rules_heading"] [aria-label="All purchases"]', ) as HTMLInputElement; assert.ok(redemption); assert.ok(earning); @@ -4027,7 +4031,7 @@ test("CreateSubscriptionScreen retains unavailable snapshot names and blocks sav ); assert.match( container.textContent ?? "", - /Deleted coffee \(unavailable category #1\)/, + /Deleted coffee1Unavailable — remove before saving/, ); await act(async () => { container diff --git a/packages/taler-merchant-webui/src/screens/token-setup/wizard.test.tsx b/packages/taler-merchant-webui/src/screens/token-setup/wizard.test.tsx @@ -0,0 +1,1514 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + SPDX-License-Identifier: GPL-3.0-or-later + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { render } from "preact"; +import { useState } from "preact/hooks"; +import { act } from "preact/test-utils"; +import { + CreateSubscriptionScreen, + initialSubscriptionDraft, +} from "../CreateSubscriptionScreen.js"; +import { PersonalizationScreen } from "../PersonalizationScreen.js"; +import { + codecForPreferences, + preferences, + updatePreference, +} from "../../stores/preferences.js"; +import { prepareSubscription, type SubscriptionDraft } from "./model.js"; +import { subscriptionItem } from "./copy.js"; +import { TokenSelector } from "./TokenSelector.js"; +import { TokenSetupReview } from "./TokenSetupReview.js"; +import type { TranslateFn } from "../../context/translation.js"; + +const t: TranslateFn = (strings, ...values) => + typeof strings === "string" + ? strings + : strings.reduce((text, part, i) => text + part + (values[i] ?? ""), ""); +const context = { + t, + primaryCurrency: "CHF", + supportedCurrencies: ["CHF", "EUR"], + displayCategories: [ + { id: 1, name: "Coffee" }, + { id: 2, name: "Cake" }, + ], + displayProducts: [], + isEditing: false, +}; +const draft = (): SubscriptionDraft => ({ + ...initialSubscriptionDraft(undefined, "CHF", "0.01"), + name: "Coffee rewards", + slugId: "token_coffee_rewards", + description: "Collect coffee tokens", + percentage: "10", + requiredTokens: "5", + issuanceWildcard: true, + redemptionWildcard: true, +}); +function mount(node: preact.VNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + act(() => render(node, container)); + return { + container, + close: () => { + act(() => render(null, container)); + container.remove(); + }, + }; +} +function button(container: HTMLElement, text: string) { + const result = [ + ...container.querySelectorAll<HTMLButtonElement>("button"), + ].find((b) => b.textContent?.trim() === text); + assert.ok(result, `Missing button ${text}`); + return result; +} +function input(container: HTMLElement, selector: string, value: string) { + const target = container.querySelector<HTMLInputElement>(selector); + assert.ok(target, selector); + act(() => { + target.value = value; + target.dispatchEvent(new Event("input", { bubbles: true })); + target.dispatchEvent(new Event("change", { bubbles: true })); + }); +} +function check(container: HTMLElement, selector: string) { + const target = container.querySelector<HTMLInputElement>(selector); + assert.ok(target, selector); + act(() => target.click()); +} +async function submit(container: HTMLElement) { + await act(async () => { + container + .querySelector("form")! + .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); +} +function current(container: HTMLElement) { + return container.querySelector('[aria-current="step"]')?.textContent; +} + +function assertStepHeading(container: HTMLElement, title: string) { + const headings = [...container.querySelectorAll("h2, h3")].filter( + (heading) => heading.textContent?.trim() === title, + ); + assert.equal(headings.length, 1, `${title} has one heading`); + assert.equal(headings[0]!.id, "token-step-heading"); + assert.equal(container.querySelectorAll("h2").length, 1); + assert.equal(container.querySelectorAll("h4,h5,h6").length, 0); + if (title === "Details" || title === "Validity") { + const subsection = container.querySelector("#token-advanced-heading")!; + assert.equal(subsection.tagName, "H3"); + assert.equal( + subsection.closest(".rounded-xl.border"), + headings[0]!.closest(".rounded-xl.border"), + "optional settings belong inside the current step card", + ); + } + assert.ok( + headings[0]!.closest(".rounded-xl.border"), + `${title} is inside its card`, + ); +} + +async function fillWizard(container: HTMLElement) { + assertStepHeading(container, "Details"); + input(container, "#sub_name_input", "Coffee rewards"); + input(container, "#sub_desc_input", "Collect coffee tokens"); + await submit(container); + assert.match(current(container)!, /Collecting discount stamps/); + assertStepHeading(container, "Collecting discount stamps"); + check( + container, + '[aria-labelledby="earning_rules_heading"] [aria-label="All purchases"]', + ); + await submit(container); + assert.match(current(container)!, /Customer reward/); + assertStepHeading(container, "Customer reward"); + const percentageLabel = container.querySelector( + 'label[for="sub_discount_percentage"]', + )!; + assert.equal(percentageLabel.textContent, "Discount percentage"); + assert.equal(percentageLabel.classList.contains("sr-only"), false); + input(container, "#sub_discount_percentage", "10"); + check(container, 'input[name="stamp_count"][value="5"]'); + check( + container, + '[aria-labelledby="redemption_rules_heading"] [aria-label="All purchases"]', + ); + await submit(container); + assert.match(current(container)!, /Validity/); + assertStepHeading(container, "Validity"); + await submit(container); + assert.match(current(container)!, /Review/); + assertStepHeading(container, "Review"); +} + +test("wizard keeps one content width without a sidebar or earning recap", async () => { + const ui = mount( + <CreateSubscriptionScreen setupMode="wizard" primaryCurrency="CHF" />, + ); + try { + const widthClass = ui.container.querySelector("form")!.className; + await fillWizard(ui.container); + assert.equal(ui.container.querySelector("form")!.className, widthClass); + check(ui.container, '[aria-label="Edit Customer reward"]'); + assert.equal(ui.container.querySelector("aside"), null); + assert.equal( + ui.container.querySelector('[aria-label="Earning conditions"]'), + null, + ); + const fields = ui.container.querySelectorAll( + "section[aria-labelledby=redemption_rules_heading] input", + ); + assert.deepEqual( + Array.from(fields) + .slice(0, 4) + .map((field) => field.getAttribute("name")), + Array(4).fill("discount_type"), + "reward choices come before the required stamp count", + ); + assert.equal(fields[4]?.getAttribute("name"), "stamp_count"); + assert.doesNotMatch( + ui.container.textContent!, + /Choose the reward and the purchases it applies to/, + ); + assert.match(ui.container.textContent!, /Number of discount stamps needed/); + check(ui.container, 'input[name="discount_type"][value="none"]'); + assert.equal(ui.container.querySelector("#sub_required_tokens"), null); + assert.match(ui.container.textContent!, /No reward yet/); + assert.match( + ui.container.textContent!, + /Customers can collect discount stamps, but cannot yet exchange them for a reward at checkout/, + ); + } finally { + ui.close(); + } +}); + +test("stamp count chips preserve custom values and clear corrected validation", async () => { + const ui = mount( + <CreateSubscriptionScreen setupMode="wizard" primaryCurrency="CHF" />, + ); + try { + await fillWizard(ui.container); + check(ui.container, '[aria-label="Edit Customer reward"]'); + const select = (value: string) => + check(ui.container, `input[name="stamp_count"][value="${value}"]`); + for (const value of ["5", "10", "15", "20"]) { + select(value); + assert.equal( + [ + ...ui.container.querySelectorAll<HTMLInputElement>( + 'input[name="stamp_count"]', + ), + ].find((radio) => radio.checked)?.value, + value, + ); + assert.equal(ui.container.querySelector("#sub_required_tokens"), null); + } + select("custom"); + input(ui.container, "#sub_required_tokens", "7"); + select("10"); + select("custom"); + assert.equal( + ui.container.querySelector<HTMLInputElement>("#sub_required_tokens") + ?.value, + "7", + ); + input(ui.container, "#sub_required_tokens", "5"); + assert.equal( + [ + ...ui.container.querySelectorAll<HTMLInputElement>( + 'input[name="stamp_count"]', + ), + ].find((radio) => radio.checked)?.value, + "custom", + ); + input(ui.container, "#sub_required_tokens", "0"); + await submit(ui.container); + assert.ok(ui.container.querySelector("#sub_required_tokens-error")); + assert.equal(document.activeElement?.id, "sub_required_tokens"); + select("15"); + assert.equal( + ui.container.querySelector("#sub_required_tokens-error"), + null, + ); + assert.equal(ui.container.querySelector('[role="alert"]'), null); + select("custom"); + input(ui.container, "#sub_required_tokens", "7"); + await submit(ui.container); + assert.match(current(ui.container)!, /Review/); + assert.match(ui.container.textContent!, /7 discount stamps/); + check(ui.container, '[aria-label="Edit Customer reward"]'); + assert.equal( + ui.container.querySelector<HTMLInputElement>("#sub_required_tokens") + ?.value, + "7", + ); + } finally { + ui.close(); + } +}); + +test("saved stamp counts select a preset or reveal the custom input", () => { + for (const count of [5, 10, 15, 20, 7]) { + const ui = mount( + <CreateSubscriptionScreen + primaryCurrency="CHF" + initialItem={{ + id: "coffee", + name: "Coffee", + description: "Coffee reward", + kind: "discount", + canBeUsed: "Always", + extraData: { + experimental_discount: { + type: "free_item", + price_selection: "cheapest", + required_tokens: count, + product_selectors: "*", + issuance: { product_selectors: "*", issue_on_redemption: false }, + }, + }, + }} + />, + ); + try { + assert.equal( + [ + ...ui.container.querySelectorAll<HTMLInputElement>( + 'input[name="stamp_count"]', + ), + ].find((radio) => radio.checked)?.value, + count === 7 ? "custom" : String(count), + ); + assert.equal( + ui.container.querySelector<HTMLInputElement>("#sub_required_tokens") + ?.value, + count === 7 ? "7" : undefined, + ); + } finally { + ui.close(); + } + } +}); + +test("old preferences preserve existing settings and default the wizard off", () => { + assert.deepEqual( + codecForPreferences().decode({ dateFormat: "dmy", advancedTools: true }), + { dateFormat: "dmy", advancedTools: true, tokenSetupWizard: false }, + ); +}); + +test("fixing the reported field clears both messages without advancing or reporting another error", async () => { + const ui = mount( + <CreateSubscriptionScreen setupMode="wizard" primaryCurrency="CHF" />, + ); + try { + await submit(ui.container); + assert.ok(ui.container.querySelector("#sub_name_input-error")); + assert.ok(ui.container.querySelector('[role="alert"]')); + input(ui.container, "#sub_desc_input", "Unrelated change"); + input(ui.container, "#sub_name_input", " "); + assert.ok(ui.container.querySelector("#sub_name_input-error")); + assert.ok(ui.container.querySelector('[role="alert"]')); + input(ui.container, "#sub_desc_input", ""); + input(ui.container, "#sub_name_input", "Coffee stamps"); + assert.equal(ui.container.querySelector("#sub_name_input-error"), null); + assert.equal(ui.container.querySelector('[role="alert"]'), null); + assert.equal(ui.container.querySelector('[aria-invalid="true"]'), null); + assert.equal(document.activeElement?.id, "sub_name_input"); + assert.match(current(ui.container)!, /Details/); + await submit(ui.container); + assert.ok(ui.container.querySelector("#sub_desc_input-error")); + } finally { + ui.close(); + } +}); + +for (const setupMode of ["wizard", "classic"] as const) { + for (const selection of ["category", "product"] as const) { + test(`${setupMode}: adding a ${selection} clears earning and reward errors immediately`, async () => { + const ui = mount( + <CreateSubscriptionScreen + setupMode={setupMode} + primaryCurrency="CHF" + categories={[{ id: 1, name: "Coffee", productCount: 1 }]} + products={[ + { + id: "coffee", + name: "Coffee", + price: "CHF:5", + stock: "10", + stockTracked: true, + category: "Coffee", + }, + ]} + />, + ); + try { + input(ui.container, "#sub_name_input", "Coffee stamps"); + input( + ui.container, + "#sub_desc_input", + "Collect stamps for a discount.", + ); + if (setupMode === "wizard") await submit(ui.container); + else { + input(ui.container, "#sub_discount_percentage", "10"); + check(ui.container, 'input[name="stamp_count"][value="5"]'); + check( + ui.container, + 'input[name="token-redemption-scope"][value="any"]', + ); + } + check( + ui.container, + 'input[name="token-issuance-scope"][value="selected"]', + ); + await submit(ui.container); + const earningError = ui.container.querySelector( + "#issuance-products-error", + )!; + assert.ok(earningError); + assert.equal( + earningError.parentElement!.dataset.field, + "issuance-products", + ); + assert.equal( + earningError.parentElement!.firstElementChild, + earningError, + ); + assert.equal( + earningError.parentElement!.querySelector( + 'input[name="token-issuance-scope"]', + ), + null, + ); + assert.match( + earningError.textContent!, + /Select at least one product category or inventory product/, + ); + const choose = (heading: string) => { + const section = ui.container.querySelector<HTMLElement>( + `[aria-labelledby="${heading}"]`, + )!; + act(() => + button( + section, + selection === "category" + ? "+ Add product category" + : "+ Add inventory product", + ).click(), + ); + check(section, '[role="option"]'); + assert.ok( + section.querySelector('[role="dialog"]'), + "the selector stays open", + ); + }; + choose("earning_rules_heading"); + assert.equal( + ui.container.querySelector("#issuance-products-error"), + null, + ); + assert.equal(ui.container.querySelector('[role="alert"]'), null); + if (setupMode === "wizard") await submit(ui.container); + input(ui.container, "#sub_discount_percentage", "10"); + check(ui.container, 'input[name="stamp_count"][value="5"]'); + check( + ui.container, + 'input[name="token-redemption-scope"][value="selected"]', + ); + await submit(ui.container); + const productError = ui.container.querySelector( + "#redemption-products-error", + ); + assert.ok(productError); + const selectors = productError.parentElement!; + assert.equal(selectors.dataset.field, "redemption-products"); + assert.equal(selectors.firstElementChild, productError); + assert.equal( + selectors.querySelector('input[name="token-redemption-scope"]'), + null, + ); + assert.ok(selectors.querySelector('button[aria-haspopup="dialog"]')); + assert.match( + productError.textContent!, + /Select at least one product category or inventory product/, + ); + assert.ok(ui.container.querySelector('[role="alert"]')); + choose("redemption_rules_heading"); + assert.equal( + ui.container.querySelector("#redemption-products-error"), + null, + ); + assert.equal(ui.container.querySelector('[role="alert"]'), null); + const reward = ui.container.querySelector<HTMLElement>( + '[aria-labelledby="redemption_rules_heading"]', + )!; + check(reward, '[role="option"]'); + assert.equal( + ui.container.querySelector('[role="alert"]'), + null, + "a new problem waits for submission", + ); + await submit(ui.container); + assert.ok(ui.container.querySelector("#redemption-products-error")); + check(ui.container, 'input[name="discount_type"][value="none"]'); + assert.equal( + ui.container.querySelector("#redemption-products-error"), + null, + ); + assert.equal( + ui.container.querySelector('[role="alert"]'), + null, + "changing the reward can remove the requirement", + ); + } finally { + ui.close(); + } + }); + } +} + +test("editing fields does not clear a failed save", async () => { + const ui = mount( + <CreateSubscriptionScreen + setupMode="classic" + primaryCurrency="CHF" + onCreateFamily={async () => { + throw new Error("Save was rejected"); + }} + />, + ); + try { + input(ui.container, "#sub_name_input", "Coffee stamps"); + input(ui.container, "#sub_desc_input", "Collect stamps."); + input(ui.container, "#sub_discount_percentage", "10"); + check(ui.container, 'input[name="stamp_count"][value="5"]'); + check(ui.container, 'input[name="token-issuance-scope"][value="any"]'); + check(ui.container, 'input[name="token-redemption-scope"][value="any"]'); + await submit(ui.container); + assert.match( + ui.container.querySelector('[role="alert"]')!.textContent!, + /Save was rejected/, + ); + input(ui.container, "#sub_discount_percentage", "20"); + assert.match( + ui.container.querySelector('[role="alert"]')!.textContent!, + /Save was rejected/, + ); + } finally { + ui.close(); + } +}); + +test("wizard preference is conditional and saves explicitly", async () => { + const previous = preferences.value; + preferences.value = { + ...previous, + advancedTools: false, + tokenSetupWizard: false, + }; + const ui = mount(<PersonalizationScreen />); + try { + assert.equal(ui.container.querySelector("#pref-token-setup-wizard"), null); + check(ui.container, "#pref-advanced-tools"); + check(ui.container, "#pref-token-setup-wizard"); + assert.equal(preferences.value.tokenSetupWizard, false); + await submit(ui.container); + assert.equal(preferences.value.tokenSetupWizard, true); + check(ui.container, "#pref-advanced-tools"); + await submit(ui.container); + assert.equal(preferences.value.advancedTools, false); + assert.equal(preferences.value.tokenSetupWizard, true); + } finally { + ui.close(); + preferences.value = previous; + } +}); + +test("wizard validates steps, retains answers, shows review, and matches classic payload", async () => { + let wizardPayload: unknown, classicPayload: unknown; + const ui = mount( + <CreateSubscriptionScreen + setupMode="wizard" + primaryCurrency="CHF" + onCreateFamily={async (p) => { + wizardPayload = p; + }} + />, + ); + try { + await submit(ui.container); + assert.match(current(ui.container)!, /Details/); + assert.equal(document.activeElement?.id, "sub_name_input"); + input(ui.container, "#sub_name_input", "Coffee rewards"); + await submit(ui.container); + assert.equal(document.activeElement?.id, "sub_desc_input"); + await fillWizard(ui.container); + assert.equal(wizardPayload, undefined); + assert.match(ui.container.textContent!, /10% off/); + assert.equal( + ui.container.querySelector('[aria-label="Example purchase"]'), + null, + ); + act(() => button(ui.container, "Back").click()); + assert.match(current(ui.container)!, /Validity/); + await submit(ui.container); + await submit(ui.container); + assert.ok(wizardPayload); + } finally { + ui.close(); + } + const classic = mount( + <CreateSubscriptionScreen + setupMode="classic" + primaryCurrency="CHF" + onCreateFamily={async (p) => { + classicPayload = p; + }} + />, + ); + try { + input(classic.container, "#sub_name_input", "Coffee rewards"); + input(classic.container, "#sub_desc_input", "Collect coffee tokens"); + input(classic.container, "#sub_discount_percentage", "10"); + check(classic.container, 'input[name="stamp_count"][value="5"]'); + check( + classic.container, + '[aria-labelledby="earning_rules_heading"] [aria-label="All purchases"]', + ); + check( + classic.container, + '[aria-labelledby="redemption_rules_heading"] [aria-label="All purchases"]', + ); + await submit(classic.container); + assert.deepEqual(wizardPayload, classicPayload); + } finally { + classic.close(); + } +}); + +for (const setupMode of ["wizard", "classic"] as const) { + test(`${setupMode} free-item rewards require matching products after choosing all purchases`, async () => { + let saved: unknown; + const ui = mount( + <CreateSubscriptionScreen + setupMode={setupMode} + primaryCurrency="CHF" + categories={[{ id: 1, name: "Coffee", productCount: 1 }]} + onCreateFamily={async (payload) => { + saved = payload; + }} + />, + ); + try { + input(ui.container, "#sub_name_input", "Coffee rewards"); + input(ui.container, "#sub_desc_input", "Collect coffee tokens"); + if (setupMode === "wizard") await submit(ui.container); + check(ui.container, 'input[name="token-issuance-scope"][value="any"]'); + if (setupMode === "wizard") await submit(ui.container); + input(ui.container, "#sub_discount_percentage", "10"); + check(ui.container, 'input[name="stamp_count"][value="5"]'); + check(ui.container, 'input[name="token-redemption-scope"][value="any"]'); + check(ui.container, 'input[name="discount_type"][value="free_item"]'); + assert.equal( + ui.container.querySelector('input[name="token-redemption-scope"]'), + null, + ); + assert.ok(button(ui.container, "+ Add product category")); + assert.ok(button(ui.container, "+ Add inventory product")); + await submit(ui.container); + assert.equal(saved, undefined); + assert.match( + ui.container.textContent!, + /Select at least one product category or inventory product/, + ); + if (setupMode === "wizard") + assert.match(current(ui.container)!, /Customer reward/); + act(() => button(ui.container, "+ Add product category").click()); + act(() => + ui.container.querySelector<HTMLElement>('[role="option"]')!.click(), + ); + check(ui.container, 'input[name="discount_type"][value="percentage"]'); + assert.equal( + ui.container.querySelector<HTMLInputElement>( + 'input[name="token-redemption-scope"][value="any"]', + )?.checked, + true, + ); + check(ui.container, 'input[name="discount_type"][value="free_item"]'); + assert.ok(ui.container.querySelector('[aria-label="Remove Coffee (1)"]')); + await submit(ui.container); + if (setupMode === "wizard") { + assert.match(current(ui.container)!, /Validity/); + await submit(ui.container); + assert.match(current(ui.container)!, /Review/); + await submit(ui.container); + } + assert.ok(saved); + assert.deepEqual( + (saved as { extraData: Record<string, unknown> }).extraData + .experimental_discount, + { + type: "free_item", + price_selection: "cheapest", + required_tokens: 5, + product_selectors: [{ type: "category", id: 1, name: "Coffee" }], + issuance: { product_selectors: "*", issue_on_redemption: false }, + }, + ); + } finally { + ui.close(); + } + }); +} + +test("editing an existing free-item wildcard requires selecting eligible items", async () => { + let saved = false; + const ui = mount( + <CreateSubscriptionScreen + primaryCurrency="CHF" + categories={[{ id: 1, name: "Coffee", productCount: 1 }]} + initialItem={{ + id: "free-coffee", + name: "Free coffee", + description: "Coffee reward", + kind: "discount", + canBeUsed: "Always", + extraData: { + experimental_discount: { + type: "free_item", + price_selection: "cheapest", + required_tokens: 5, + product_selectors: "*", + issuance: { product_selectors: "*", issue_on_redemption: false }, + }, + }, + }} + onSave={() => { + saved = true; + }} + />, + ); + try { + assert.equal( + ui.container.querySelector('input[name="token-redemption-scope"]'), + null, + ); + assert.ok(button(ui.container, "+ Add product category")); + await submit(ui.container); + assert.equal(saved, false); + assert.match( + ui.container.textContent!, + /Select at least one product category or inventory product/, + ); + } finally { + ui.close(); + } +}); + +test("mode is captured for the draft and editing always uses the classic form", () => { + const previous = preferences.value; + preferences.value = { + ...previous, + advancedTools: true, + tokenSetupWizard: true, + }; + const ui = mount(<CreateSubscriptionScreen primaryCurrency="CHF" />); + try { + assert.match(current(ui.container)!, /Details/); + act(() => updatePreference("advancedTools", false)); + assert.match(current(ui.container)!, /Details/); + } finally { + ui.close(); + preferences.value = previous; + } + const edit = mount( + <CreateSubscriptionScreen + setupMode="wizard" + primaryCurrency="CHF" + initialItem={{ + id: "old", + name: "Old", + kind: "discount", + canBeUsed: "Always", + }} + />, + ); + try { + assert.equal(current(edit.container), undefined); + assert.ok(edit.container.querySelector("#sub_discount_percentage")); + } finally { + edit.close(); + } +}); + +test("kind switching restores benefit values and passes skip earning", async () => { + const ui = mount( + <CreateSubscriptionScreen setupMode="wizard" primaryCurrency="CHF" />, + ); + try { + await fillWizard(ui.container); + act(() => button(ui.container, "1. Details").click()); + const choose = (kind: string) => + check( + ui.container, + `input[name="token-kind"][value="${kind === "Pass" ? "subscription" : "discount"}"]`, + ); + choose("Pass"); + await submit(ui.container); + assert.match(current(ui.container)!, /Pass benefit/); + assertStepHeading(ui.container, "Pass benefit"); + assert.equal(ui.container.querySelector("#sub_required_tokens"), null); + input(ui.container, "#sub_discount_percentage", "20"); + act(() => button(ui.container, "1. Details").click()); + choose("Discount stamps"); + await submit(ui.container); + await submit(ui.container); + assert.equal( + ui.container.querySelector<HTMLInputElement>("#sub_discount_percentage") + ?.value, + "10", + ); + } finally { + ui.close(); + } +}); + +test("review edits reveal technical settings, validate changes, and return without saving", async () => { + let saved = 0; + const ui = mount( + <CreateSubscriptionScreen + setupMode="wizard" + primaryCurrency="CHF" + onCreateFamily={async () => { + saved++; + }} + />, + ); + try { + await fillWizard(ui.container); + assert.equal( + ui.container.querySelector<HTMLDetailsElement>( + 'details[aria-label="Technical details"]', + )?.open, + false, + ); + check(ui.container, '[aria-label="Edit Identifier"]'); + assert.match(current(ui.container)!, /Details/); + assert.equal(document.activeElement?.id, "sub_slug_input"); + input(ui.container, "#sub_slug_input", "coffee_club"); + assert.ok(button(ui.container, "Return to review")); + await submit(ui.container); + assert.match(current(ui.container)!, /Review/); + assert.equal( + ui.container.querySelector<HTMLDetailsElement>( + 'details[aria-label="Technical details"]', + )?.open, + true, + ); + assert.equal( + document.activeElement?.getAttribute("aria-label"), + "Edit Identifier", + ); + assert.match( + ui.container.querySelector<HTMLDetailsElement>( + 'details[aria-label="Technical details"]', + )!.textContent!, + /coffee_club/, + ); + + check(ui.container, '[aria-label="Edit Rounding"]'); + assert.equal(document.activeElement?.id, "sub_discount_rounding_precision"); + input(ui.container, "#sub_discount_rounding_precision", "0"); + await submit(ui.container); + assert.match(current(ui.container)!, /Customer reward/); + assert.equal(document.activeElement?.id, "sub_discount_rounding_precision"); + assert.ok(button(ui.container, "Return to review")); + input(ui.container, "#sub_discount_rounding_precision", "0.05"); + assert.equal( + ui.container.querySelector("#sub_discount_rounding_precision-error"), + null, + ); + assert.equal(ui.container.querySelector('[role="alert"]'), null); + await submit(ui.container); + assert.match(current(ui.container)!, /Review/); + assert.equal( + document.activeElement?.getAttribute("aria-label"), + "Edit Rounding", + ); + assert.match( + ui.container.querySelector<HTMLDetailsElement>( + 'details[aria-label="Technical details"]', + )!.textContent!, + /0.05/, + ); + + check(ui.container, '[aria-label="Edit Validity"]'); + assert.ok(ui.container.querySelector("#sub_granularity")); + input(ui.container, "#sub_duration", "90d"); + await submit(ui.container); + assert.match(current(ui.container)!, /Review/); + assert.match( + ui.container.querySelector("#token-review-timing")!.textContent!, + /Up to 90 days/, + ); + assert.equal( + document.activeElement?.getAttribute("aria-label"), + "Edit Validity", + ); + assert.equal(saved, 0); + await submit(ui.container); + assert.equal(saved, 1); + } finally { + ui.close(); + } +}); + +test("returning to review validates new reward conditions and handles removed technical settings", async () => { + const ui = mount( + <CreateSubscriptionScreen setupMode="wizard" primaryCurrency="CHF" />, + ); + try { + await fillWizard(ui.container); + check(ui.container, '[aria-label="Edit Rounding"]'); + check(ui.container, 'input[name="discount_type"][value="free_item"]'); + await submit(ui.container); + assert.match(current(ui.container)!, /Customer reward/); + assert.ok(button(ui.container, "Return to review")); + check(ui.container, 'input[name="discount_type"][value="none"]'); + await submit(ui.container); + assert.match(current(ui.container)!, /Review/); + assert.equal(document.activeElement?.id, "token-step-heading"); + assert.equal( + ui.container.querySelector('[aria-label="Edit Rounding"]'), + null, + ); + assert.doesNotMatch( + ui.container.textContent!, + /Using the reward|when using the reward/, + ); + } finally { + ui.close(); + } +}); + +test("review groups earning rules and distinguishes category and product chips", () => { + const d: SubscriptionDraft = { + ...draft(), + benefitType: "free_item", + minimumPurchase: "CHF:40", + issuanceWildcard: false, + issuanceCategories: [{ id: 1, name: "Coffee" }], + redemptionWildcard: false, + selectedCategories: [{ id: 1, name: "Coffee" }], + selectedProducts: [{ id: "coffee", name: "Coffee" }], + }; + const prepared = prepareSubscription(d, { + ...context, + displayProducts: d.selectedProducts, + }); + assert.deepEqual(prepared.errors, []); + const ui = mount( + <TokenSetupReview draft={d} primaryCurrency="CHF" onEdit={() => {}} />, + ); + try { + const earning = ui.container.querySelector("#token-review-earning")!; + assert.match(earning.textContent!, /Coffee/); + assert.match(earning.textContent!, /CHF 40/); + assert.doesNotMatch(earning.textContent!, /optional/); + assert.equal(earning.querySelectorAll("button").length, 1); + const reward = ui.container.querySelector("#token-review-benefit")!; + assert.equal( + reward.querySelector('[aria-label="Product categories"]')?.textContent, + "Coffee", + ); + assert.equal( + reward.querySelector('[aria-label="Inventory products"]')?.textContent, + "Coffee", + ); + assert.equal( + ui.container.querySelector('[aria-label="Example purchase"]'), + null, + ); + assert.match( + ui.container.querySelector("#token-review-timing")!.textContent!, + /still expire when no end date is set/, + ); + } finally { + ui.close(); + } +}); + +test("pass reviews omit stamp language and checkout examples", () => { + for (const benefitType of ["percentage", "none"] as const) { + const d: SubscriptionDraft = { + ...draft(), + kind: "subscription", + benefitType, + }; + const prepared = prepareSubscription(d, context); + assert.deepEqual(prepared.errors, []); + const ui = mount( + <TokenSetupReview draft={d} primaryCurrency="CHF" onEdit={() => {}} />, + ); + try { + assert.doesNotMatch(ui.container.textContent!, /stamp/i); + const example = ui.container.querySelector( + '[aria-label="Example purchase"]', + ); + assert.equal(example, null); + } finally { + ui.close(); + } + } +}); + +test("validation collects step-addressable errors without producing a payload", () => { + const result = prepareSubscription( + { + ...draft(), + description: "", + requiredTokens: "0", + percentage: "0", + roundingPrecision: "0", + minimumPurchase: "CHF:0", + useValidFrom: true, + useValidUntil: true, + validFrom: "bad", + validUntil: "bad", + }, + context, + ); + assert.equal(result.payload, undefined); + for (const field of [ + "sub_discount_percentage", + "sub_discount_rounding_precision", + "sub_required_tokens", + ]) + assert.ok( + result.errors.some((error) => error.field === field), + `${field} is validated independently`, + ); + for (const step of ["basics", "earning", "benefit", "timing"]) + assert.ok(result.errors.some((e) => e.step === step)); + assert.equal( + prepareSubscription( + { + ...draft(), + redemptionWildcard: false, + selectedProducts: [{ id: "missing", name: "Old" }], + }, + context, + ).errors[0]?.field, + "redemption-products", + ); +}); + +test("inline category creation selects the new category for earning and makes it available for rewards", async () => { + let saved: unknown; + const names: string[] = []; + function Harness() { + const [categories, setCategories] = useState([ + { id: 1, name: "Coffee", productCount: 0 }, + ]); + return ( + <CreateSubscriptionScreen + setupMode="wizard" + primaryCurrency="CHF" + categories={categories} + onCreateCategory={async (name) => { + names.push(name); + const category = { id: 9, name, productCount: 0 }; + setCategories((items) => [...items, category]); + return category; + }} + onCreateFamily={async (payload) => { + saved = payload; + }} + /> + ); + } + const ui = mount(<Harness />); + try { + input(ui.container, "#sub_name_input", "Dessert stamps"); + input(ui.container, "#sub_desc_input", "Collect stamps on desserts"); + await submit(ui.container); + await submit(ui.container); + assert.ok(ui.container.querySelector("#issuance-products-error")); + act(() => button(ui.container, "+ Add product category").click()); + input(ui.container, '[role="combobox"]', " Desserts "); + await act(async () => + button(ui.container, "Create category “Desserts”").click(), + ); + assert.deepEqual(names, ["Desserts"]); + assert.ok(ui.container.querySelector('[aria-label="Remove Desserts (9)"]')); + assert.ok(ui.container.querySelector('[role="dialog"]')); + assert.equal(ui.container.querySelector("#issuance-products-error"), null); + assert.equal(ui.container.querySelector('[role="alert"]'), null); + assert.doesNotMatch(ui.container.textContent!, /Unavailable — remove/); + act(() => button(ui.container, "Done").click()); + await submit(ui.container); + check(ui.container, 'input[name="discount_type"][value="free_item"]'); + check(ui.container, 'input[name="stamp_count"][value="5"]'); + act(() => button(ui.container, "+ Add product category").click()); + input(ui.container, '[role="combobox"]', "Desserts"); + check(ui.container, '[role="option"]'); + act(() => button(ui.container, "Done").click()); + await submit(ui.container); + await submit(ui.container); + await submit(ui.container); + const semantics = ( + saved as { + extraData: { + experimental_discount: { + product_selectors: unknown; + issuance: { product_selectors: unknown }; + }; + }; + } + ).extraData.experimental_discount; + const expected = [{ type: "category", id: 9, name: "Desserts" }]; + assert.deepEqual(semantics.issuance.product_selectors, expected); + assert.deepEqual(semantics.product_selectors, expected); + } finally { + ui.close(); + } +}); + +test("inline category creation retains search on failure and blocks duplicate pending requests", async () => { + let calls = 0; + let reject!: (cause: Error) => void; + let selected: { id: number; name: string }[] = []; + const ui = mount( + <TokenSelector<{ id: number; name: string }> + selected={[]} + onChange={(value) => { + selected = value; + }} + options={[]} + loading={false} + unavailable={false} + emptyMessage="Empty" + label="Search categories" + addLabel="+ Add product category" + onCreateCategory={(name) => { + calls++; + if (calls === 1) + return new Promise((_resolve, fail) => { + reject = fail; + }); + return Promise.resolve({ id: 8, name }); + }} + />, + ); + try { + act(() => button(ui.container, "+ Add product category").click()); + input(ui.container, '[role="combobox"]', " "); + assert.equal(ui.container.querySelector("[data-create-category]"), null); + input(ui.container, '[role="combobox"]', "Desserts"); + act(() => { + button(ui.container, "Create category “Desserts”").click(); + }); + assert.equal(button(ui.container, "Creating category…").disabled, true); + act(() => button(ui.container, "Creating category…").click()); + assert.equal(calls, 1); + await act(async () => reject(new Error("Connection lost"))); + assert.match( + ui.container.querySelector('[role="alert"]')!.textContent!, + /Connection lost/, + ); + assert.equal( + ui.container.querySelector<HTMLInputElement>('[role="combobox"]')!.value, + "Desserts", + ); + assert.deepEqual(selected, []); + input(ui.container, '[role="combobox"]', "Cakes"); + assert.equal(ui.container.querySelector('[role="alert"]'), null); + await act(async () => + button(ui.container, "Create category “Cakes”").click(), + ); + assert.deepEqual(selected, [{ id: 8, name: "Cakes" }]); + assert.ok(ui.container.querySelector('[role="dialog"]')); + } finally { + ui.close(); + } +}); + +test("selector search keeps selected items visible and supports unavailable removal", () => { + let value: { id: string; name: string }[] = []; + const ui = mount( + <TokenSelector + selected={[{ id: "gone", name: "Old" }]} + onChange={(v) => { + value = v; + }} + options={[ + { id: "abc", name: "Coffee" }, + { id: "def", name: "Cake" }, + ]} + loading={false} + unavailable={false} + emptyMessage="Empty" + label="Search" + addLabel="+ Add item" + />, + ); + try { + act(() => + ui.container + .querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]')! + .click(), + ); + input(ui.container, 'input[type="search"]', "ABC"); + assert.match(ui.container.textContent!, /Coffee/); + assert.doesNotMatch(ui.container.textContent!, /Cake/); + assert.match(ui.container.textContent!, /Old/); + act(() => + ui.container + .querySelector<HTMLButtonElement>("[data-chip-remove]")! + .click(), + ); + assert.deepEqual(value, []); + } finally { + ui.close(); + } +}); + +test("final submission revalidates live eligibility and retains draft on API failure", async () => { + let available = [{ id: 1, name: "Coffee", productCount: 1 }]; + let calls = 0; + const save = async () => { + calls++; + throw new Error("Try again later"); + }; + const screen = () => ( + <CreateSubscriptionScreen + setupMode="wizard" + primaryCurrency="CHF" + categories={available} + onCreateFamily={save} + /> + ); + const ui = mount(screen()); + try { + await fillWizard(ui.container); + act(() => button(ui.container, "3. Customer reward").click()); + check( + ui.container, + '[aria-labelledby="redemption_rules_heading"] [aria-label="Purchases with matching products"]', + ); + act(() => button(ui.container, "+ Add product category").click()); + input(ui.container, 'input[aria-label="Search categories"]', "Coffee"); + act(() => + ui.container.querySelector<HTMLElement>('[role="option"]')!.click(), + ); + await submit(ui.container); + await submit(ui.container); + available = []; + act(() => render(screen(), ui.container)); + await submit(ui.container); + assert.equal(calls, 0); + assert.match(current(ui.container)!, /Customer reward/); + assert.match(ui.container.textContent!, /unavailable categories/); + check( + ui.container, + '[aria-labelledby="redemption_rules_heading"] [aria-label="All purchases"]', + ); + await submit(ui.container); + await submit(ui.container); + await submit(ui.container); + assert.equal(calls, 1); + assert.match(current(ui.container)!, /Review/); + assert.match(ui.container.textContent!, /Try again later/); + assert.match(ui.container.textContent!, /Coffee rewards/); + } finally { + ui.close(); + } +}); + +test("pending creation prevents duplicate calls", async () => { + let calls = 0; + let finish: (() => void) | undefined; + const ui = mount( + <CreateSubscriptionScreen + setupMode="wizard" + primaryCurrency="CHF" + onCreateFamily={() => { + calls++; + return new Promise<void>((resolve) => { + finish = resolve; + }); + }} + />, + ); + try { + await fillWizard(ui.container); + await submit(ui.container); + await submit(ui.container); + assert.equal(calls, 1); + await act(async () => { + finish!(); + }); + } finally { + ui.close(); + } +}); + +test("reward copy explains selected-product minimums and expiry without changing payloads", () => { + const ui = mount( + <CreateSubscriptionScreen setupMode="classic" primaryCurrency="CHF" />, + ); + try { + assert.match( + ui.container.textContent!, + /Minimum spend on the selected products/, + ); + assert.match( + ui.container.textContent!, + /Other products in the same purchase do not count/, + ); + check( + ui.container, + '[aria-labelledby="earning_rules_heading"] [aria-label="All purchases"]', + ); + assert.match(ui.container.textContent!, /Minimum purchase total/); + assert.doesNotMatch( + ui.container.textContent!, + /Other products in the same purchase do not count/, + ); + act(() => button(ui.container, "Show advanced options").click()); + check(ui.container, "#sub_valid_until_enabled"); + assert.match( + ui.container.textContent!, + /00:00 UTC at the start of this date/, + ); + assert.match( + ui.container.textContent!, + /does not rewrite expiry dates already assigned to discount stamps/, + ); + assert.doesNotMatch( + ui.container.textContent!, + /Program|issuance|redemption threshold|once activated/, + ); + } finally { + ui.close(); + } +}); + +test("shared summaries distinguish rewards, access passes and legacy families", () => { + const reward = prepareSubscription( + { ...draft(), benefitType: "flat", flatAmounts: ["CHF:3", "EUR:2"] }, + context, + ); + assert.ok(reward.payload); + const item = subscriptionItem(t, reward.payload); + assert.match(item.ruleSummary!, /CHF 3.00, EUR 2.00/); + assert.match(item.ruleSummary!, /Use 5 discount stamps/); + assert.doesNotMatch(item.ruleSummary!, /CHF:|tokens/); + const access = prepareSubscription( + { ...draft(), kind: "subscription", benefitType: "none" }, + context, + ); + assert.ok(access.payload); + assert.equal( + subscriptionItem(t, access.payload).ruleSummary, + "Access only (no checkout discount)", + ); + assert.equal( + subscriptionItem(t, { slug: "legacy", name: "Legacy", kind: "discount" }) + .ruleSummary, + undefined, + ); +}); + +test("wizard navigation and kind changes do not scroll; validation still reveals its field", async () => { + const scroll = HTMLElement.prototype.scrollIntoView; + const focus = HTMLElement.prototype.focus; + const scrolled: HTMLElement[] = []; + const focused: { target: HTMLElement; options?: FocusOptions }[] = []; + HTMLElement.prototype.scrollIntoView = function () { + scrolled.push(this); + }; + HTMLElement.prototype.focus = function (options) { + focused.push({ target: this, options }); + focus.call(this, options); + }; + const ui = mount( + <CreateSubscriptionScreen setupMode="wizard" primaryCurrency="CHF" />, + ); + try { + assert.equal(scrolled.length, 0); + input(ui.container, "#sub_name_input", "Coffee"); + input(ui.container, "#sub_desc_input", "Discount stamps for coffee"); + await submit(ui.container); + assert.equal(scrolled.length, 0); + assert.equal(focused.at(-1)?.target.id, "token-step-heading"); + assert.equal(focused.at(-1)?.options?.preventScroll, true); + act(() => button(ui.container, "1. Details").click()); + const pass = ui.container.querySelector<HTMLLabelElement>( + 'label[aria-label="Pass"]', + )!; + act(() => pass.querySelector<HTMLInputElement>("input")!.click()); + assert.equal(scrolled.length, 0); + input(ui.container, "#sub_name_input", ""); + await submit(ui.container); + assert.equal(scrolled.at(-1)?.id, "sub_name_input"); + assert.equal(document.activeElement?.id, "sub_name_input"); + } finally { + ui.close(); + HTMLElement.prototype.scrollIntoView = scroll; + HTMLElement.prototype.focus = focus; + } +}); + +test("product scope choices are independent and preserve selected categories", () => { + const ui = mount( + <CreateSubscriptionScreen + setupMode="classic" + primaryCurrency="CHF" + categories={[{ id: 1, name: "Coffee", productCount: 1 }]} + />, + ); + try { + const earning = ui.container.querySelector<HTMLElement>( + '[aria-labelledby="earning_rules_heading"]', + )!; + const reward = ui.container.querySelector<HTMLElement>( + '[aria-labelledby="redemption_rules_heading"]', + )!; + for (const section of [earning, reward]) { + assert.equal( + section.querySelectorAll('input[type="radio"][name$="-scope"]').length, + 2, + ); + assert.equal( + section.querySelector<HTMLInputElement>('input[value="selected"]')! + .checked, + true, + ); + } + act(() => button(earning, "+ Add product category").click()); + act(() => earning.querySelector<HTMLElement>('[role="option"]')!.click()); + assert.ok(earning.querySelector('[aria-label="Remove Coffee (1)"]')); + check(earning, 'input[aria-label="All purchases"]'); + assert.equal( + earning.querySelector('[aria-label="Remove Coffee (1)"]'), + null, + ); + assert.equal( + reward.querySelector<HTMLInputElement>('input[value="selected"]')! + .checked, + true, + ); + check(earning, 'input[aria-label="Purchases with matching products"]'); + assert.ok(earning.querySelector('[aria-label="Remove Coffee (1)"]')); + assert.equal( + earning.querySelector<HTMLInputElement>( + 'input[aria-label="All purchases"]', + )!.checked, + false, + ); + } finally { + ui.close(); + } +}); + +test("selector checkboxes toggle in place and preserve the search", () => { + const options = [ + { id: "small", name: "Coffee" }, + { id: "large", name: "Coffee" }, + ]; + function Harness() { + const [selected, setSelected] = useState([options[0]!]); + return ( + <TokenSelector + selected={selected} + onChange={setSelected} + options={options} + loading={false} + unavailable={false} + emptyMessage="Empty" + label="Search" + addLabel="+ Add" + /> + ); + } + const ui = mount(<Harness />); + try { + const trigger = button(ui.container, "+ Add"); + assert.equal( + trigger.closest("ul"), + null, + "Add has its own row outside the chips", + ); + act(() => trigger.click()); + input(ui.container, 'input[type="search"]', "Coffee"); + const search = ui.container.querySelector<HTMLInputElement>( + 'input[type="search"]', + )!; + const choices = () => [ + ...ui.container.querySelectorAll<HTMLElement>('[role="option"]'), + ]; + assert.equal(choices().length, 2); + assert.equal( + choices()[0]!.querySelector<HTMLInputElement>('input[type="checkbox"]')! + .checked, + true, + ); + assert.equal( + choices()[1]!.querySelector<HTMLInputElement>('input[type="checkbox"]')! + .checked, + false, + ); + // Clicking a checkbox or its row toggles exactly once and keeps both rows. + act(() => + choices()[1]! + .querySelector<HTMLInputElement>('input[type="checkbox"]')! + .click(), + ); + assert.equal(choices()[1]!.getAttribute("aria-selected"), "true"); + assert.equal(ui.container.querySelectorAll("[data-chip-remove]").length, 2); + act(() => choices()[1]!.click()); + assert.equal(choices()[1]!.getAttribute("aria-selected"), "false"); + assert.equal( + choices()[1]!.querySelector<HTMLInputElement>('input[type="checkbox"]')! + .checked, + false, + ); + assert.equal(ui.container.querySelectorAll("[data-chip-remove]").length, 1); + assert.equal(search.value, "Coffee"); + assert.equal(document.activeElement, search); + assert.equal(trigger.getAttribute("aria-expanded"), "true"); + input(ui.container, 'input[type="search"]', "large"); + act(() => { + search.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }), + ); + }); + act(() => { + search.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + }); + assert.equal(choices()[0]!.getAttribute("aria-selected"), "true"); + act(() => { + search.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + assert.equal(ui.container.querySelector('[role="dialog"]'), null); + assert.equal(document.activeElement, trigger); + } finally { + ui.close(); + } +}); diff --git a/packages/taler-merchant-webui/src/stores/preferences.ts b/packages/taler-merchant-webui/src/stores/preferences.ts @@ -20,6 +20,7 @@ import { codecForBoolean, codecForConstString, codecForEither, + codecOptionalDefault, } from "@gnu-taler/taler-util"; import type { Signal } from "@preact/signals"; import { persistedSignal } from "./persisted.js"; @@ -33,14 +34,16 @@ export interface Preferences { dateFormat: DateFormat; /** Show specialist and diagnostic screens in the navigation. */ advancedTools: boolean; + tokenSetupWizard: boolean; } const defaults: Preferences = { dateFormat: "ymd", advancedTools: false, + tokenSetupWizard: false, }; -const codecForPreferences = (): Codec<Preferences> => +export const codecForPreferences = (): Codec<Preferences> => buildCodecForObject<Preferences>() .property( "dateFormat", @@ -51,6 +54,10 @@ const codecForPreferences = (): Codec<Preferences> => ), ) .property("advancedTools", codecForBoolean()) + .property( + "tokenSetupWizard", + codecOptionalDefault(codecForBoolean(), false), + ) .build("Preferences"); const store = persistedSignal<Preferences>( diff --git a/packages/taler-merchant-webui/src/stories/story-data.tsx b/packages/taler-merchant-webui/src/stories/story-data.tsx @@ -15,6 +15,8 @@ */ import { TokenSelectorStory } from "./TokenSelectorStory.js"; +import { useTranslation } from "../context/translation.js"; +import { subscriptionItem } from "../screens/token-setup/copy.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { @@ -668,6 +670,63 @@ const storyResource = <T,>(data: T) => ({ refresh: async () => undefined, }); +function RewardManagementStory() { + const { t } = useTranslation(); + return ( + <SubscriptionsScreen + items={[ + subscriptionItem(t, { + slug: "coffee-reward", + name: "Coffee discount stamps", + description: "Collect five discount stamps for a discount", + kind: "discount", + validBefore: "2026-12-31", + extraData: { + experimental_discount: { + type: "flat", + amount: ["CHF:3"], + required_tokens: 5, + product_selectors: "*", + issuance: { product_selectors: "*", issue_on_redemption: false }, + }, + }, + }), + subscriptionItem(t, { + slug: "member-pass", + name: "Member pass", + description: "Member access", + kind: "subscription", + extraData: { + experimental_subscription: { type: "none", product_selectors: "*" }, + }, + }), + ]} + /> + ); +} + +function TokenSetupWizardStory() { + const [categories, setCategories] = useState([ + { id: 1, name: "Coffee", productCount: 0 }, + ]); + return ( + <CreateSubscriptionScreen + setupMode="wizard" + primaryCurrency="CHF" + categories={categories} + onCreateCategory={async (name) => { + const category = { + id: Math.max(0, ...categories.map((item) => item.id)) + 1, + name, + productCount: 0, + }; + setCategories((items) => [...items, category]); + return category; + }} + /> + ); +} + export const STORIES: Story[] = [ { id: "ui-token-selector", @@ -753,6 +812,13 @@ export const STORIES: Story[] = [ render: () => <SubscriptionsScreen />, }, { + id: "integration-rewards-populated", + category: "Integration & Advanced", + name: "Discounts & Passes", + description: "Customer discounts and time-based access passes.", + render: () => <RewardManagementStory />, + }, + { id: "integration-paymentservices", category: "Integration & Advanced", name: "Payment Services", @@ -1324,10 +1390,19 @@ export const STORIES: Story[] = [ { id: "integration-create-subscription", category: "Integration & Advanced", - name: "Create Discount", + name: "Create Discount Stamps", description: "Discount and pass creation form with automatic benefits and validity controls.", - render: () => <CreateSubscriptionScreen />, + render: () => ( + <CreateSubscriptionScreen setupMode="classic" primaryCurrency="CHF" /> + ), + }, + { + id: "integration-token-wizard", + category: "Integration & Advanced", + name: "Create Discount Stamps", + description: "Guided setup for discount stamps and passes.", + render: () => <TokenSetupWizardStory />, }, { id: "integration-create-webhook",