Merge pull request #626 from nwparker/cloud/team-dialog-2

fix(team): TEAM-DIALOG-2; no header Plan & billing button, invite line is only the seat rule
This commit is contained in:
Neil
2026-11-13 00:01:07 -08:00
committed by GitHub
11 changed files with 106 additions and 426 deletions
@@ -0,0 +1,32 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TeamInviteComposer } from './TeamInviteComposer'
afterEach(cleanup)
const RULE = 'Each invite uses a seat once accepted.'
it('says only the seat rule, with no seat counts or prices, however many are entered', () => {
render(<TeamInviteComposer viewerRole="owner" onInvite={async () => ({ failed: [] })} />, {
wrapper: TooltipProvider
})
const input = screen.getByRole('textbox')
const hint = document.getElementById(input.getAttribute('aria-describedby')!)!
expect(hint.textContent).toBe(RULE)
fireEvent.change(input, { target: { value: 'jordan@acme.dev, kim@acme.dev ' } })
expect(hint.textContent).toBe(RULE)
expect(hint.textContent).not.toMatch(/open seat|\$|prorated/)
})
it('still flags invalid entries after the rule', () => {
render(<TeamInviteComposer viewerRole="owner" onInvite={async () => ({ failed: [] })} />, {
wrapper: TooltipProvider
})
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'jordan@acme.dev, not-an-email ' } })
const hint = document.getElementById(input.getAttribute('aria-describedby')!)!
expect(hint.textContent).toBe(`${RULE} Skipping 1 invalid entry.`)
})
@@ -10,9 +10,8 @@ import {
} from '@/components/ui/select'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
import type { OrcaOrgRole } from '../../../../../shared/orca-profiles'
import {
MAX_INVITES_PER_BATCH,
@@ -22,17 +21,11 @@ import {
} from './parse-invite-emails'
import { roleDescription } from './team-copy'
import { assignableRoles, roleLabel } from './team-roles'
import { inviteSeatImpactLine } from './team-invite-impact-copy'
import { inviteSeatImpact } from './team-seat-math'
export type InviteBatchOutcome = { failed: { email: string; message: string }[] }
export type TeamInviteComposerProps = {
viewerRole: OrcaOrgRole
entitlement: OrcaBillingEntitlement | null
members: number
pendingInvites: number
now: number
/** Sends every invite; resolves with the ones that failed so they stay in the field. */
onInvite: (emails: string[], role: OrcaOrgRole) => Promise<InviteBatchOutcome>
inputRef?: React.Ref<HTMLInputElement>
@@ -41,7 +34,6 @@ export type TeamInviteComposerProps = {
/** Multi-email invite field: paste a list, review the chips, pick a role, send. */
export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.Element {
const t = useTranslate()
const locale = useIntlLocale()
const id = useId()
const [draft, setDraft] = useState('')
const [candidates, setCandidates] = useState<InviteCandidate[]>([])
@@ -65,19 +57,10 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El
const pendingDraft = draft.trim()
? mergeInviteCandidates(candidates, splitInviteText(draft))
: candidates
// The button and the seat line both count what Send would send, including a typed,
// unseparated address, so the seat impact always matches the invites about to go out.
// The button counts what Send would send, including a typed, unseparated address.
const valid = pendingDraft.filter((candidate) => candidate.valid)
const committedValid = candidates.filter((candidate) => candidate.valid)
const invalidCount = candidates.length - committedValid.length
const paymentFailed = props.entitlement?.payment.pastDueSince != null
const impact = inviteSeatImpact({
entitlement: props.entitlement,
members: props.members,
pendingInvites: props.pendingInvites,
newInvites: valid.length,
now: props.now
})
const send = async (): Promise<void> => {
const all = commit(draft)
@@ -271,13 +254,8 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El
</div>
</div>
<p id={hintId} className="text-xs text-muted-foreground text-pretty">
{inviteSeatImpactLine(impact, valid.length, locale, t)}
{paymentFailed && valid.length > 0
? ` ${t(
'auto.components.billing.team.invitePaymentFailed2',
'New seats are charged once the failed payment is fixed.'
)}`
: null}
{/* Only the rule: seat counts and prices live in Plan & billing (Neil, TEAM-DIALOG-2). */}
{t('auto.components.billing.team.inviteSeatRule', 'Each invite uses a seat once accepted.')}
{invalidCount > 0
? ` ${t(
'auto.components.billing.team.skipInvalid',
@@ -244,34 +244,13 @@ async function typeInvites(text: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 100))
}
/** Will add seats: 4 paid, 4 members and 2 pending, so both new invites add a seat. */
/** Pasted list: the chips, one invalid entry, and the seat rule (no counts or prices). */
export const invitePasted = defineFixture({
width,
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, seats: 4 } }} />,
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev, not-an-email ')
})
/** Seats free: 8 paid, 6 taken (members plus pending), so one invite adds nothing. */
export const inviteSeatsFree = defineFixture({
width,
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, seats: 8 } }} />,
interact: () => typeInvites('jordan@acme.dev ')
})
/** Billing unknown (disabled in this build, or it failed): the rule without counts or prices. */
export const inviteBillingUnknown = defineFixture({
width,
render: () => <Page billing={{ status: 'unconfigured' }} />,
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ')
})
/** Billing sent no price: seats are still counted, no price is shown. */
export const inviteNoPrice = defineFixture({
width,
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, offers: null } }} />,
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ')
})
/** Short roster so the Enterprise row sits inside the frame. */
export const ssoRow = defineFixture({
width,
@@ -0,0 +1,63 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles'
import { sampleTeam } from '../plan-billing-fixtures'
import { TeamPageView, type TeamPageViewProps } from './TeamPageView'
afterEach(cleanup)
const roster: OrcaOrgMembersRoster = {
viewerRole: 'owner',
canManageMembers: true,
members: [
{ userId: 'u-neil', email: 'neil@acme.dev', displayName: 'Neil Parker', role: 'owner' },
{ userId: 'u-ada', email: 'ada@acme.dev', displayName: 'Ada Lovelace', role: 'admin' }
],
pendingInvites: [{ email: 'sam@acme.dev', role: 'member', createdAt: 0 }]
}
const noop = (): void => {}
const props: TeamPageViewProps = {
org: { orgId: 'org-acme', name: 'Acme Engineering' },
viewerUserId: 'u-neil',
roster: { status: 'ok', roster },
billing: { status: 'ok', entitlement: { ...sampleTeam, canManageBilling: true } },
arrival: null,
rowStates: {},
refreshing: false,
billingBusy: false,
now: 0,
create: {
organizations: [],
creating: false,
error: null,
unavailable: false,
switchingOrgId: null
},
onCreateTeam: noop,
onSwitchOrg: noop,
onRefresh: noop,
onInvite: async () => ({ failed: [] }),
onChangeRole: noop,
onMakeOwner: noop,
onRemove: noop,
onResend: noop,
onRevoke: noop,
onLeave: noop,
onSeePlans: noop,
onManageBilling: noop,
onCreateWorkspace: noop,
onDismissArrival: noop
}
it('keeps the header to the name and counts: no role sentence, no Plan & billing button', () => {
render(<TeamPageView {...props} />, { wrapper: TooltipProvider })
const header = document.querySelector('header')!
expect(header.textContent).toContain('2 members · 1 invited')
expect(header.textContent).not.toMatch(/You’re an|Plan & billing/)
expect(header.querySelector('button')).toBeNull()
// Seat counts and prices live only in Plan & billing.
expect(screen.queryByText(/open seat/)).toBeNull()
})
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { CreditCard, RefreshCw } from 'lucide-react'
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { Skeleton } from '@/components/ui/skeleton'
@@ -141,20 +141,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
: t('auto.components.billing.team.loadFailedTitle', 'Couldn’t load team')}
</p>
</div>
{!inline && entitlement?.canManageBilling ? (
// Seats, prices and invoices live in Plan & billing; one quiet way there from here.
// mr-6 clears the dialog's close button, which sits in this corner.
<Button
type="button"
variant="ghost"
size="xs"
className="mr-6 shrink-0"
onClick={props.onSeePlans}
>
<CreditCard />
{t('auto.components.billing.team.planBillingLink', 'Plan & billing')}
</Button>
) : null}
</header>
{props.arrival?.kind === 'checkout-success' && entitlement ? (
@@ -213,10 +199,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
{roster?.canManageMembers ? (
<TeamInviteComposer
viewerRole={viewerRole}
entitlement={entitlement}
members={roster.members.length}
pendingInvites={roster.pendingInvites.length}
now={props.now}
onInvite={props.onInvite}
inputRef={inviteInput}
/>
@@ -1,96 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
import type { Translate } from '@/i18n/use-translate'
import { sampleTeam } from '../plan-billing-fixtures'
import { formatCents } from '../plans/plans-pricing'
import { inviteSeatImpactLine } from './team-invite-impact-copy'
import { inviteSeatImpact } from './team-seat-math'
const DAY = 86_400_000
// Mirrors i18next's `_one` plural pick so both forms are checked.
const t: Translate = (_key, fallback, options) => {
const one = options?.count === 1 ? options.defaultValue_one : undefined
const text = typeof one === 'string' ? one : fallback
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options?.[name] ?? ''))
}
function team(overrides: Partial<OrcaBillingEntitlement> = {}): OrcaBillingEntitlement {
return {
...sampleTeam,
status: 'active',
interval: 'month',
seats: 3,
seatChange: null,
cancelAtPeriodEnd: false,
period: { start: 0, end: 30 * DAY },
...overrides
}
}
function line(
entitlement: OrcaBillingEntitlement | null,
counts: { members: number; pendingInvites: number; newInvites: number }
): string {
const impact = inviteSeatImpact({ entitlement, ...counts, now: 0 })
return inviteSeatImpactLine(impact, counts.newInvites, 'en-US', t)
}
const monthly = sampleTeam.offers?.team.monthlyPriceCentsPerSeat ?? 0
describe('invite seat impact line', () => {
it('says how many seats are open before anything is typed', () => {
expect(line(team({ seats: 5 }), { members: 2, pendingInvites: 1, newInvites: 0 })).toBe(
'Each invite uses a seat once accepted. You have 2 open seats.'
)
expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 0 })).toBe(
'Each invite uses a seat once accepted. You have no open seats.'
)
})
it('adds nothing while the invites fit in open seats', () => {
expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 1 })).toBe(
'Each invite uses a seat once accepted. You have 1 open seat, so no seats will be added.'
)
})
it('prices the added seats from billing, per period, prorated', () => {
const total = formatCents(2 * monthly, 'en-US')
expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
`Each invite uses a seat once accepted. You have no open seats, so 2 more seats (${total}/mo, prorated) will be added.`
)
})
it('counts open seats first, then pluralizes the added ones apart', () => {
const one = formatCents(monthly, 'en-US')
expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
`Each invite uses a seat once accepted. You have 1 open seat, so 1 more seat (${one}/mo, prorated) will be added.`
)
})
it('names the yearly price on annual plans', () => {
expect(
line(team({ seats: 1, interval: 'year' }), { members: 1, pendingInvites: 0, newInvites: 1 })
).toMatch(/\/yr, prorated\) will be added\.$/)
})
it('never shows a price billing did not send', () => {
expect(
line(team({ seats: 3, offers: null }), { members: 3, pendingInvites: 0, newInvites: 2 })
).toBe(
'Each invite uses a seat once accepted. You have no open seats, so 2 more seats will be added, prorated.'
)
})
it('keeps to the rule when billing is off or unreachable', () => {
expect(line(null, { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
'Each invite uses a seat once accepted.'
)
})
it('says invites are free before the team has a plan', () => {
expect(
line(team({ plan: null, status: 'none' }), { members: 1, pendingInvites: 0, newInvites: 1 })
).toBe('Invites are free. Seats are billed once your team picks a plan.')
})
})
@@ -1,86 +0,0 @@
// The one line under "Invite teammates" that says what the invites do to seats. Billing's own
// numbers only (ENTITLEMENTS-C): no price is shown when billing didn't send one.
import type { Translate } from '@/i18n/use-translate'
import { formatCents } from '../plans/plans-pricing'
import type { InviteSeatImpact } from './team-seat-math'
/** "You have N open seats", its own key so the seat count and the added count pluralize apart. */
function openSeatsClause(open: number, t: Translate): string {
return open === 0
? t('auto.components.billing.team.openSeatsNone', 'You have no open seats')
: t('auto.components.billing.team.openSeatsClause', 'You have {{count}} open seats', {
count: open,
defaultValue_one: 'You have {{count}} open seat'
})
}
export function inviteSeatImpactLine(
impact: InviteSeatImpact,
invites: number,
locale: string,
t: Translate
): string {
switch (impact.kind) {
case 'unknown':
return t(
'auto.components.billing.team.inviteSeatUnknown',
'Each invite uses a seat once accepted.'
)
case 'no-plan':
return t(
'auto.components.billing.team.inviteNoPlan',
'Invites are free. Seats are billed once your team picks a plan.'
)
case 'covered':
return invites === 0
? t(
'auto.components.billing.team.inviteSeatOpen',
'Each invite uses a seat once accepted. {{openSeats}}.',
{
openSeats: openSeatsClause(impact.freeSeats, t)
}
)
: t(
'auto.components.billing.team.inviteSeatCovered',
'Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.',
{ openSeats: openSeatsClause(impact.freeSeats, t) }
)
case 'adds-seats-unpriced':
return t(
'auto.components.billing.team.inviteSeatAddsUnpriced',
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.',
{
openSeats: openSeatsClause(impact.freeSeats, t),
count: impact.addedSeats,
defaultValue_one:
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.'
}
)
case 'adds-seats': {
const price = formatCents(impact.addedSeats * impact.perSeatPeriodCents, locale)
return impact.interval === 'year'
? t(
'auto.components.billing.team.inviteSeatAddsYear',
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.',
{
openSeats: openSeatsClause(impact.freeSeats, t),
count: impact.addedSeats,
price,
defaultValue_one:
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.'
}
)
: t(
'auto.components.billing.team.inviteSeatAddsMonth',
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.',
{
openSeats: openSeatsClause(impact.freeSeats, t),
count: impact.addedSeats,
price,
defaultValue_one:
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.'
}
)
}
}
}
@@ -1,17 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
import { sampleTeam as activeTeamEntitlement } from '../plan-billing-fixtures'
import { inviteSeatImpact, nextInvoiceEstimate, proratedSeatCents } from './team-seat-math'
import { nextInvoiceEstimate } from './team-seat-math'
const DAY = 86_400_000
const noPlanEntitlement: OrcaBillingEntitlement = {
...activeTeamEntitlement,
plan: null,
planLabel: null,
status: 'none',
interval: null,
period: null
}
function team(overrides: Partial<OrcaBillingEntitlement> = {}): OrcaBillingEntitlement {
return {
@@ -25,73 +17,6 @@ function team(overrides: Partial<OrcaBillingEntitlement> = {}): OrcaBillingEntit
}
}
describe('proratedSeatCents', () => {
it('charges the remaining share of the period', () => {
const pricing = { interval: 'month' as const, perSeatPeriodCents: 5000 }
expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, 15 * DAY)).toBe(2500)
expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, 31 * DAY)).toBe(0)
expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, -DAY)).toBe(5000)
})
})
describe('inviteSeatImpact', () => {
it('reports no plan when unsubscribed', () => {
expect(
inviteSeatImpact({
entitlement: noPlanEntitlement,
members: 1,
pendingInvites: 0,
newInvites: 2,
now: 0
})
).toEqual({ kind: 'no-plan' })
})
it('uses seats already paid this period first (peak seats)', () => {
// 3 paid, 1 member: two free seats, so two invites cost nothing.
expect(
inviteSeatImpact({
entitlement: team(),
members: 1,
pendingInvites: 0,
newInvites: 2,
now: 0
})
).toEqual({ kind: 'covered', freeSeats: 2 })
})
it('counts pending invites against free seats before new ones', () => {
const impact = inviteSeatImpact({
entitlement: team(),
members: 2,
pendingInvites: 1,
newInvites: 2,
now: 15 * DAY
})
expect(impact).toMatchObject({
kind: 'adds-seats',
addedSeats: 2,
coveredSeats: 0,
proratedCentsEach: 2500
})
})
})
describe('inviteSeatImpact without prices', () => {
it('still says seats are added (never "covered") when pricing is unknown', () => {
const unpriced = team({ offers: null })
expect(
inviteSeatImpact({
entitlement: unpriced,
members: 3,
pendingInvites: 0,
newInvites: 2,
now: 0
})
).toEqual({ kind: 'adds-seats-unpriced', addedSeats: 2, freeSeats: 0 })
})
})
describe('nextInvoiceEstimate', () => {
it('bills the renewal seat count, not the period peak', () => {
const estimate = nextInvoiceEstimate(
@@ -1,7 +1,6 @@
// Seat money shown on the Team page. Display estimates only: Stripe computes the real invoice.
// Mirrors the billing design: seats billed = the period's peak membership; a member who joins
// above the paid peak is charged now, prorated; removals only lower seats at renewal; pending
// invites cost nothing until accepted.
// Seat money shown in Plan & billing. Display estimates only: Stripe computes the real invoice.
// Mirrors the billing design: seats billed = the period's peak membership; removals only lower
// seats at renewal.
import type {
OrcaBillingEntitlement,
OrcaBillingInterval
@@ -33,80 +32,6 @@ export function isActivePlan(entitlement: OrcaBillingEntitlement | null): boolea
)
}
/** One seat for the rest of the current period, rounded to the cent like Stripe's proration. */
export function proratedSeatCents(
pricing: SeatPricing,
period: { start: number; end: number },
now: number
): number {
const length = period.end - period.start
if (length <= 0) {
return pricing.perSeatPeriodCents
}
const remaining = Math.min(Math.max(period.end - now, 0), length)
return Math.round((pricing.perSeatPeriodCents * remaining) / length)
}
export type InviteSeatImpact =
/** Billing didn't answer (disabled in this build, signed out or failed): no seat facts to show. */
| { kind: 'unknown' }
| { kind: 'no-plan' }
/** Every new invite fits in seats already paid this period. */
| { kind: 'covered'; freeSeats: number }
/** Seats will be added but the price or period isn't known (older billing service). */
| { kind: 'adds-seats-unpriced'; addedSeats: number; freeSeats: number }
| {
kind: 'adds-seats'
/** Paid seats not taken by a member or a pending invite. */
freeSeats: number
/** How many of the new invites would add a seat when accepted. */
addedSeats: number
/** Seats already paid but unused that the first acceptances take for free. */
coveredSeats: number
proratedCentsEach: number
perSeatPeriodCents: number
interval: OrcaBillingInterval
}
/**
* What inviting `newInvites` people costs if they all accept now. Pending invites are assumed to
* accept first (conservative: never promises a free seat that an earlier invite will take).
*/
export function inviteSeatImpact(args: {
entitlement: OrcaBillingEntitlement | null
members: number
pendingInvites: number
newInvites: number
now: number
}): InviteSeatImpact {
const { entitlement } = args
if (!entitlement) {
return { kind: 'unknown' }
}
if (!isActivePlan(entitlement)) {
return { kind: 'no-plan' }
}
const pricing = seatPricing(entitlement)
const paidSeats = Math.max(entitlement.seats, 1)
const freeSeats = Math.max(0, paidSeats - args.members - args.pendingInvites)
const addedSeats = Math.max(0, args.newInvites - freeSeats)
if (addedSeats === 0) {
return { kind: 'covered', freeSeats }
}
if (!pricing || !entitlement.period) {
return { kind: 'adds-seats-unpriced', addedSeats, freeSeats }
}
return {
kind: 'adds-seats',
freeSeats,
addedSeats,
coveredSeats: Math.min(freeSeats, args.newInvites),
proratedCentsEach: proratedSeatCents(pricing, entitlement.period, args.now),
perSeatPeriodCents: pricing.perSeatPeriodCents,
interval: pricing.interval
}
}
export type NextInvoiceEstimate = {
seats: number
amountCents: number
-8
View File
@@ -242,18 +242,10 @@
"blockedWorkspaces_one": "{{count}} cloud workspace still exists. Delete it first.",
"blockedWorkspaces_other": "{{count}} cloud workspaces still exist. Delete them first."
},
"inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.",
"inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.",
"inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.",
"inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.",
"inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.",
"inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.",
"invitedCount_one": "{{count}} invited",
"invitedCount_other": "{{count}} invited",
"memberCount_one": "{{count}} member",
"memberCount_other": "{{count}} members",
"openSeatsClause_one": "You have {{count}} open seat",
"openSeatsClause_other": "You have {{count}} open seats",
"seatCount_one": "{{count}} seat",
"seatCount_other": "{{count}} seats",
"sendInvites_one": "Send {{count}} invite",
+1 -15
View File
@@ -21254,7 +21254,6 @@
"dismiss": "Dismiss",
"enterEmail": "Enter a teammate’s email",
"inviteLabel": "Invite teammates",
"inviteNoPlan": "Invites are free. Seats are billed once your team picks a plan.",
"invitePlaceholder2": "alex@company.com, sam@company.com",
"inviteSentBody": "They join, and take a seat, when they accept.",
"inviteSentMany": "{{value0}} invites sent",
@@ -21310,7 +21309,6 @@
"createdNotSwitched": "{{value0}} was created, but Orca couldn’t switch to it.",
"invitedCount_one": "{{count}} invited",
"invitedCount_other": "{{count}} invited",
"invitePaymentFailed2": "New seats are charged once the failed payment is fixed.",
"memberCount_one": "{{count}} member",
"memberCount_other": "{{count}} members",
"seatCount_one": "{{count}} seat",
@@ -21351,22 +21349,10 @@
"deletedToast2": "{{value0}} was deleted"
},
"leaveLastOwnerOrDelete": "You’re the only owner. Make someone else an owner first, or delete the team.",
"planBillingLink": "Plan & billing",
"perSeat": "Per seat",
"perSeatMonthValue": "{{value0}}/mo",
"seatRulesShort": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.",
"openSeatsNone": "You have no open seats",
"openSeatsClause_one": "You have {{count}} open seat",
"openSeatsClause_other": "You have {{count}} open seats",
"inviteSeatUnknown": "Each invite uses a seat once accepted.",
"inviteSeatOpen": "Each invite uses a seat once accepted. {{openSeats}}.",
"inviteSeatCovered": "Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.",
"inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.",
"inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.",
"inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.",
"inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.",
"inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.",
"inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added."
"inviteSeatRule": "Each invite uses a seat once accepted."
},
"manageBilling": "Manage billing",
"comparePlans": "Compare plans",