OrganizationLayout.tsx181 lines · main
| 1 | import { LOCAL_STORAGE_KEYS } from 'common' |
| 2 | import { ExternalLink, XIcon } from 'lucide-react' |
| 3 | import Head from 'next/head' |
| 4 | import { useRouter } from 'next/router' |
| 5 | import type { PropsWithChildren } from 'react' |
| 6 | import { Alert, AlertDescription, AlertTitle, Button, cn } from 'ui' |
| 7 | |
| 8 | import { useRegisterOrgMenu } from './OrganizationLayout/useRegisterOrgMenu' |
| 9 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 10 | import PartnerIcon from '@/components/ui/PartnerIcon' |
| 11 | import { useAwsRedirectQuery } from '@/data/integrations/aws-redirect-query' |
| 12 | import { useVercelRedirectQuery } from '@/data/integrations/vercel-redirect-query' |
| 13 | import { useCustomContent } from '@/hooks/custom-content/useCustomContent' |
| 14 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 15 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 16 | import { withAuth } from '@/hooks/misc/withAuth' |
| 17 | import { MANAGED_BY } from '@/lib/constants/infrastructure' |
| 18 | import { buildStudioPageTitle } from '@/lib/page-title' |
| 19 | |
| 20 | interface OrganizationLayoutProps { |
| 21 | title: string |
| 22 | } |
| 23 | |
| 24 | // [Joshen] Just for page title generation for org settings pages |
| 25 | const settingsPages = ['general', 'security', 'sso', 'apps', 'audit', 'documents'] |
| 26 | |
| 27 | type MarketplaceBannerRedirectSource = 'vercel' | 'aws' |
| 28 | |
| 29 | type MarketplaceBannerConfig = { |
| 30 | title: string |
| 31 | description: string |
| 32 | redirectSource?: MarketplaceBannerRedirectSource |
| 33 | } |
| 34 | |
| 35 | const MARKETPLACE_BANNER_CONFIG: Record< |
| 36 | | typeof MANAGED_BY.VERCEL_MARKETPLACE |
| 37 | | typeof MANAGED_BY.AWS_MARKETPLACE |
| 38 | | typeof MANAGED_BY.STRIPE_PROJECTS, |
| 39 | MarketplaceBannerConfig |
| 40 | > = { |
| 41 | [MANAGED_BY.VERCEL_MARKETPLACE]: { |
| 42 | title: 'This organization is managed via Vercel Marketplace', |
| 43 | description: 'Billing and some organization access settings are managed in Vercel.', |
| 44 | redirectSource: 'vercel', |
| 45 | }, |
| 46 | [MANAGED_BY.AWS_MARKETPLACE]: { |
| 47 | title: 'This organization is billed via AWS Marketplace', |
| 48 | description: 'Changes to billing and payment details must be made in AWS.', |
| 49 | redirectSource: 'aws', |
| 50 | }, |
| 51 | [MANAGED_BY.STRIPE_PROJECTS]: { |
| 52 | title: 'This organization is connected to Stripe', |
| 53 | description: 'Changes here will be reflected in your connected Stripe account.', |
| 54 | }, |
| 55 | } |
| 56 | |
| 57 | const DEFAULT_ORGANIZATION_MARKETPLACE_BANNER_DISMISS_KEY = |
| 58 | LOCAL_STORAGE_KEYS.ORGANIZATION_MARKETPLACE_BANNER_DISMISSED('unknown', MANAGED_BY.BRIVEN) |
| 59 | |
| 60 | function getMarketplaceBannerDismissKey({ |
| 61 | organizationSlug, |
| 62 | managedBy, |
| 63 | }: { |
| 64 | organizationSlug?: string |
| 65 | managedBy?: string |
| 66 | }) { |
| 67 | if (!organizationSlug || !managedBy) return DEFAULT_ORGANIZATION_MARKETPLACE_BANNER_DISMISS_KEY |
| 68 | |
| 69 | return LOCAL_STORAGE_KEYS.ORGANIZATION_MARKETPLACE_BANNER_DISMISSED(organizationSlug, managedBy) |
| 70 | } |
| 71 | |
| 72 | function getMarketplaceBannerConfig(managedBy?: string): MarketplaceBannerConfig | undefined { |
| 73 | switch (managedBy) { |
| 74 | case MANAGED_BY.VERCEL_MARKETPLACE: |
| 75 | return MARKETPLACE_BANNER_CONFIG[MANAGED_BY.VERCEL_MARKETPLACE] |
| 76 | case MANAGED_BY.AWS_MARKETPLACE: |
| 77 | return MARKETPLACE_BANNER_CONFIG[MANAGED_BY.AWS_MARKETPLACE] |
| 78 | case MANAGED_BY.STRIPE_PROJECTS: |
| 79 | return MARKETPLACE_BANNER_CONFIG[MANAGED_BY.STRIPE_PROJECTS] |
| 80 | default: |
| 81 | return undefined |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | const OrganizationLayoutContent = ({ |
| 86 | children, |
| 87 | title, |
| 88 | }: PropsWithChildren<OrganizationLayoutProps>) => { |
| 89 | const router = useRouter() |
| 90 | const { data: selectedOrganization } = useSelectedOrganizationQuery() |
| 91 | const { appTitle } = useCustomContent(['app:title']) |
| 92 | const [isBannerDismissed, setIsBannerDismissed] = useLocalStorageQuery<boolean>( |
| 93 | getMarketplaceBannerDismissKey({ |
| 94 | organizationSlug: selectedOrganization?.slug, |
| 95 | managedBy: selectedOrganization?.managed_by, |
| 96 | }), |
| 97 | false |
| 98 | ) |
| 99 | |
| 100 | // Keep title intent close to each page (getLayout) to avoid route-to-title drift in this layout. |
| 101 | const isSettingsSurface = settingsPages.some((x) => router.pathname.endsWith(x)) |
| 102 | const pageTitle = buildStudioPageTitle({ |
| 103 | section: title, |
| 104 | surface: isSettingsSurface ? 'Organization Settings' : undefined, |
| 105 | org: selectedOrganization?.name, |
| 106 | brand: appTitle || 'Briven', |
| 107 | }) |
| 108 | |
| 109 | const vercelQuery = useVercelRedirectQuery( |
| 110 | { installationId: selectedOrganization?.partner_id }, |
| 111 | { enabled: selectedOrganization?.managed_by === MANAGED_BY.VERCEL_MARKETPLACE } |
| 112 | ) |
| 113 | |
| 114 | const awsQuery = useAwsRedirectQuery( |
| 115 | { organizationSlug: selectedOrganization?.slug }, |
| 116 | { enabled: selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE } |
| 117 | ) |
| 118 | |
| 119 | const bannerConfig = getMarketplaceBannerConfig(selectedOrganization?.managed_by) |
| 120 | |
| 121 | const selectedRedirectQuery = (() => { |
| 122 | if (!bannerConfig?.redirectSource) return undefined |
| 123 | |
| 124 | switch (bannerConfig.redirectSource) { |
| 125 | case 'aws': |
| 126 | return awsQuery |
| 127 | case 'vercel': |
| 128 | return vercelQuery |
| 129 | default: |
| 130 | return undefined |
| 131 | } |
| 132 | })() |
| 133 | |
| 134 | return ( |
| 135 | <div className={cn('h-full w-full flex flex-col overflow-hidden')}> |
| 136 | {pageTitle && ( |
| 137 | <Head> |
| 138 | <title>{pageTitle}</title> |
| 139 | <meta name="description" content="Briven Studio" /> |
| 140 | </Head> |
| 141 | )} |
| 142 | {selectedOrganization && bannerConfig && !isBannerDismissed && ( |
| 143 | <Alert |
| 144 | variant="default" |
| 145 | className="flex items-center gap-4 border-t-0 border-x-0 rounded-none" |
| 146 | > |
| 147 | <PartnerIcon organization={selectedOrganization} showTooltip={false} size="medium" /> |
| 148 | <div className="flex-1"> |
| 149 | <AlertTitle>{bannerConfig.title}</AlertTitle> |
| 150 | <AlertDescription>{bannerConfig.description}</AlertDescription> |
| 151 | </div> |
| 152 | <div className="flex items-center gap-2"> |
| 153 | {selectedRedirectQuery?.data?.url && ( |
| 154 | <Button asChild type="default" iconRight={<ExternalLink />}> |
| 155 | <a href={selectedRedirectQuery.data.url} target="_blank" rel="noopener noreferrer"> |
| 156 | Manage |
| 157 | </a> |
| 158 | </Button> |
| 159 | )} |
| 160 | <ButtonTooltip |
| 161 | type="text" |
| 162 | icon={<XIcon size={14} />} |
| 163 | className="h-7 w-7 p-0" |
| 164 | onClick={() => setIsBannerDismissed(true)} |
| 165 | aria-label="Dismiss banner" |
| 166 | tooltip={{ content: { text: 'Dismiss' } }} |
| 167 | /> |
| 168 | </div> |
| 169 | </Alert> |
| 170 | )} |
| 171 | <main className="h-full w-full overflow-y-auto flex flex-col">{children}</main> |
| 172 | </div> |
| 173 | ) |
| 174 | } |
| 175 | |
| 176 | const OrganizationLayout = ({ children, title }: PropsWithChildren<OrganizationLayoutProps>) => { |
| 177 | useRegisterOrgMenu() |
| 178 | return <OrganizationLayoutContent title={title}>{children}</OrganizationLayoutContent> |
| 179 | } |
| 180 | |
| 181 | export default withAuth(OrganizationLayout) |