Skip to content

Commit 738a360

Browse files
committed
feat(webapp): unify billing information into one panel
Collapse the four separately-bordered cards (payment method; legal entity + email; address; tax ID) under two muted subheadings into a single panel with internal dividers, matching the approved design — title and 'View all invoices' link on top, payment method, legal entity/email fields, collapsible address and tax ID rows, then save. Also swap the single email input for a chip field (InvoicingEmailsField) so a customer can add and remove multiple billing email addresses.
1 parent 0178bd7 commit 738a360

6 files changed

Lines changed: 237 additions & 151 deletions

File tree

packages/webapp/src/pages/Team/Billing/Show.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ export const TeamBilling: React.FC = () => {
5656
{canManageBilling && (
5757
<>
5858
<Separator />
59-
<div id="payment-and-invoices" className="flex flex-col gap-4">
60-
<span className="text-text-strong text-body-medium-medium">Billing information</span>
59+
<div id="payment-and-invoices">
6160
<Payment />
6261
</div>
6362
</>

packages/webapp/src/pages/Team/Billing/components/InvoicingAddressFields.tsx

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Plus, Trash2 } from 'lucide-react';
22
import { useFormContext, useWatch } from 'react-hook-form';
33

4-
import { Card, CardAction, CardContent, CardHeader, CardTitle, IconButton, Input } from '@nangohq/design-system';
4+
import { IconButton, Input } from '@nangohq/design-system';
55

66
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form';
77
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
@@ -24,28 +24,24 @@ export const InvoicingAddressFields: React.FC = () => {
2424
};
2525

2626
return (
27-
<Card>
28-
<CardHeader>
29-
<CardTitle>
30-
<span className="flex items-center gap-2">
31-
Billing address
32-
<OptionalTag />
33-
</span>
34-
</CardTitle>
35-
<CardAction>
36-
{address ? (
37-
<IconButton type="button" variant="ghost" size="2xs" onClick={handleRemove} label="Remove line">
38-
<Trash2 />
39-
</IconButton>
40-
) : (
41-
<IconButton type="button" variant="ghost" size="2xs" onClick={handleAdd} label="Add line">
42-
<Plus />
43-
</IconButton>
44-
)}
45-
</CardAction>
46-
</CardHeader>
27+
<div className="border-t border-border-muted">
28+
<div className="p-4 flex items-center justify-between">
29+
<span className="flex items-center gap-2 text-text-strong text-ds-lg font-ds-medium leading-ds-snug tracking-ds-tight">
30+
Billing address
31+
<OptionalTag />
32+
</span>
33+
{address ? (
34+
<IconButton type="button" variant="ghost" size="2xs" onClick={handleRemove} label="Remove line">
35+
<Trash2 />
36+
</IconButton>
37+
) : (
38+
<IconButton type="button" variant="ghost" size="2xs" onClick={handleAdd} label="Add line">
39+
<Plus />
40+
</IconButton>
41+
)}
42+
</div>
4743
{address && (
48-
<CardContent>
44+
<div className="px-4 pb-4">
4945
<div className="grid grid-cols-2 items-start gap-3">
5046
<FormField
5147
control={control}
@@ -139,8 +135,8 @@ export const InvoicingAddressFields: React.FC = () => {
139135
)}
140136
/>
141137
</div>
142-
</CardContent>
138+
</div>
143139
)}
144-
</Card>
140+
</div>
145141
);
146142
};

packages/webapp/src/pages/Team/Billing/components/InvoicingDetailsForm.tsx

Lines changed: 32 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
33
import { useForm } from 'react-hook-form';
44
import { z } from 'zod';
55

6-
import { Button, Card, CardContent, CardHeader, CardTitle, Input } from '@nangohq/design-system';
6+
import { Button, Input } from '@nangohq/design-system';
77

