Fix various issues in billing flows

- Add color package for color manipulation
- Update Stripe form appearance for light and dark themes with proper
  color conversion and improved focus states
- Fix payment modal loading state display
- Improve payment method table column widths and conditional visibility
- Fix navbar account menu closing on navigation
- Update OAuth modal descriptions and remove duplicate titles
- Export closeModal method from FakeModal component
This commit is contained in:
Torsten Dittmann
2025-05-30 15:51:17 +02:00
parent b33a9d2d01
commit e22ec6ae52
13 changed files with 130 additions and 46 deletions
+1
View File
@@ -65,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"@vitest/ui": "^3.0.9",
"color": "^5.0.0",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.0",
"eslint-plugin-svelte": "^3.3.3",
+34
View File
@@ -135,6 +135,9 @@ importers:
'@vitest/ui':
specifier: ^3.0.9
version: 3.0.9(vitest@3.0.9)
color:
specifier: ^5.0.0
version: 5.0.0
eslint:
specifier: ^9.23.0
version: 9.23.0
@@ -1714,9 +1717,25 @@ packages:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-convert@3.1.0:
resolution: {integrity: sha512-TVoqAq8ZDIpK5lsQY874DDnu65CSsc9vzq0wLpNQ6UMBq81GSZocVazPiBbYGzngzBOIRahpkTzCLVe2at4MfA==}
engines: {node: '>=14.6'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-name@2.0.0:
resolution: {integrity: sha512-SbtvAMWvASO5TE2QP07jHBMXKafgdZz8Vrsrn96fiL+O92/FN/PLARzUW5sKt013fjAprK2d2iCn2hk2Xb5oow==}
engines: {node: '>=12.20'}
color-string@2.0.1:
resolution: {integrity: sha512-5z9FbYTZPAo8iKsNEqRNv+OlpBbDcoE+SY9GjLfDUHEfcNNV7tS9eSAlFHEaub/r5tBL9LtskAeq1l9SaoZ5tQ==}
engines: {node: '>=18'}
color@5.0.0:
resolution: {integrity: sha512-16BlyiuyLq3MLxpRWyOTiWsO3ii/eLQLJUQXBSNcxMBBSnyt1ee9YUdaozQp03ifwm5woztEZGDbk9RGVuCsdw==}
engines: {node: '>=18'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -5256,8 +5275,23 @@ snapshots:
dependencies:
color-name: 1.1.4
color-convert@3.1.0:
dependencies:
color-name: 2.0.0
color-name@1.1.4: {}
color-name@2.0.0: {}
color-string@2.0.1:
dependencies:
color-name: 2.0.0
color@5.0.0:
dependencies:
color-convert: 3.1.0
color-string: 2.0.1
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
+14 -4
View File
@@ -15,11 +15,12 @@
let name: string;
let error: string;
let modal: FakeModal;
async function handleSubmit() {
try {
const card = await submitStripeCard(name, page?.params?.organization ?? null);
show = false;
modal.closeModal();
invalidate(Dependencies.PAYMENT_METHODS);
dispatch('submit', card);
addNotification({
@@ -65,7 +66,12 @@
}
</script>
<FakeModal bind:show title="Add payment method" bind:error onSubmit={handleSubmit}>
<FakeModal
bind:this={modal}
bind:show
title="Add payment method"
bind:error
onSubmit={handleSubmit}>
<slot />
<InputText
id="name"
@@ -82,7 +88,10 @@
</div>
{/if}
<div class="stripe-element" bind:this={element}>
<div
style:display={isLoading ? 'none' : 'initial'}
class="stripe-element"
bind:this={element}>
<!-- Stripe will create form elements here -->
</div>
</div>
@@ -105,7 +114,8 @@
.loader-element {
width: 100%;
align-self: center;
justify-items: end;
justify-content: center;
display: flex;
}
}
</style>
+3 -2
View File
@@ -19,8 +19,9 @@
</Layout.Stack>
</Table.Cell>
<Table.Cell column="name" {root}>{paymentMethod?.name}</Table.Cell>
<Table.Cell column="expiry" {root}
>{paymentMethod?.expiryMonth}/{paymentMethod?.expiryYear}</Table.Cell>
<Table.Cell column="expiry" {root}>
{paymentMethod?.expiryMonth}/{paymentMethod?.expiryYear}
</Table.Cell>
<Table.Cell column="status" {root}>
{#if paymentMethod?.lastError || paymentMethod?.expired}
<Popover let:toggle>
+1 -1
View File
@@ -30,7 +30,7 @@
}
}
function closeModal() {
export function closeModal() {
document.documentElement.classList.remove('u-overflow-hidden');
show = false;
}
@@ -65,8 +65,6 @@
items: filterCols.map((col) => {
return {
name: col.title,
onClick: () =>
console.log(subSheets.find((sheet) => sheet?.title === col?.title)),
subMenu: subSheets.find((sheet) => sheet?.title === col?.title),
trailingIcon: IconChevronRight
};
+4
View File
@@ -55,6 +55,7 @@
import { user } from '$lib/stores/user';
import { Click, trackEvent } from '$lib/actions/analytics';
import type { HTMLAttributes } from 'svelte/elements';
import { beforeNavigate } from '$app/navigation';
let showSupport = false;
@@ -115,6 +116,9 @@
$: currentOrg = organizations.find((org) => org.isSelected);
$: selectedProject = currentOrg?.projects.find((project) => project.isSelected);
beforeNavigate(() => {
showAccountMenu = false;
});
</script>
<Navbar.Base {...$$props}>
+41 -20
View File
@@ -7,7 +7,8 @@ import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from './notifications';
import { organization } from './organization';
import { base } from '$app/paths';
import { ThemeLightCloud } from '$themes';
import { ThemeDarkCloud, ThemeLightCloud } from '$themes';
import Color from 'color';
export const stripe = writable<Stripe>();
let paymentMethod: PaymentMethodData;
@@ -185,37 +186,44 @@ export async function confirmSetup(
}
}
function toRGB(color: string): string {
return Color(color).rgb().string();
}
const appearanceLight: Appearance = {
variables: {
fontSizeBase: ThemeLightCloud['font-size-s'],
fontSizeSm: ThemeLightCloud['font-size-s'],
colorPrimary: ThemeLightCloud['neutral-700'],
colorText: ThemeLightCloud['neutral-700'],
colorBackground: 'rgb(250, 250, 251)',
colorDanger: ThemeLightCloud['fgcolor-error'],
colorPrimary: toRGB(ThemeLightCloud['neutral-700']),
colorTextSecondary: toRGB(ThemeLightCloud['neutral-700']),
colorText: toRGB(ThemeLightCloud['neutral-700']),
colorBackground: toRGB(ThemeLightCloud['neutral-25']),
colorDanger: toRGB(ThemeLightCloud['web-red-700']),
fontFamily: ThemeLightCloud['font-family-sansserif'],
borderRadius: ThemeLightCloud['base-8']
},
rules: {
'.Label': {
color: ThemeLightCloud['neutral-700'],
color: toRGB(ThemeLightCloud['neutral-700']),
marginBottom: ThemeLightCloud['base-8'],
fontWeight: '500'
},
'.Input': {
padding: ThemeLightCloud['base-6'],
paddingLeft: ThemeLightCloud['base-12']
paddingLeft: ThemeLightCloud['base-12'],
outlineOffset: '-1px'
},
'.Input:hover': {
border: 'solid 1px rgb(195, 195, 198)',
border: '1px solid ' + toRGB(ThemeLightCloud['neutral-250']),
boxShadow: 'none'
},
'.Input:focus': {
border: 'solid 1px rgb(195, 195, 198)',
border: '1px solid ' + toRGB(ThemeLightCloud['neutral-250']),
outline: '2px solid ' + toRGB(ThemeLightCloud['neutral-250']),
boxShadow: 'none'
},
'.Input::placeholder': {
color: '#C4C6D7'
color: toRGB(ThemeLightCloud['neutral-250'])
},
'.Input--invalid': {
border: 'solid 1px var(--colorDanger)',
@@ -226,25 +234,38 @@ const appearanceLight: Appearance = {
const appearanceDark = {
variables: {
colorPrimary: '#606a7b',
colorText: 'rgb(195, 195, 198)',
colorBackground: 'rgb(24, 24, 27)',
colorDanger: '#FF453A',
fontFamily: 'Inter, arial, sans-serif',
borderRadius: '4px',
spacingGridRow: '16px'
fontSizeBase: ThemeDarkCloud['font-size-s'],
fontSizeSm: ThemeDarkCloud['font-size-s'],
colorPrimary: toRGB(ThemeDarkCloud['neutral-250']),
colorText: toRGB(ThemeDarkCloud['neutral-250']),
colorTextSecondary: toRGB(ThemeDarkCloud['neutral-250']),
colorBackground: toRGB(ThemeDarkCloud['neutral-900']),
colorDanger: toRGB(ThemeDarkCloud['web-red-500']),
fontFamily: ThemeDarkCloud['font-family-sansserif'],
borderRadius: ThemeDarkCloud['base-8']
},
rules: {
'.Label': {
color: toRGB(ThemeDarkCloud['neutral-250']),
marginBottom: ThemeDarkCloud['base-8'],
fontWeight: '500'
},
'.Input': {
padding: ThemeDarkCloud['base-6'],
paddingLeft: ThemeDarkCloud['base-12'],
outlineOffset: '-1px'
},
'.Input:hover': {
border: 'solid 1px rgb(87, 87, 92)',
border: '1px solid ' + toRGB(ThemeDarkCloud['neutral-500']),
boxShadow: 'none'
},
'.Input:focus': {
border: 'solid 1px rgb(87, 87, 92)',
border: '1px solid ' + toRGB(ThemeDarkCloud['neutral-500']),
outline: '2px solid ' + toRGB(ThemeDarkCloud['neutral-500']),
boxShadow: 'none'
},
'.Input::placeholder': {
color: 'rgb(87, 87, 92)'
color: toRGB(ThemeDarkCloud['neutral-500'])
},
'.Input--invalid': {
border: 'solid 1px var(--colorDanger)',
+1 -1
View File
@@ -41,7 +41,7 @@
...permanentTabs,
{
href: `${path}/payments`,
title: 'Payment details',
title: 'Payments',
event: 'payments',
hasChildren: true
}
@@ -40,6 +40,13 @@
$: filteredMethods = $paymentMethods?.paymentMethods.filter(
(method: PaymentMethodData) => !!method?.last4
);
$: hasLinkedOrgs = filteredMethods.some((method) =>
orgList.some(
(org) => method.$id === org.paymentMethodId || method.$id === org.backupPaymentMethodId
)
);
$: hasPaymentError = filteredMethods.some((method) => method?.lastError || method?.expired);
</script>
<CardGrid>
@@ -50,18 +57,21 @@
<Table.Root
let:root
columns={[
{ id: 'cc' },
{ id: 'name' },
{ id: 'expiry' },
{ id: 'status' },
{ id: 'links' },
{ id: 'cc', width: 140 },
{ id: 'name', width: { min: 140 } },
{ id: 'expiry', width: 100 },
{ id: 'status', width: 110, hide: !hasPaymentError },
{ id: 'links', width: 190, hide: !hasLinkedOrgs },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="cc" {root}>Credit card</Table.Header.Cell>
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="expiration" {root}
>Expiration date</Table.Header.Cell>
<Table.Header.Cell column="expiration" {root}>
Expiration date
</Table.Header.Cell>
<Table.Header.Cell column="status" {root} />
<Table.Header.Cell column="links" {root} />
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each filteredMethods as paymentMethod, i}
@@ -99,6 +99,12 @@
$: if (!showReplace) {
isSelectedBackup = false;
}
$: hasPaymentError =
defaultPaymentMethod?.lastError ||
defaultPaymentMethod.expired ||
backupPaymentMethod?.lastError ||
backupPaymentMethod.expired;
</script>
<CardGrid>
@@ -109,10 +115,10 @@
<Table.Root
let:root
columns={[
{ id: 'cc' },
{ id: 'name' },
{ id: 'expiry' },
{ id: 'status' },
{ id: 'cc', width: 140 },
{ id: 'name', width: { min: 140 } },
{ id: 'expiry', width: 100 },
{ id: 'status', width: 110, hide: !hasPaymentError },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
@@ -39,10 +39,10 @@
};
</script>
<Modal {error} size="l" bind:show onSubmit={update} on:close>
<Modal {error} bind:show onSubmit={update} title="{provider.name} OAuth2 settings" on:close>
<svelte:fragment slot="title">{provider.name} OAuth2 settings</svelte:fragment>
<p>
<p slot="description">
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
@@ -65,9 +65,8 @@
: provider.secret;
</script>
<Modal {error} onSubmit={update} size="l" bind:show on:close>
<svelte:fragment slot="title">{provider.name} OAuth2 settings</svelte:fragment>
<p>
<Modal {error} onSubmit={update} title="{provider.name} OAuth2 settings" bind:show on:close>
<p slot="description">
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"