mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-03 21:02:05 +08:00
<!-- 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 |  |  | | Dark |  |  | Widescreen: | | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | ### Product Lines onboarding — vertical centering fix | | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | ### Create Product Line dialog — `DesignDialog` migration | | Before | After | | --- | --- | --- | | Light | *(legacy shadcn dialog on `dev` — open via Product Line → Create new)* |  | | Dark | |  | ### Auth Methods — toggle row accessibility | | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | ### Other migrated surfaces (after only) | Page | Light | Dark | | --- | --- | --- | | Payments settings |  |  | | Sign-up rules |  |  | | Projects list (Create Project button) |  |  | | Playground / DesignDialog |  |  | | Included Item dialog |  |  | ### Scroll behaviour — Sign-up Rules | | Light | Dark | | --- | --- | --- | | Scroll |  |  | ## 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>
270 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|