| |
| |
| |
| |
| |
|
|
| import { escapeHtml } from '@/utils/sanitize'; |
| import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; |
| import { getAuthState } from '@/services/auth-state'; |
| import { showToast } from '@/utils/toast'; |
| import { |
| getSubscription, |
| listBusinessSeats, |
| inviteBusinessSeats, |
| removeBusinessSeat, |
| type BusinessSeat, |
| } from '@/services/billing'; |
|
|
| export class BusinessSeatsSection { |
| private seats: BusinessSeat[] = []; |
| private loading = false; |
| private error = ''; |
| private ownerDomain: string | null = null; |
| |
| |
| |
| private ownerIsCorporateDomain = true; |
| private accountGeneration = 0; |
| |
| |
| |
| private readonly removingGrantIds = new Set<string>(); |
|
|
| |
| constructor(private readonly overlay: HTMLElement) {} |
|
|
| resetForAccountChange(): void { |
| this.accountGeneration += 1; |
| this.seats = []; |
| this.loading = false; |
| this.error = ''; |
| this.ownerDomain = null; |
| this.ownerIsCorporateDomain = true; |
| this.removingGrantIds.clear(); |
| this.renderInPlace(); |
| } |
|
|
| async load(): Promise<void> { |
| if (this.loading) return; |
| const generation = this.accountGeneration; |
| this.loading = true; |
| this.error = ''; |
| try { |
| const result = await listBusinessSeats(); |
| if (generation !== this.accountGeneration) return; |
| this.seats = result.seats; |
| this.ownerDomain = result.ownerDomain; |
| this.ownerIsCorporateDomain = result.ownerIsCorporateDomain; |
| } catch (err) { |
| if (generation !== this.accountGeneration) return; |
| this.error = err instanceof Error ? err.message : 'Failed to load seats'; |
| } finally { |
| if (generation === this.accountGeneration) { |
| this.loading = false; |
| this.renderInPlace(); |
| } |
| } |
| } |
|
|
| renderInPlace(): void { |
| const container = this.overlay.querySelector('#usBusinessSeats'); |
| if (container) { |
| setTrustedHtml(container, trustedHtml(this.renderContent(), 'legacy direct innerHTML migration')); |
| } |
| } |
|
|
| renderContent(): string { |
| const sub = getSubscription(); |
| const authUser = getAuthState().user; |
| const ownerEmail = authUser?.email ?? ''; |
| |
| |
| |
| |
| const ownerDomain = this.ownerDomain ?? ownerEmail.split('@')[1]?.toLowerCase() ?? ''; |
| const isBusinessOwner = sub?.planKey === 'api_business' && sub?.status === 'active'; |
| const isCorporateDomain = this.ownerIsCorporateDomain; |
|
|
| if (!isBusinessOwner) return ''; |
|
|
| const seats = this.seats; |
| const activeSeats = seats.filter((s) => s.status === 'accepted'); |
| const pendingSeats = seats.filter((s) => s.status === 'pending'); |
| const seatCount = activeSeats.length + pendingSeats.length; |
|
|
| const renderSeat = (seat: BusinessSeat) => { |
| const statusLabel = seat.status === 'accepted' ? 'Accepted' : seat.status === 'expired' ? 'Expired' : 'Pending'; |
| const statusColor = seat.status === 'accepted' ? '#22c55e' : seat.status === 'expired' ? '#666' : '#eab308'; |
| const expires = seat.status === 'pending' |
| ? `<div style="font-size:11px;color:#666;">Expires ${new Date(seat.expiresAt).toLocaleDateString()}</div>` |
| : ''; |
| return ` |
| <div class="business-seat-item" style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid #1a1a1a;border-radius:6px;margin-bottom:8px;background:#0d0d0d;"> |
| <div> |
| <div style="font-size:13px;color:#fff;">${escapeHtml(seat.inviteeEmail)}</div> |
| <div style="display:flex;align-items:center;gap:6px;margin-top:4px;"> |
| <span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${statusColor};"></span> |
| <span style="font-size:11px;color:${statusColor};">${statusLabel}</span> |
| </div> |
| ${expires} |
| </div> |
| <button class="btn btn-ghost business-seat-remove-btn" data-grant-id="${escapeHtml(seat.grantId)}" style="font-size:12px;" ${this.removingGrantIds.has(seat.grantId) ? 'disabled' : ''}>${this.removingGrantIds.has(seat.grantId) ? 'Removing...' : 'Remove'}</button> |
| </div> |
| `; |
| }; |
|
|
| const inviteHint = isCorporateDomain |
| ? `Invite teammates on your company domain (@${escapeHtml(ownerDomain)}).` |
| : 'Add a company email to invite teammates.'; |
|
|
| return ` |
| <div class="business-seats-section" style="margin-top:16px;padding:14px 16px;border:1px solid #1a1a1a;border-radius:6px;background:#0d0d0d;"> |
| <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;"> |
| <div style="font-size:13px;font-weight:600;color:#fff;">Business Seats</div> |
| <div style="font-size:11px;color:#888;">${seatCount} / 4 used</div> |
| </div> |
| <div style="font-size:12px;color:#999;margin-bottom:12px;">${escapeHtml(inviteHint)}</div> |
| ${!isCorporateDomain ? `<div style="font-size:12px;color:#ef4444;margin-bottom:12px;">Free or disposable email domains cannot invite teammates. Add a company email to use this feature.</div>` : ''} |
| ${isCorporateDomain ? ` |
| <div class="business-seats-invite-form" style="display:flex;gap:8px;margin-bottom:12px;"> |
| <input type="email" class="business-seats-email-input" placeholder="teammate@${escapeHtml(ownerDomain)}" style="flex:1;padding:8px 10px;background:#111;border:1px solid #1a1a1a;border-radius:4px;color:#fff;font-size:13px;" ${seatCount >= 4 ? 'disabled' : ''} /> |
| <button class="btn btn-primary business-seats-invite-btn" ${seatCount >= 4 ? 'disabled' : ''}>Invite</button> |
| </div> |
| ` : ''} |
| ${this.error ? `<div style="font-size:12px;color:#ef4444;margin-bottom:12px;">${escapeHtml(this.error)}</div>` : ''} |
| <div id="usBusinessSeatsList"> |
| ${seats.length === 0 ? `<div style="font-size:12px;color:#666;padding:12px;text-align:center;">No seats invited yet.</div>` : seats.map(renderSeat).join('')} |
| </div> |
| </div> |
| `; |
| } |
|
|
| async handleInvite(): Promise<void> { |
| const generation = this.accountGeneration; |
| const input = this.overlay.querySelector<HTMLInputElement>('.business-seats-email-input'); |
| const btn = this.overlay.querySelector<HTMLButtonElement>('.business-seats-invite-btn'); |
| const email = input?.value.trim(); |
| if (!email || !btn || btn.disabled) return; |
|
|
| btn.disabled = true; |
| btn.textContent = 'Inviting...'; |
| this.error = ''; |
| try { |
| const result = await inviteBusinessSeats([email]); |
| if (generation !== this.accountGeneration) return; |
| if (result.invited[0]?.status === 'created') { |
| showToast('Invite sent'); |
| } else { |
| showToast('Already invited'); |
| } |
| await this.load(); |
| } catch (err) { |
| if (generation !== this.accountGeneration) return; |
| const msg = err instanceof Error ? err.message : 'Failed to send invite'; |
| if (msg.includes('SEAT_CAP_REACHED')) { |
| this.error = 'All 4 seats are used. Remove a seat first.'; |
| } else if (msg.includes('INVITEE_DOMAIN_MISMATCH')) { |
| this.error = 'Invitee must share your company email domain.'; |
| } else if (msg.includes('OWNER_DOMAIN_NOT_CORPORATE')) { |
| this.error = 'Your account email must be a company domain to invite teammates.'; |
| } else if (msg.includes('CANNOT_INVITE_SELF')) { |
| this.error = 'You cannot invite yourself.'; |
| } else if (msg.includes('INVITEE_DOMAIN_NOT_CORPORATE')) { |
| this.error = 'The invitee email must be a company domain.'; |
| } else { |
| this.error = msg; |
| } |
| |
| |
| this.renderInPlace(); |
| } |
| } |
|
|
| async handleRemove(grantId: string): Promise<void> { |
| const generation = this.accountGeneration; |
| if (this.removingGrantIds.has(grantId)) return; |
| if (!confirm('Remove this seat? The invitee will lose Pro access immediately.')) return; |
|
|
| this.removingGrantIds.add(grantId); |
| this.renderInPlace(); |
| try { |
| const result = await removeBusinessSeat(grantId); |
| if (generation !== this.accountGeneration) return; |
| showToast(result.status === 'already_inactive' ? 'Seat was already inactive' : 'Seat removed'); |
| await this.load(); |
| } catch (err) { |
| if (generation !== this.accountGeneration) return; |
| this.error = err instanceof Error ? err.message : 'Failed to remove seat'; |
| this.renderInPlace(); |
| } finally { |
| if (generation === this.accountGeneration) { |
| this.removingGrantIds.delete(grantId); |
| this.renderInPlace(); |
| } |
| } |
| } |
| } |
|
|