stack/apps/dashboard/src/components/repeating-input.tsx
Armaan Jain e42ec65c88
Payments app design fixes (#1375)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

## Summary

This PR brings the Payments dashboard surfaces in line with the shared
design system: product creation, product-line / included-item dialogs,
auth-method toggles, payments empty states, and related layout polish.
Dialogs migrate from raw shadcn `Dialog` to `DesignDialog` with
consistent headers, footers, inputs, and selector dropdowns.

**Base:** `dev` → **Head:** `Payments-app-design-fixes`  
**Scope:** 31 files, ~+1.4k / −1.3k lines  
**Captured on:** local dev server (`internal` project), signed in as
`admin@example.com`

## Screenshots

Captured from `http://localhost:8101` (viewport: **1920×1200** standard,
**2560×1440** widescreen). Assets hosted in [this
gist](https://gist.github.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf).

> Red outlines on the **after** shots mark the new or changed UI
introduced by this PR.

### Create Product — payments form redesign

| | Before | After |
| --- | --- | --- |
| Light |
![payments-products-new-before-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-products-new-before-light.png)
|
![payments-products-new-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-products-new-after-light.png)
|
| Dark |
![payments-products-new-before-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-products-new-before-dark.png)
|
![payments-products-new-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-products-new-after-dark.png)
|

Widescreen:

| | Before | After |
| --- | --- | --- |
| Light |
![payments-products-new-before-light-wide](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-products-new-before-light-wide.png)
|
![payments-products-new-after-light-wide](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-products-new-after-light-wide.png)
|
| Dark |
![payments-products-new-before-dark-wide](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-products-new-before-dark-wide.png)
|
![payments-products-new-after-dark-wide](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-products-new-after-dark-wide.png)
|

### Product Lines onboarding — vertical centering fix

| | Before | After |
| --- | --- | --- |
| Light |
![payments-product-lines-before-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-product-lines-before-light.png)
|
![payments-product-lines-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-product-lines-after-light.png)
|
| Dark |
![payments-product-lines-before-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-product-lines-before-dark.png)
|
![payments-product-lines-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-product-lines-after-dark.png)
|

### Create Product Line dialog — `DesignDialog` migration

| | Before | After |
| --- | --- | --- |
| Light | *(legacy shadcn dialog on `dev` — open via Product Line →
Create new)* |
![dialog-create-product-line-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/dialog-create-product-line-after-light.png)
|
| Dark | |
![dialog-create-product-line-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/dialog-create-product-line-after-dark.png)
|

### Auth Methods — toggle row accessibility

| | Before | After |
| --- | --- | --- |
| Light |
![auth-methods-before-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/auth-methods-before-light.png)
|
![auth-methods-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/auth-methods-after-light.png)
|
| Dark |
![auth-methods-before-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/auth-methods-before-dark.png)
|
![auth-methods-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/auth-methods-after-dark.png)
|

### Other migrated surfaces (after only)

| Page | Light | Dark |
| --- | --- | --- |
| Payments settings |
![payments-settings-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-settings-after-light.png)
|
![payments-settings-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/payments-settings-after-dark.png)
|
| Sign-up rules |
![sign-up-rules-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/sign-up-rules-after-light.png)
|
![sign-up-rules-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/sign-up-rules-after-dark.png)
|
| Projects list (Create Project button) |
![projects-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/projects-after-light.png)
|
![projects-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/projects-after-dark.png)
|
| Playground / DesignDialog |
![playground-dialog-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/playground-dialog-after-light.png)
|
![playground-dialog-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/playground-dialog-after-dark.png)
|
| Included Item dialog |
![dialog-included-item-after-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/dialog-included-item-after-light.png)
|
![dialog-included-item-after-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/dialog-included-item-after-dark.png)
|

### Scroll behaviour — Sign-up Rules

| | Light | Dark |
| --- | --- | --- |
| Scroll |
![sign-up-rules-scroll-light](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/sign-up-rules-scroll-light.gif)
|
![sign-up-rules-scroll-dark](https://gist.githubusercontent.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf/raw/sign-up-rules-scroll-dark.gif)
|

## What's new

- **`DesignDialog`** extended with `customHeader`, `noBodyPadding`, and
section `className` hooks; Playground updated to showcase them.
- **Payments dialogs** (`CreateProductLineDialog`, `IncludedItemDialog`,
price edit, item dialog) migrated to design-system components.
- **Create Product** page uses `DesignButton`, `DesignInput`,
`DesignSelectorDropdown`, and refreshed header actions.
- **Auth Methods** toggle rows use semantic `<Label htmlFor>` instead of
click-capture divs.
- **Payments layout** empty-state card centers correctly; product-lines
onboarding slideshow vertically centers.
- **Backend** seed invariant for Growth product price; removed unused
import in product switch route.

## Notes for reviewers

- Dialog migrations preserve validation + async error handling
(`runAsynchronouslyWithAlert` where applicable).
- Included-item dialog uses a sentinel value for “Create new item” to
avoid colliding with real item IDs.
- `packages/stack` / `packages/js` are untouched; template +
dashboard-ui-components carry SDK-facing dialog changes.

## Test plan

- [x] Visual capture on `internal` project (`admin@example.com`) —
light/dark, standard + widescreen
- [ ] Create product flow: customer type → product line dropdown →
create line dialog
- [ ] Add included item dialog from create/edit product
- [ ] Auth Methods toggles (label click + switch)
- [ ] Payments product-lines onboarding slideshow at varied viewport
heights
- [ ] `pnpm typecheck` / `pnpm lint` / targeted E2E if API surface
changed

---------

Co-authored-by: nams1570 <amanganapathy@gmail.com>
Co-authored-by: mantrakp04 <mantrakp@gmail.com>
Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com>
2026-05-21 14:48:56 -07:00

270 lines
9.1 KiB
TypeScript

"use client";
import { DesignInput, DesignSelectorDropdown, designPopoverSurfaceClasses } from "@/components/design-components";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui";
import { cn } from "@/lib/utils";
import { CaretUpDownIcon } from "@phosphor-icons/react";
import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates";
import { useState } from "react";
const DEFAULT_INTERVAL_UNITS: DayInterval[1][] = ['day', 'week', 'month', 'year'];
type IntervalSelection = 'one-time' | 'custom' | DayInterval[1];
export type RepeatingInputProps = {
value: string,
onValueChange: (value: string) => void,
inputType?: 'text' | 'number',
placeholder?: string,
prefix?: string,
inputClassName?: string,
intervalSelection: IntervalSelection,
intervalCount: number,
intervalUnit?: DayInterval[1],
onIntervalChange: (interval: DayInterval | null) => void,
onIntervalSelectionChange: (selection: IntervalSelection) => void,
onIntervalCountChange: (count: number) => void,
onIntervalUnitChange: (unit: DayInterval[1] | undefined) => void,
allowedUnits?: DayInterval[1][],
noneLabel?: string,
useDurationLabels?: boolean,
readOnly?: boolean,
disabled?: boolean,
className?: string,
};
function getIntervalLabel(
intervalSelection: IntervalSelection,
intervalCount: number,
intervalUnit?: DayInterval[1],
useDurationLabels = false
): string {
if (intervalSelection === 'one-time') {
return 'One-time';
}
const unit = intervalUnit || 'month';
const count = intervalCount || 1;
if (count === 1) {
if (useDurationLabels) {
return `1 ${unit}`;
}
const labels: Record<DayInterval[1], string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
year: 'Yearly',
};
return labels[unit];
}
return `Every ${count} ${unit}s`;
}
export function RepeatingInput({
value,
onValueChange,
inputType = 'text',
placeholder,
prefix,
inputClassName,
intervalSelection,
intervalCount,
intervalUnit,
onIntervalChange,
onIntervalSelectionChange,
onIntervalCountChange,
onIntervalUnitChange,
allowedUnits,
noneLabel = 'One-time',
useDurationLabels = false,
readOnly,
disabled,
className,
}: RepeatingInputProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const units = allowedUnits ?? DEFAULT_INTERVAL_UNITS;
const normalizedUnits = units.length > 0 ? units : DEFAULT_INTERVAL_UNITS;
const defaultUnit = (normalizedUnits[0] ?? 'month') as DayInterval[1];
const effectiveUnit = intervalUnit && normalizedUnits.includes(intervalUnit) ? intervalUnit : defaultUnit;
const isIntervalUnit = intervalSelection !== 'custom' && intervalSelection !== 'one-time';
const effectiveSelection: IntervalSelection =
isIntervalUnit && !normalizedUnits.includes(intervalSelection as DayInterval[1])
? 'custom'
: intervalSelection;
const buttonLabels: Record<DayInterval[1], string> = useDurationLabels ? {
day: '1 day',
week: '1 week',
month: '1 month',
year: '1 year',
} : {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
year: 'Yearly',
};
const selectOneTime = () => {
onIntervalSelectionChange('one-time');
onIntervalUnitChange(undefined);
onIntervalCountChange(1);
if (!readOnly) onIntervalChange(null);
setPopoverOpen(false);
};
const selectFixed = (unitOption: DayInterval[1]) => {
if (!normalizedUnits.includes(unitOption)) return;
onIntervalSelectionChange(unitOption);
onIntervalUnitChange(unitOption);
onIntervalCountChange(1);
if (!readOnly) onIntervalChange([1, unitOption]);
setPopoverOpen(false);
};
const applyCustom = (countValue: number, maybeUnit?: DayInterval[1]) => {
const safeUnit = maybeUnit && normalizedUnits.includes(maybeUnit) ? maybeUnit : defaultUnit;
onIntervalSelectionChange('custom');
onIntervalUnitChange(safeUnit);
onIntervalCountChange(countValue);
if (!readOnly) onIntervalChange([countValue, safeUnit]);
};
const triggerLabel = getIntervalLabel(effectiveSelection, intervalCount, effectiveUnit, useDurationLabels);
return (
<div
className={cn(
"flex h-9 w-full items-stretch overflow-hidden rounded-xl",
"border border-black/[0.08] dark:border-white/[0.06]",
"bg-white/80 dark:bg-foreground/[0.03]",
"shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06]",
"transition-all duration-150 hover:transition-none",
"hover:bg-white dark:hover:bg-foreground/[0.06]",
"focus-within:ring-1 focus-within:ring-foreground/[0.1]",
(disabled || readOnly) && "opacity-60",
className
)}
>
{prefix && (
<div className="flex items-center justify-center select-none px-3 text-sm text-muted-foreground/70 border-r border-black/[0.06] dark:border-white/[0.06] bg-black/[0.03] dark:bg-white/[0.02]">
{prefix}
</div>
)}
<input
type={inputType}
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder={placeholder}
disabled={disabled || readOnly}
className={cn(
"min-w-0 flex-1 bg-transparent px-3 text-sm text-foreground",
"placeholder:text-muted-foreground/50",
"focus:outline-none",
"disabled:cursor-not-allowed",
inputClassName
)}
/>
<Popover open={popoverOpen} onOpenChange={(isOpen) => !readOnly && !disabled && setPopoverOpen(isOpen)}>
<PopoverTrigger asChild disabled={readOnly || disabled}>
<button
type="button"
disabled={disabled || readOnly}
className={cn(
"flex items-center gap-1.5 px-3 text-sm border-l border-black/[0.06] dark:border-white/[0.06]",
"bg-black/[0.02] dark:bg-white/[0.02] text-muted-foreground",
"hover:bg-foreground/[0.05] hover:text-foreground",
"focus:outline-none focus-visible:bg-foreground/[0.06]",
"transition-colors duration-150 hover:transition-none",
(disabled || readOnly) && "opacity-50 cursor-not-allowed pointer-events-none"
)}
>
<span className="whitespace-nowrap">{triggerLabel}</span>
<CaretUpDownIcon className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={6}
className={cn("w-60 p-0 overflow-hidden", designPopoverSurfaceClasses)}
>
<div className="flex flex-col p-1">
<button
type="button"
className={cn(
"flex items-center w-full px-2.5 py-2 rounded-lg text-left text-sm",
"transition-colors duration-150 hover:transition-none",
effectiveSelection === 'one-time'
? "bg-foreground/[0.08] text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05]"
)}
onClick={selectOneTime}
>
{noneLabel}
</button>
{normalizedUnits.map((unitOption) => (
<button
type="button"
key={unitOption}
className={cn(
"flex items-center w-full px-2.5 py-2 rounded-lg text-left text-sm",
"transition-colors duration-150 hover:transition-none",
effectiveSelection === unitOption
? "bg-foreground/[0.08] text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05]"
)}
onClick={() => selectFixed(unitOption)}
>
{buttonLabels[unitOption]}
</button>
))}
</div>
<div className="border-t border-black/[0.06] dark:border-white/[0.06] p-3">
<div className="text-xs font-medium text-muted-foreground mb-2">Custom</div>
<div className="flex gap-2">
<DesignInput
type="number"
min={1}
size="sm"
className="w-20"
value={effectiveSelection === 'custom' ? intervalCount : 1}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (val > 0) {
applyCustom(val, effectiveUnit);
}
}}
/>
<DesignSelectorDropdown
value={effectiveUnit}
onValueChange={(v) => {
const newUnit = v as DayInterval[1];
applyCustom(effectiveSelection === 'custom' ? intervalCount : 1, newUnit);
}}
options={normalizedUnits.map((u) => ({
value: u,
label: `${u}${(effectiveSelection === 'custom' ? intervalCount : 1) !== 1 ? 's' : ''}`,
}))}
size="sm"
className="min-w-0 flex-1"
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}