chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
acceptInvitationContract,
|
||||
getInvitationContract,
|
||||
type InvitationDetails,
|
||||
} from '@/lib/api/contracts/invitations'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('InviteById')
|
||||
|
||||
type InviteErrorCode =
|
||||
| 'missing-token'
|
||||
| 'invalid-token'
|
||||
| 'expired'
|
||||
| 'already-processed'
|
||||
| 'email-mismatch'
|
||||
| 'workspace-not-found'
|
||||
| 'user-not-found'
|
||||
| 'already-member'
|
||||
| 'already-in-organization'
|
||||
| 'no-seats-available'
|
||||
| 'upgrade-required'
|
||||
| 'invalid-invitation'
|
||||
| 'missing-invitation-id'
|
||||
| 'server-error'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'network-error'
|
||||
| 'unknown'
|
||||
|
||||
interface InviteError {
|
||||
code: InviteErrorCode
|
||||
message: string
|
||||
requiresAuth?: boolean
|
||||
canRetry?: boolean
|
||||
}
|
||||
|
||||
function getInviteError(code: string): InviteError {
|
||||
const errorMap: Record<string, InviteError> = {
|
||||
'missing-token': {
|
||||
code: 'missing-token',
|
||||
message: 'The invitation link is invalid or missing a required parameter.',
|
||||
},
|
||||
'invalid-token': {
|
||||
code: 'invalid-token',
|
||||
message: 'The invitation link is invalid or has already been used.',
|
||||
},
|
||||
expired: {
|
||||
code: 'expired',
|
||||
message: 'This invitation has expired. Please ask for a new invitation.',
|
||||
},
|
||||
'already-processed': {
|
||||
code: 'already-processed',
|
||||
message: 'This invitation has already been accepted or declined.',
|
||||
},
|
||||
'email-mismatch': {
|
||||
code: 'email-mismatch',
|
||||
message:
|
||||
'This invitation was sent to a different email address. Please sign in with the correct account.',
|
||||
requiresAuth: true,
|
||||
},
|
||||
'workspace-not-found': {
|
||||
code: 'workspace-not-found',
|
||||
message: 'The workspace associated with this invitation could not be found.',
|
||||
},
|
||||
'user-not-found': {
|
||||
code: 'user-not-found',
|
||||
message: 'Your user account could not be found. Please try signing out and signing back in.',
|
||||
requiresAuth: true,
|
||||
},
|
||||
'already-member': {
|
||||
code: 'already-member',
|
||||
message: 'You are already a member of this organization or workspace.',
|
||||
},
|
||||
'already-in-organization': {
|
||||
code: 'already-in-organization',
|
||||
message:
|
||||
'You are already a member of an organization. Leave your current organization before accepting a new invitation.',
|
||||
},
|
||||
'no-seats-available': {
|
||||
code: 'no-seats-available',
|
||||
message:
|
||||
'This organization has reached its seat limit. Ask an admin to contact support to add seats, then try again.',
|
||||
canRetry: true,
|
||||
},
|
||||
'upgrade-required': {
|
||||
code: 'upgrade-required',
|
||||
message:
|
||||
'The workspace owner needs an active paid plan with billing set up before you can join. Ask them to update their plan, then try again.',
|
||||
canRetry: true,
|
||||
},
|
||||
'invalid-invitation': {
|
||||
code: 'invalid-invitation',
|
||||
message: 'This invitation is invalid or no longer exists.',
|
||||
},
|
||||
'not-found': {
|
||||
code: 'invalid-invitation',
|
||||
message: 'This invitation is invalid or no longer exists.',
|
||||
},
|
||||
'server-error': {
|
||||
code: 'server-error',
|
||||
message:
|
||||
'An unexpected error occurred while processing your invitation. Please try again later.',
|
||||
canRetry: true,
|
||||
},
|
||||
unauthorized: {
|
||||
code: 'unauthorized',
|
||||
message: 'You need to sign in to accept this invitation.',
|
||||
requiresAuth: true,
|
||||
},
|
||||
forbidden: {
|
||||
code: 'forbidden',
|
||||
message:
|
||||
'You do not have permission to accept this invitation. Please check you are signed in with the correct account.',
|
||||
requiresAuth: true,
|
||||
},
|
||||
'network-error': {
|
||||
code: 'network-error',
|
||||
message:
|
||||
'Unable to connect to the server. Please check your internet connection and try again.',
|
||||
canRetry: true,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
errorMap[code] || {
|
||||
code: 'unknown',
|
||||
message:
|
||||
'An unexpected error occurred while processing your invitation. Please try again or contact support.',
|
||||
canRetry: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function codeFromStatus(status: number): InviteErrorCode {
|
||||
if (status === 401) return 'unauthorized'
|
||||
if (status === 403) return 'forbidden'
|
||||
if (status === 404) return 'invalid-invitation'
|
||||
if (status === 409) return 'already-in-organization'
|
||||
if (status >= 500) return 'server-error'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function codeFromApiClientError(error: ApiClientError): string {
|
||||
if (error.body && typeof error.body === 'object') {
|
||||
const code = (error.body as { error?: unknown }).error
|
||||
if (typeof code === 'string' && code.length > 0) return code
|
||||
}
|
||||
|
||||
return codeFromStatus(error.status)
|
||||
}
|
||||
|
||||
export default function Invite() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const inviteId = params.id as string
|
||||
const inviteTokenStorageKey = `inviteToken:${inviteId}`
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const [invitation, setInvitation] = useState<InvitationDetails | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<InviteError | null>(null)
|
||||
const [isAccepting, setIsAccepting] = useState(false)
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [isNewUser, setIsNewUser] = useState(false)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const errorReason = searchParams.get('error')
|
||||
const isNew = searchParams.get('new') === 'true'
|
||||
setIsNewUser(isNew)
|
||||
|
||||
const tokenFromQuery = searchParams.get('token')
|
||||
if (tokenFromQuery) {
|
||||
setToken(tokenFromQuery)
|
||||
sessionStorage.setItem(inviteTokenStorageKey, tokenFromQuery)
|
||||
} else {
|
||||
const storedToken = sessionStorage.getItem(inviteTokenStorageKey)
|
||||
if (storedToken) {
|
||||
setToken(storedToken)
|
||||
}
|
||||
}
|
||||
|
||||
if (errorReason) {
|
||||
setError(getInviteError(errorReason))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [searchParams, inviteId, inviteTokenStorageKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user) return
|
||||
|
||||
async function fetchInvitation() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await requestJson(getInvitationContract, {
|
||||
params: { id: inviteId },
|
||||
query: { token: token ?? undefined },
|
||||
})
|
||||
setInvitation(data.invitation)
|
||||
setError(null)
|
||||
} catch (fetchError) {
|
||||
logger.error('Error fetching invitation:', fetchError)
|
||||
const code =
|
||||
fetchError instanceof ApiClientError
|
||||
? codeFromApiClientError(fetchError)
|
||||
: 'network-error'
|
||||
setError(getInviteError(code))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchInvitation()
|
||||
}, [session?.user, inviteId, token])
|
||||
|
||||
const handleAcceptInvitation = async () => {
|
||||
if (!session?.user || !invitation) return
|
||||
setIsAccepting(true)
|
||||
|
||||
try {
|
||||
const data = await requestJson(acceptInvitationContract, {
|
||||
params: { id: inviteId },
|
||||
body: { token: token ?? undefined },
|
||||
})
|
||||
|
||||
if (invitation.organizationId) {
|
||||
try {
|
||||
await client.organization.setActive({ organizationId: invitation.organizationId })
|
||||
} catch (setActiveError) {
|
||||
logger.warn('Failed to set active organization after accept', setActiveError)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.all }),
|
||||
])
|
||||
|
||||
setAccepted(true)
|
||||
setIsAccepting(false)
|
||||
|
||||
setTimeout(() => router.push(data.redirectPath), 1200)
|
||||
} catch (acceptError) {
|
||||
logger.error('Error accepting invitation:', acceptError)
|
||||
const code =
|
||||
acceptError instanceof ApiClientError
|
||||
? codeFromApiClientError(acceptError)
|
||||
: 'network-error'
|
||||
setError(getInviteError(code))
|
||||
setIsAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getCallbackUrl = () => {
|
||||
const effectiveToken =
|
||||
token || sessionStorage.getItem(inviteTokenStorageKey) || searchParams.get('token')
|
||||
return `/invite/${inviteId}${effectiveToken ? `?token=${effectiveToken}` : ''}`
|
||||
}
|
||||
|
||||
if (!session?.user && !isPending) {
|
||||
const callbackUrl = encodeURIComponent(getCallbackUrl())
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='login'
|
||||
title="You've been invited!"
|
||||
description={
|
||||
isNewUser
|
||||
? 'Create an account to join this workspace on Sim'
|
||||
: 'Sign in to your account to accept this invitation'
|
||||
}
|
||||
icon='userPlus'
|
||||
actions={[
|
||||
...(isNewUser
|
||||
? [
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
{
|
||||
label: 'I already have an account',
|
||||
onClick: () =>
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Sign in',
|
||||
onClick: () =>
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
|
||||
},
|
||||
]),
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading || isPending) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard type='loading' title='' description='Loading invitation...' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const callbackUrl = encodeURIComponent(getCallbackUrl())
|
||||
|
||||
if (error.code === 'email-mismatch') {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='warning'
|
||||
title='Wrong Account'
|
||||
description={error.message}
|
||||
icon='userPlus'
|
||||
actions={[
|
||||
{
|
||||
label: 'Sign in with a different account',
|
||||
onClick: async () => {
|
||||
await client.signOut()
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)
|
||||
},
|
||||
},
|
||||
{ label: 'Return to Home', onClick: () => router.push('/') },
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error.code === 'already-in-organization') {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='warning'
|
||||
title='Already Part of a Team'
|
||||
description={error.message}
|
||||
icon='users'
|
||||
actions={[
|
||||
{ label: 'Manage Team Settings', onClick: () => router.push('/workspace') },
|
||||
{ label: 'Return to Home', onClick: () => router.push('/') },
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error.requiresAuth) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='warning'
|
||||
title='Authentication Required'
|
||||
description={error.message}
|
||||
icon='userPlus'
|
||||
actions={[
|
||||
{
|
||||
label: 'Sign in to continue',
|
||||
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
{ label: 'Return to Home', onClick: () => router.push('/') },
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const actions: Array<{ label: string; onClick: () => void }> = []
|
||||
if (error.canRetry) {
|
||||
actions.push({ label: 'Try Again', onClick: () => window.location.reload() })
|
||||
}
|
||||
actions.push({ label: 'Return to Home', onClick: () => router.push('/') })
|
||||
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='error'
|
||||
title='Invitation Error'
|
||||
description={error.message}
|
||||
icon='error'
|
||||
isExpiredError={error.code === 'expired'}
|
||||
actions={actions}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const displayName =
|
||||
invitation?.kind === 'workspace'
|
||||
? invitation.grants[0]?.workspaceName || 'a workspace'
|
||||
: invitation?.organizationName || 'an organization'
|
||||
|
||||
if (accepted) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='success'
|
||||
title='Welcome!'
|
||||
description={`You have successfully joined ${displayName}. Redirecting...`}
|
||||
icon='success'
|
||||
actions={[{ label: 'Return to Home', onClick: () => router.push('/') }]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const isOrg = invitation?.kind === 'organization'
|
||||
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='invitation'
|
||||
title={isOrg ? 'Organization Invitation' : 'Workspace Invitation'}
|
||||
description={`You've been invited to join ${displayName}. Click accept below to join.`}
|
||||
icon={isOrg ? 'users' : 'mail'}
|
||||
actions={[
|
||||
{
|
||||
label: 'Accept Invitation',
|
||||
onClick: handleAcceptInvitation,
|
||||
disabled: isAccepting,
|
||||
loading: isAccepting,
|
||||
},
|
||||
{ label: 'Return to Home', onClick: () => router.push('/') },
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function InviteLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<Skeleton className='size-[48px] rounded-[12px]' />
|
||||
<Skeleton className='mt-[16px] h-[24px] w-[200px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[8px] h-[14px] w-[280px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[4px] h-[14px] w-[240px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[24px] h-[44px] w-[200px] rounded-[10px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import Invite from '@/app/invite/[id]/invite'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Invite',
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function InvitePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Invite />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as InviteLayout } from './layout'
|
||||
export { InviteStatusCard } from './status-card'
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AuthShell, SupportFooter } from '@/app/(auth)/components'
|
||||
|
||||
interface InviteLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite pages wear the same light auth shell as login/signup — the shared
|
||||
* {@link AuthShell} (logo-only header, centered column) plus the support footer —
|
||||
* so the invite-to-workspace flow is visually aligned with the rest of auth.
|
||||
*/
|
||||
export default function InviteLayout({ children }: InviteLayoutProps) {
|
||||
return <AuthShell footer={<SupportFooter position='static' />}>{children}</AuthShell>
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
import { Chip, cn, Loader } from '@sim/emcn'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthSubmitButton } from '@/app/(auth)/components'
|
||||
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
|
||||
|
||||
interface InviteStatusCardProps {
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
|
||||
title: string
|
||||
description: string | React.ReactNode
|
||||
icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success' | 'warning'
|
||||
actions?: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>
|
||||
isExpiredError?: boolean
|
||||
}
|
||||
|
||||
const EMPTY_ACTIONS: NonNullable<InviteStatusCardProps['actions']> = []
|
||||
|
||||
export function InviteStatusCard({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
icon: _icon,
|
||||
actions = EMPTY_ACTIONS,
|
||||
isExpiredError = false,
|
||||
}: InviteStatusCardProps) {
|
||||
const router = useRouter()
|
||||
|
||||
if (type === 'loading') {
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[500] text-[32px] text-[var(--text-primary)] tracking-tight'>
|
||||
Loading
|
||||
</h1>
|
||||
<p className='font-[380] text-[var(--text-muted)]'>{description}</p>
|
||||
</div>
|
||||
<div className='mt-8 flex w-full items-center justify-center py-8'>
|
||||
<Loader className='size-8 text-[var(--text-muted)]' animate />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='font-[500] text-[32px] text-[var(--text-primary)] tracking-tight'>
|
||||
{title}
|
||||
</h1>
|
||||
<p className='font-[380] text-[var(--text-muted)]'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 w-full max-w-[410px] space-y-3'>
|
||||
{isExpiredError && (
|
||||
<AuthSubmitButton type='button' onClick={() => router.push('/')} loadingLabel=''>
|
||||
Request New Invitation
|
||||
</AuthSubmitButton>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) =>
|
||||
index === 0 ? (
|
||||
<AuthSubmitButton
|
||||
key={action.label}
|
||||
type='button'
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
loading={action.loading}
|
||||
loadingLabel={`${action.label}...`}
|
||||
>
|
||||
{action.label}
|
||||
</AuthSubmitButton>
|
||||
) : (
|
||||
<Chip
|
||||
key={action.label}
|
||||
fullWidth
|
||||
flush
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
|
||||
>
|
||||
{action.loading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='size-4' animate />
|
||||
{action.label}...
|
||||
</span>
|
||||
) : (
|
||||
action.label
|
||||
)}
|
||||
</Chip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user