refactor(portal): partner-mode customer switcher on real tenants
Migrate the partner-mode customer switcher, in-customer banner, sidebar tile and the team invite/teammate panels off the data/customers fixture onto the real /api/partner/tenants list (shared key, gated to partner-staff so the global shell doesn't 403 for other users). Active customer resolves by tenant _id (the key the customers page already passes to partnerMode.enter); partner-identity labels now use the real partner name from useMe. Removes the now-unused customers + CustomerOrg-list fixture export and the dead setCustomer helper. Verified in UI: switcher + enter/exit show real Baslund Test / Baslund Research ApS.
This commit is contained in:
@@ -3,13 +3,22 @@
|
|||||||
// specific customer org. Distinct color (indigo — partner mode is normal
|
// specific customer org. Distinct color (indigo — partner mode is normal
|
||||||
// operating mode, not danger). Persistent until partner exits.
|
// operating mode, not danger). Persistent until partner exits.
|
||||||
|
|
||||||
import { customers } from '~/data/customers'
|
import type { PartnerTenantDoc } from '~/types/partner'
|
||||||
|
|
||||||
const partnerMode = usePartnerMode()
|
const partnerMode = usePartnerMode()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { partner, isPartnerStaff } = useMe()
|
||||||
|
|
||||||
|
// Real tenant list (shared key; gated to partner-staff so the layout-level
|
||||||
|
// banner doesn't 403 the partner endpoint for other users).
|
||||||
|
const { data: tenants } = useFetch<PartnerTenantDoc[]>('/api/partner/tenants', {
|
||||||
|
key: 'partner-tenants',
|
||||||
|
default: () => [],
|
||||||
|
immediate: isPartnerStaff.value,
|
||||||
|
})
|
||||||
|
|
||||||
const activeCustomer = computed(() =>
|
const activeCustomer = computed(() =>
|
||||||
customers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
|
(tenants.value ?? []).find((c) => c._id === partnerMode.activeCustomerId.value) || null,
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => partnerMode.hydrate())
|
onMounted(() => partnerMode.hydrate())
|
||||||
@@ -25,7 +34,7 @@ function exit() {
|
|||||||
<span class="dot" />
|
<span class="dot" />
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<Mono>Partner view</Mono>
|
<Mono>Partner view</Mono>
|
||||||
<span class="text">managing <strong>{{ activeCustomer.name }}</strong> · actions are attributed to NordicMSP in the customer's audit log</span>
|
<span class="text">managing <strong>{{ activeCustomer.name }}</strong> · actions are attributed to {{ partner?.name ?? 'your partner org' }} in the customer's audit log</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="exit" @click="exit">
|
<button class="exit" @click="exit">
|
||||||
<UiIcon name="logout" :size="12" />
|
<UiIcon name="logout" :size="12" />
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// source design.
|
// source design.
|
||||||
|
|
||||||
import type { IconName } from './UiIcon.vue'
|
import type { IconName } from './UiIcon.vue'
|
||||||
import { customers as fixtureCustomers } from '~/data/customers'
|
import type { PartnerTenantDoc } from '~/types/partner'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -130,11 +130,6 @@ const currentId = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Customer currently being acted-as (partner-in-customer mode)
|
|
||||||
const activeCustomer = computed(() =>
|
|
||||||
fixtureCustomers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Workspace-switcher content matches the URL section.
|
// Workspace-switcher content matches the URL section.
|
||||||
type SwitcherKind = 'customer' | 'partner' | 'in-customer'
|
type SwitcherKind = 'customer' | 'partner' | 'in-customer'
|
||||||
const switcherKind = computed<SwitcherKind>(() => {
|
const switcherKind = computed<SwitcherKind>(() => {
|
||||||
@@ -154,12 +149,18 @@ function exitCustomer() {
|
|||||||
// hitting a 403 against the partner-scoped endpoint. useFetch with a stable
|
// hitting a 403 against the partner-scoped endpoint. useFetch with a stable
|
||||||
// key dedupes with the /partner/customers page's request.
|
// key dedupes with the /partner/customers page's request.
|
||||||
const { partner, isPartnerStaff } = useMe()
|
const { partner, isPartnerStaff } = useMe()
|
||||||
const { data: partnerTenants } = await useFetch<unknown[]>('/api/partner/tenants', {
|
const { data: partnerTenants } = await useFetch<PartnerTenantDoc[]>('/api/partner/tenants', {
|
||||||
key: 'partner-tenants',
|
key: 'partner-tenants',
|
||||||
default: () => [],
|
default: () => [],
|
||||||
immediate: isPartnerStaff.value,
|
immediate: isPartnerStaff.value,
|
||||||
})
|
})
|
||||||
const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
|
const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
|
||||||
|
|
||||||
|
// Customer currently being acted-as (partner-in-customer mode), resolved from
|
||||||
|
// the real tenant list by the _id stored in partner mode.
|
||||||
|
const activeCustomer = computed(() =>
|
||||||
|
(partnerTenants.value ?? []).find((c) => c._id === partnerMode.activeCustomerId.value) || null,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -193,7 +194,7 @@ const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
|
|||||||
<span class="ws-tile" :style="{ background: activeCustomer?.brandColor || '#0A0A0A' }" />
|
<span class="ws-tile" :style="{ background: activeCustomer?.brandColor || '#0A0A0A' }" />
|
||||||
<div v-if="!collapsed" class="ws-text">
|
<div v-if="!collapsed" class="ws-text">
|
||||||
<div class="ws-name">{{ activeCustomer?.name }}</div>
|
<div class="ws-name">{{ activeCustomer?.name }}</div>
|
||||||
<div class="ws-sub mono">via NordicMSP</div>
|
<div class="ws-sub mono">via {{ partner?.name ?? 'partner' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,25 @@
|
|||||||
// Portal topbar: workspace label, optional org switcher (partner admins), global
|
// Portal topbar: workspace label, optional org switcher (partner admins), global
|
||||||
// search, app launcher, notifications, profile menu.
|
// search, app launcher, notifications, profile menu.
|
||||||
|
|
||||||
import { customers } from '~/data/customers'
|
import type { PartnerTenantDoc } from '~/types/partner'
|
||||||
|
|
||||||
const launcher = useAppLauncher()
|
const launcher = useAppLauncher()
|
||||||
const drawer = useNotificationDrawer()
|
const drawer = useNotificationDrawer()
|
||||||
const partnerMode = usePartnerMode()
|
const partnerMode = usePartnerMode()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { partner, isPartnerStaff } = useMe()
|
||||||
|
|
||||||
|
// Real customer tenants drive the org switcher. Shared 'partner-tenants' key
|
||||||
|
// dedupes with the sidebar + customers page; gated to partner-staff so the
|
||||||
|
// global shell doesn't 403 the partner-scoped endpoint for end-users/admins.
|
||||||
|
const { data: tenants } = useFetch<PartnerTenantDoc[]>('/api/partner/tenants', {
|
||||||
|
key: 'partner-tenants',
|
||||||
|
default: () => [],
|
||||||
|
immediate: isPartnerStaff.value,
|
||||||
|
})
|
||||||
|
const PLAN_LABEL: Record<string, string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
|
||||||
|
const partnerLabel = computed(() => partner.value?.name ?? 'Partner')
|
||||||
|
|
||||||
// Section context is URL-driven (same rule as the sidebar). The org switcher
|
// Section context is URL-driven (same rule as the sidebar). The org switcher
|
||||||
// only appears in the partner section or when acting-as a customer.
|
// only appears in the partner section or when acting-as a customer.
|
||||||
@@ -24,7 +36,7 @@ const showOrgSwitcher = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const activeCustomer = computed(() =>
|
const activeCustomer = computed(() =>
|
||||||
customers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
|
(tenants.value ?? []).find((c) => c._id === partnerMode.activeCustomerId.value) || null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const orgSwitcherOpen = ref(false)
|
const orgSwitcherOpen = ref(false)
|
||||||
@@ -57,7 +69,7 @@ const searchValue = ref('')
|
|||||||
>
|
>
|
||||||
{{ (activeCustomer?.name || 'NordicMSP').slice(0, 1) }}
|
{{ (activeCustomer?.name || 'NordicMSP').slice(0, 1) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="org-name">{{ activeCustomer?.name || 'NordicMSP · Partner view' }}</span>
|
<span class="org-name">{{ activeCustomer?.name || `${partnerLabel} · Partner view` }}</span>
|
||||||
<UiIcon name="chevDown" :size="12" />
|
<UiIcon name="chevDown" :size="12" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -89,10 +101,10 @@ const searchValue = ref('')
|
|||||||
<div v-if="orgSwitcherOpen" class="org-drop-scrim" @click="orgSwitcherOpen = false" />
|
<div v-if="orgSwitcherOpen" class="org-drop-scrim" @click="orgSwitcherOpen = false" />
|
||||||
<div v-if="orgSwitcherOpen" class="org-drop">
|
<div v-if="orgSwitcherOpen" class="org-drop">
|
||||||
<div class="org-drop-head">
|
<div class="org-drop-head">
|
||||||
<Eyebrow>NordicMSP · {{ customers.length }} customers</Eyebrow>
|
<Eyebrow>{{ partnerLabel }} · {{ (tenants?.length ?? 0) }} customers</Eyebrow>
|
||||||
</div>
|
</div>
|
||||||
<button class="org-drop-row" :class="{ on: !partnerMode.isActive.value }" @click="leaveCustomerMode">
|
<button class="org-drop-row" :class="{ on: !partnerMode.isActive.value }" @click="leaveCustomerMode">
|
||||||
<span class="org-drop-chip" style="background: #0A0A0A">N</span>
|
<span class="org-drop-chip" style="background: #0A0A0A">{{ partnerLabel.charAt(0).toUpperCase() }}</span>
|
||||||
<div class="org-drop-meta">
|
<div class="org-drop-meta">
|
||||||
<div class="org-drop-name">Partner view</div>
|
<div class="org-drop-name">Partner view</div>
|
||||||
<Mono dim>portfolio overview</Mono>
|
<Mono dim>portfolio overview</Mono>
|
||||||
@@ -100,16 +112,16 @@ const searchValue = ref('')
|
|||||||
</button>
|
</button>
|
||||||
<div class="org-drop-divider" />
|
<div class="org-drop-divider" />
|
||||||
<button
|
<button
|
||||||
v-for="c in customers"
|
v-for="c in tenants"
|
||||||
:key="c.id"
|
:key="c._id"
|
||||||
class="org-drop-row"
|
class="org-drop-row"
|
||||||
:class="{ on: partnerMode.activeCustomerId.value === c.id }"
|
:class="{ on: partnerMode.activeCustomerId.value === c._id }"
|
||||||
@click="pickCustomer(c.id)"
|
@click="pickCustomer(c._id)"
|
||||||
>
|
>
|
||||||
<span class="org-drop-chip" :style="{ background: c.brandColor }">{{ c.name.slice(0, 1) }}</span>
|
<span class="org-drop-chip" :style="{ background: c.brandColor || '#0A0A0A' }">{{ c.name.slice(0, 1) }}</span>
|
||||||
<div class="org-drop-meta">
|
<div class="org-drop-meta">
|
||||||
<div class="org-drop-name">{{ c.name }}</div>
|
<div class="org-drop-name">{{ c.name }}</div>
|
||||||
<Mono dim>{{ c.domain }} · {{ c.plan }}</Mono>
|
<Mono dim>{{ c.domains?.[0] || c.slug }} · {{ PLAN_LABEL[c.plan ?? 'pro'] }}</Mono>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
// scoping + require-MFA toggle + optional personal note. Invitations expire
|
// scoping + require-MFA toggle + optional personal note. Invitations expire
|
||||||
// after 7 days — the design surfaces that explicitly.
|
// after 7 days — the design surfaces that explicitly.
|
||||||
|
|
||||||
import { customers } from '~/data/customers'
|
const { tenants } = usePartnerTenants()
|
||||||
|
const PLAN_LABEL: Record<string, string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
|
||||||
|
|
||||||
defineProps<{ open: boolean }>()
|
defineProps<{ open: boolean }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -115,18 +116,18 @@ function planBadgeTone(p: string) {
|
|||||||
|
|
||||||
<div v-if="access === 'specific'" class="picker">
|
<div v-if="access === 'specific'" class="picker">
|
||||||
<div class="picker-head">
|
<div class="picker-head">
|
||||||
<Mono dim>{{ specific.length }} of {{ customers.length }} selected</Mono>
|
<Mono dim>{{ specific.length }} of {{ tenants.length }} selected</Mono>
|
||||||
</div>
|
</div>
|
||||||
<div class="picker-list">
|
<div class="picker-list">
|
||||||
<label v-for="c in customers" :key="c.id" class="picker-row">
|
<label v-for="c in tenants" :key="c._id" class="picker-row">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="specific.includes(c.id)"
|
:checked="specific.includes(c.slug)"
|
||||||
@change="toggleCustomer(c.id)"
|
@change="toggleCustomer(c.slug)"
|
||||||
/>
|
/>
|
||||||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
<div class="cust-swatch" :style="{ background: c.brandColor || '#0A0A0A' }" />
|
||||||
<span class="cust-name">{{ c.name }}</span>
|
<span class="cust-name">{{ c.name }}</span>
|
||||||
<Badge :tone="planBadgeTone(c.plan)">{{ c.planLabel }}</Badge>
|
<Badge :tone="planBadgeTone(c.plan ?? 'pro')">{{ PLAN_LABEL[c.plan ?? 'pro'] }}</Badge>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
// • Activity — last 5 partner actions with timestamps + IPs
|
// • Activity — last 5 partner actions with timestamps + IPs
|
||||||
// • Security — MFA card, active sessions, API tokens, suspend callout
|
// • Security — MFA card, active sessions, API tokens, suspend callout
|
||||||
|
|
||||||
import { customers } from '~/data/customers'
|
const { tenants } = usePartnerTenants()
|
||||||
|
const PLAN_LABEL: Record<string, string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
|
||||||
|
|
||||||
export interface TeamMember {
|
export interface TeamMember {
|
||||||
id: string
|
id: string
|
||||||
@@ -59,10 +60,10 @@ const isOwner = computed(() => !!props.member?.isOwner)
|
|||||||
|
|
||||||
const accessText = computed(() => {
|
const accessText = computed(() => {
|
||||||
if (!props.member) return ''
|
if (!props.member) return ''
|
||||||
if (props.member.access === 'all') return `all (${customers.length})`
|
const total = tenants.value?.length ?? 0
|
||||||
|
if (props.member.access === 'all') return `all (${total})`
|
||||||
if (props.member.access === 'none') return 'no access'
|
if (props.member.access === 'none') return 'no access'
|
||||||
// Specific: just say first N customers
|
return `${props.member.accessCount ?? 0} of ${total}`
|
||||||
return `${customers.length - 5} of ${customers.length}`
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -131,14 +132,14 @@ const accessText = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="ac-list">
|
<div class="ac-list">
|
||||||
<div
|
<div
|
||||||
v-for="c in customers.slice(0, member.access === 'all' ? customers.length : 3)"
|
v-for="c in tenants.slice(0, member.access === 'all' ? tenants.length : 3)"
|
||||||
:key="c.id"
|
:key="c._id"
|
||||||
class="ac-row"
|
class="ac-row"
|
||||||
>
|
>
|
||||||
<UiIcon name="check" :size="11" :stroke-width="2.5" />
|
<UiIcon name="check" :size="11" :stroke-width="2.5" />
|
||||||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
<div class="cust-swatch" :style="{ background: c.brandColor || '#0A0A0A' }" />
|
||||||
<span class="cust-name">{{ c.name }}</span>
|
<span class="cust-name">{{ c.name }}</span>
|
||||||
<Mono dim>{{ c.planLabel }}</Mono>
|
<Mono dim>{{ PLAN_LABEL[c.plan ?? 'pro'] }}</Mono>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
//
|
//
|
||||||
// In real use, every action while in this mode is logged with the partner's
|
// In real use, every action while in this mode is logged with the partner's
|
||||||
// identity (not the customer's) — the design spec is explicit about this for
|
// identity (not the customer's) — the design spec is explicit about this for
|
||||||
// trust. For the prototype we just hold the customer id.
|
// trust. We hold the active customer's tenant _id (the same key the customers
|
||||||
|
// page passes to enter()); consumers resolve it against the real tenant list.
|
||||||
import type { CustomerOrg } from '~/types/partner'
|
|
||||||
|
|
||||||
const activeCustomerId = ref<string | null>(null)
|
const activeCustomerId = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -34,8 +33,5 @@ export const usePartnerMode = () => {
|
|||||||
enter,
|
enter,
|
||||||
exit,
|
exit,
|
||||||
hydrate,
|
hydrate,
|
||||||
setCustomer: (c: CustomerOrg | null) => {
|
|
||||||
activeCustomerId.value = c?.id ?? null
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
// Partner-admin portfolio fixtures. The partner (NordicMSP) manages 8 customer
|
// Remaining partner fixtures. `partner` (dashboard fallback identity) and
|
||||||
// orgs. Numbers seeded to match partner-screens.jsx (the canonical design
|
// `partnerMrrSparkline` (decorative dashboard/reports sparkline) are the last
|
||||||
// source) line for line: same customer set, same MRR, seats, status, mark.
|
// two holdouts — kept until the dashboard partner header and a real historical
|
||||||
|
// MRR series replace them. The customer list now comes from /api/partner/tenants.
|
||||||
// Types moved to ~/types/partner so the fixture *data* below can be deleted
|
|
||||||
// page-by-page (as each goes real) without breaking type-only importers.
|
|
||||||
import type { CustomerOrg } from '~/types/partner'
|
|
||||||
|
|
||||||
export const partner = {
|
export const partner = {
|
||||||
id: 'p-nordicmsp',
|
id: 'p-nordicmsp',
|
||||||
@@ -18,19 +15,6 @@ export const partner = {
|
|||||||
founded: '2024',
|
founded: '2024',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer set mirrors partner-screens.jsx line 16-25 exactly.
|
|
||||||
// Health values derived from status + seat utilization (lower for past-due / attention).
|
|
||||||
export const customers: CustomerOrg[] = [
|
|
||||||
{ id: 'c-acme', name: 'Acme Workspace', domain: 'acme.dk', plan: 'business', planLabel: 'Business', seats: { used: 24, total: 50 }, health: 88, status: 'healthy', mrrDkk: 4840, brandColor: '#3F6BFF', industry: 'SaaS', createdOn: '2026-02-04', since: 'Feb 2026' },
|
|
||||||
{ id: 'c-bygherre', name: 'Bygherre Cloud', domain: 'bygherre.dk', plan: 'business', planLabel: 'Business', seats: { used: 12, total: 15 }, health: 38, status: 'past_due', mrrDkk: 2940, brandColor: '#E89A1F', industry: 'Construction', createdOn: '2026-03-12', since: 'Mar 2026' },
|
|
||||||
{ id: 'c-vester', name: 'Vester Foods', domain: 'vesterfoods.dk', plan: 'starter', planLabel: 'Starter', seats: { used: 8, total: 10 }, health: 82, status: 'healthy', mrrDkk: 980, brandColor: '#5B8C5A', industry: 'Food', createdOn: '2026-04-08', since: 'Apr 2026' },
|
|
||||||
{ id: 'c-aalborg', name: 'Aalborg Logistik', domain: 'aalborg-log.dk', plan: 'enterprise', planLabel: 'Enterprise', seats: { used: 87, total: 100 }, health: 78, status: 'healthy', mrrDkk: 14500, brandColor: '#0A0A0A', industry: 'Logistics', createdOn: '2025-09-04', since: 'Sep 2025' },
|
|
||||||
{ id: 'c-norrebro', name: 'Nørrebro Studio', domain: 'nbstudio.dk', plan: 'business', planLabel: 'Business', seats: { used: 6, total: 15 }, health: 68, status: 'trial', mrrDkk: 0, brandColor: '#FF6B4A', industry: 'Creative', createdOn: '2026-05-12', since: '12 May 2026' },
|
|
||||||
{ id: 'c-vsk', name: 'Vestsjælland Kommune', domain: 'vsk.dk', plan: 'enterprise', planLabel: 'Enterprise', seats: { used: 142, total: 200 }, health: 91, status: 'healthy', mrrDkk: 28400, brandColor: '#5B3F7A', industry: 'Public sector', createdOn: '2024-11-20', since: 'Nov 2024' },
|
|
||||||
{ id: 'c-broson', name: 'Bro & Søn ApS', domain: 'broson.dk', plan: 'starter', planLabel: 'Starter', seats: { used: 4, total: 10 }, health: 86, status: 'healthy', mrrDkk: 490, brandColor: '#3D3D38', industry: 'Retail', createdOn: '2025-06-15', since: 'Jun 2025' },
|
|
||||||
{ id: 'c-henriksen', name: 'Henriksen Revision', domain: 'h-revision.dk', plan: 'business', planLabel: 'Business', seats: { used: 18, total: 25 }, health: 58, status: 'attention', mrrDkk: 3600, brandColor: '#B85C38', industry: 'Accounting', createdOn: '2026-01-08', since: 'Jan 2026' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 90-day MRR sparkline · matches the synthetic generator at partner-screens.jsx:198.
|
// 90-day MRR sparkline · matches the synthetic generator at partner-screens.jsx:198.
|
||||||
// Deterministic seeded values (no Math.random calls each render).
|
// Deterministic seeded values (no Math.random calls each render).
|
||||||
export const partnerMrrSparkline = [
|
export const partnerMrrSparkline = [
|
||||||
|
|||||||
Reference in New Issue
Block a user