88
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form';
99
import { Skeleton } from '@/components/ui/Skeleton';
@@ -12,6 +12,7 @@ import { useToast } from '@/hooks/useToast';
1212
import { useStore } from '@/store';
1313
import { countryCodes, taxIdTypes } from '../invoicingConstants';
1414
import { InvoicingAddressFields } from './InvoicingAddressFields';
15+
import { InvoicingEmailsField } from './InvoicingEmailsField';
1516
import { InvoicingTaxIdFields } from './InvoicingTaxIdFields';
1617

1718
import type { BillingCustomer } from '@nangohq/types';
@@ -40,7 +41,7 @@ const taxIdSchema = z.object({
4041

4142
const schema = z.object({
4243
legalEntityName: z.string().min(1, 'Required'),
43-
email: z.string().email('Valid email required'),
44+
emails: z.array(z.string().email('Invalid email address')).min(1, 'At least one billing email required'),
4445
address: addressSchema.nullable(),
4546
taxId: taxIdSchema.nullable()
4647
});
@@ -50,7 +51,7 @@ export type InvoicingFormData = z.infer<typeof schema>;
5051
function toFormData(customer: BillingCustomer): InvoicingFormData {
5152
return {
5253
legalEntityName: customer.invoicingDetails.legalEntityName,
53-
email: customer.invoicingDetails.email,
54+
emails: [customer.invoicingDetails.email, ...customer.invoicingDetails.additionalEmails],
5455
address: customer.invoicingDetails.address ? { ...customer.invoicingDetails.address, country: customer.invoicingDetails.address.country ?? '' } : null,
5556
taxId: customer.invoicingDetails.taxId
5657
};
@@ -74,7 +75,13 @@ export const InvoicingDetailsForm: React.FC<{ customer: BillingCustomer | undefi
7475

7576
const onSubmit = async (data: InvoicingFormData) => {
7677
try {
77-
await putAsync({ legalEntityName: data.legalEntityName, email: data.email, address: data.address, taxId: data.taxId });
78+
await putAsync({
79+
legalEntityName: data.legalEntityName,
80+
email: data.emails[0]!,
81+
additionalEmails: data.emails.slice(1),
82+
address: data.address,
83+
taxId: data.taxId
84+
});
7885
toast({ title: 'Invoicing details updated', variant: 'success' });
7986
} catch {
8087
toast({ title: 'Failed to update invoicing details', variant: 'error' });
@@ -83,7 +90,7 @@ export const InvoicingDetailsForm: React.FC<{ customer: BillingCustomer | undefi
8390

8491
if (!customer) {
8592
return (
86-
<div className="flex flex-col gap-3">
93+
<div className="border-t border-border-muted p-4 flex flex-col gap-3">
8794
<Skeleton className="w-40 h-5" />
8895
<Skeleton className="w-full h-9" />
8996
<Skeleton className="w-full h-9" />
@@ -93,51 +100,30 @@ export const InvoicingDetailsForm: React.FC<{ customer: BillingCustomer | undefi
93100

94101
return (
95102
<Form {...form}>
96-
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
97-
<Card>
98-
<CardHeader>
99-
<CardTitle>Billing information</CardTitle>
100-
</CardHeader>
101-
<CardContent>
102-
<div className="flex flex-row items-start gap-5 [&>*]:flex-1">
103-
<FormField
104-
control={form.control}
105-
name="legalEntityName"
106-
render={({ field }) => (
107-
<FormItem>
108-
<FormLabel className="flex gap-1 items-center">
109-
Legal entity name <span className="text-text-danger">*</span>
110-
</FormLabel>
111-
<FormControl>
112-
<Input placeholder="Acme Inc." {...field} />
113-
</FormControl>
114-
<FormMessage />
115-
</FormItem>
116-
)}
117-
/>
118-
<FormField
119-
control={form.control}
120-
name="email"
121-
render={({ field }) => (
122-
<FormItem>
123-
<FormLabel className="flex gap-1 items-center">
124-
Billing email <span className="text-text-danger">*</span>
125-
</FormLabel>
126-
<FormControl>
127-
<Input type="email" placeholder="billing@company.com" {...field} />
128-
</FormControl>
129-
<FormMessage />
130-
</FormItem>
131-
)}
132-
/>
133-
</div>
134-
</CardContent>
135-
</Card>
103+
<form onSubmit={form.handleSubmit(onSubmit)}>
104+
<div className="border-t border-border-muted p-4 flex flex-row items-start gap-5 [&>*]:flex-1">
105+
<FormField
106+
control={form.control}
107+
name="legalEntityName"
108+
render={({ field }) => (
109+
<FormItem>
110+
<FormLabel className="flex gap-1 items-center">
111+
Legal entity name <span className="text-text-danger">*</span>
112+
</FormLabel>
113+
<FormControl>
114+
<Input placeholder="Acme Inc." {...field} />
115+
</FormControl>
116+
<FormMessage />
117+
</FormItem>
118+
)}
119+
/>
120+
<InvoicingEmailsField />
121+
</div>
136122

137123
<InvoicingAddressFields />
138124
<InvoicingTaxIdFields />
139125

140-
<div className="flex justify-start">
126+
<div className="border-t border-border-muted p-4">
141127
<Button type="submit" variant="primary" size="md" loading={isPending}>
142128
Save changes
143129
</Button>
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useState } from 'react';
2+
import { useFormContext, useWatch } from 'react-hook-form';
3+
import { z } from 'zod';
4+
5+
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form';
6+
import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxValue } from '../../../../components/ui/Combobox';
7+
8+
import type { InvoicingFormData } from './InvoicingDetailsForm';
9+
10+
const emailSchema = z.string().email();
11+
12+
export const InvoicingEmailsField: React.FC = () => {
13+
const { control, setValue, setError, clearErrors } = useFormContext<InvoicingFormData>();
14+
// Defaults to [] for the render between the parent's `customer` becoming truthy
15+
// and the `form.reset(toFormData(customer))` effect actually populating the field.
16+
const emails = useWatch({ control, name: 'emails' }) ?? [];
17+
const [inputValue, setInputValue] = useState('');
18+
19+
const commit = (next: string[]) => {
20+
setValue('emails', next, { shouldDirty: true, shouldValidate: true });
21+
};
22+
23+
// Splits on commas so a paste of "a@x.com, b@x.com" (or Figma's comma-separated
24+
// display format) adds every address at once instead of one long invalid chip.
25+
const addEmailsFromText = (text: string) => {
26+
const candidates = text
27+
.split(',')
28+
.map((e) => e.trim())
29+
.filter(Boolean);
30+
if (candidates.length === 0) return;
31+
32+
const invalid = candidates.filter((c) => !emailSchema.safeParse(c).success);
33+
const valid = candidates.filter((c) => emailSchema.safeParse(c).success && !emails.includes(c));
34+
35+
if (valid.length > 0) {
36+
commit(Array.from(new Set([...emails, ...valid])));
37+
}
38+
39+
if (invalid.length > 0) {
40+
setError('emails', { type: 'manual', message: `Invalid email address: ${invalid.join(', ')}` });
41+
setInputValue(invalid.join(', '));
42+
} else {
43+
clearErrors('emails');
44+
setInputValue('');
45+
}
46+
};
47+
48+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
49+
if (e.key === 'Enter' || e.key === ',') {
50+
e.preventDefault();
51+
const value = (e.target as HTMLInputElement).value.replace(/,$/, '').trim();
52+
if (!value) return;
53+
addEmailsFromText(value);
54+
}
55+
};
56+
57+
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
58+
const text = e.clipboardData.getData('text');
59+
if (!text.includes(',')) return;
60+
e.preventDefault();
61+
addEmailsFromText(text);
62+
};
63+
64+
const handleBlur = () => {
65+
if (inputValue.trim()) {
66+
addEmailsFromText(inputValue);
67+
}
68+
};
69+
70+
return (
71+
<FormField
72+
control={control}
73+
name="emails"
74+
render={() => (
75+
<FormItem>
76+
<FormLabel className="flex gap-1 items-center">
77+
Billing email addresses <span className="text-text-danger">*</span>
78+
</FormLabel>
79+
<FormControl>
80+
<Combobox items={[]} multiple value={emails} inputValue={inputValue} onValueChange={commit} open={false}>
81+
<ComboboxChips className="min-h-9">
82+
{emails.length > 0 && (
83+
<ComboboxValue>
84+
{emails.map((email) => (
85+
<ComboboxChip key={email}>{email}</ComboboxChip>
86+
))}
87+
</ComboboxValue>
88+
)}
89+
<ComboboxChipsInput
90+
placeholder={emails.length === 0 ? 'billing@company.com' : ''}
91+
value={inputValue}
92+
onChange={(e) => setInputValue((e.target as HTMLInputElement).value)}
93+
onKeyDown={handleKeyDown}
94+
onPaste={handlePaste}
95+
onBlur={handleBlur}
96+
/>
97+
</ComboboxChips>
98+
</Combobox>
99+
</FormControl>
100+
<FormMessage />
101+
</FormItem>
102+
)}
103+
/>
104+
);
105+
};

packages/webapp/src/pages/Team/Billing/components/InvoicingTaxIdFields.tsx

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Plus, Trash2 } from 'lucide-react';
22
import { useEffect, useMemo } from 'react';
33
import { useFormContext, useWatch } from 'react-hook-form';
44

5-
import { Card, CardAction, CardContent, CardHeader, CardTitle, IconButton, Input } from '@nangohq/design-system';
5+
import { IconButton, Input } from '@nangohq/design-system';
66

77
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form';
88
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
@@ -53,28 +53,24 @@ export const InvoicingTaxIdFields: React.FC = () => {
5353
};
5454

5555
return (
56-
<Card>
57-
<CardHeader>
58-
<CardTitle>
59-
<span className="flex items-center gap-2">
60-
Tax ID
61-
<OptionalTag />
62-
</span>
63-
</CardTitle>
64-
<CardAction>
65-
{taxId ? (
66-
<IconButton type="button" variant="ghost" size="2xs" onClick={handleRemove} label="Remove tax ID">
67-
<Trash2 />
68-
</IconButton>
69-
) : (
70-
<IconButton type="button" variant="ghost" size="2xs" onClick={handleAdd} label="Add tax ID">
71-
<Plus />
72-
</IconButton>
73-
)}
74-
</CardAction>
75-
</CardHeader>
56+
<div className="border-t border-border-muted">
57+
<div className="p-4 flex items-center justify-between">
58+
<span className="flex items-center gap-2 text-text-strong text-ds-lg font-ds-medium leading-ds-snug tracking-ds-tight">
59+
Tax ID
60+
<OptionalTag />
61+
</span>
62+
{taxId ? (
63+
<IconButton type="button" variant="ghost" size="2xs" onClick={handleRemove} label="Remove tax ID">
64+
<Trash2 />
65+
</IconButton>
66+
) : (
67+
<IconButton type="button" variant="ghost" size="2xs" onClick={handleAdd} label="Add tax ID">
68+
<Plus />
69+
</IconButton>
70+
)}
71+
</div>
7672
{taxId && (
77-
<CardContent>
73+
<div className="px-4 pb-4">
7874
<div className="grid grid-cols-2 items-start gap-3">
7975
<FormField
8076
control={control}
@@ -153,8 +149,8 @@ export const InvoicingTaxIdFields: React.FC = () => {
153149
)}
154150
/>
155151
</div>
156-
</CardContent>
152+
</div>
157153
)}
158-
</Card>
154+
</div>
159155
);
160156
};

0 commit comments

Comments
 (0)