diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 22956b38a27..62b1716c0b8 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -89,6 +89,7 @@ export const accountCapabilities = [ 'Object Storage', 'Placement Group', 'SMTP Enabled', + 'Support Live Chat', 'Support Ticket Severity', 'Vlans', 'VPCs', diff --git a/packages/api-v4/src/support/support.ts b/packages/api-v4/src/support/support.ts index 16bdb2cce90..7a4f0cbe945 100644 --- a/packages/api-v4/src/support/support.ts +++ b/packages/api-v4/src/support/support.ts @@ -3,7 +3,7 @@ import { createSupportTicketSchema, } from '@linode/validation/lib/support.schema'; -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, @@ -152,3 +152,24 @@ export const uploadAttachment = (ticketId: number, formData: FormData) => setMethod('POST'), setData(formData), ); + +/** + * Recive Support liveChat Token + * @param TokenID { String } the ID of the token to be retrieved + */ + +export const getLiveChatToken = (params?: Params, filter?: Filter) => + Request<{ token: string }>( + setURL(`${BETA_API_ROOT}/support/chat_token`), + setMethod('GET'), + setParams(params), + setXFilter(filter), + ); + +export const getLiveChatStatus = (params?: Params, filter?: Filter) => + Request<{ live_chat_available: string }>( + setURL(`${BETA_API_ROOT}/support/live_chat_available/`), + setMethod('GET'), + setParams(params), + setXFilter(filter), + ); diff --git a/packages/manager/.env.example b/packages/manager/.env.example index 30cf48a207f..7a566cf14a5 100644 --- a/packages/manager/.env.example +++ b/packages/manager/.env.example @@ -24,6 +24,13 @@ REACT_APP_APP_ROOT='http://localhost:3000' # Pendo: # REACT_APP_PENDO_API_KEY= +# Embedded live chat URLs: +# REACT_APP_CHAT_DEPLOYMENT_URL= +# REACT_APP_CHAT_SCRT2_URL= +# REACT_APP_CHAT_BOOTSTRAP_JS_URL= +# REACT_APP_CHAT_ORG_ID= +# REACT_APP_CHAT_DEPLOYMENT_NAME= + # Linode Docs search with Algolia: # REACT_APP_ALGOLIA_APPLICATION_ID= # REACT_APP_ALGOLIA_SEARCH_KEY= diff --git a/packages/manager/index.html b/packages/manager/index.html index ab73eab7b33..c62e1514381 100644 --- a/packages/manager/index.html +++ b/packages/manager/index.html @@ -1,13 +1,245 @@ - - - - - Akamai Cloud Manager - - -
- - - + + + + + + Akamai Cloud Manager + + + + +
+ + + + + \ No newline at end of file diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index a5cf596c319..3ca82db1496 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -41,6 +41,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'limitsEvolution', label: 'Limits Evolution' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, + { flag: 'liveChat', label: 'Live Chat' }, { flag: 'lkeEnterprise2', label: 'LKE-Enterprise' }, { flag: 'marketplaceV2', label: 'MarketplaceV2' }, { flag: 'networkLoadBalancer', label: 'Network Load Balancer' }, diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index de029b12345..514de605d2e 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -54,6 +54,7 @@ export const accountFactory = Factory.Sync.makeFactory({ 'Object Storage Endpoint Types', 'Object Storage', 'Placement Group', + 'Support Live Chat', 'Vlans', 'Kubernetes Enterprise', 'VPC Dual Stack', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 8c24c608d0d..2778f3f88d6 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -253,6 +253,7 @@ export interface Flags { linodeCreateBanner: LinodeCreateBanner; linodeDiskEncryption: boolean; linodeInterfaces: LinodeInterfacesFlag; + liveChat: boolean; lkeEnterprise2: LkeEnterpriseFlag; mainContentBanner: MainContentBanner; marketplaceAppOverrides: MarketplaceAppOverride[]; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index d762204f00d..d8a7c9d8374 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -1,5 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { uploadAttachment } from '@linode/api-v4/lib/support'; +import { getLiveChatToken, uploadAttachment } from '@linode/api-v4/lib/support'; import { useCreateSupportTicketMutation } from '@linode/queries'; import { Accordion, @@ -12,7 +12,7 @@ import { Typography, } from '@linode/ui'; import { reduceAsync, scrollErrorIntoViewV2 } from '@linode/utilities'; -import { useLocation } from '@tanstack/react-router'; +import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import type { JSX } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; @@ -37,7 +37,11 @@ import { import { SupportTicketAccountLimitFields } from './SupportTicketAccountLimitFields'; import { SupportTicketProductSelectionFields } from './SupportTicketProductSelectionFields'; import { SupportTicketSMTPFields } from './SupportTicketSMTPFields'; -import { formatDescription, useTicketSeverityCapability } from './ticketUtils'; +import { + formatDescription, + useLiveChatCapability, + useTicketSeverityCapability, +} from './ticketUtils'; import type { FileAttachment } from '../index'; import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; @@ -118,7 +122,10 @@ export interface SupportTicketFormFields { export interface SupportTicketLocationState { description?: SupportTicketDialogProps['prefilledDescription']; entity?: SupportTicketDialogProps['prefilledEntity']; + entityInputValue?: SupportTicketFormFields['entityInputValue']; + entityType?: SupportTicketFormFields['entityType']; formPayloadValues?: SupportTicketFormFields['formPayloadValues']; + liveChatDisabled?: boolean; ticketType?: SupportTicketDialogProps['prefilledTicketType']; title?: SupportTicketDialogProps['prefilledTitle']; } @@ -149,7 +156,11 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { } = props; const location = useLocation(); + const navigate = useNavigate(); const locationState = location.state as SupportTicketLocationState; + const liveChatEnabled = useLiveChatCapability(); + const liveChat = liveChatEnabled && !locationState?.liveChatDisabled; + const showLiveChatFallbackWarning = Boolean(locationState?.liveChatDisabled); // Collect prefilled data from props or Link parameters. const _prefilledDescription: string | undefined = @@ -160,6 +171,10 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { prefilledTitle ?? locationState?.title ?? undefined; const prefilledFormPayloadValues: FormPayloadValues | undefined = locationState?.formPayloadValues ?? undefined; + const prefilledEntityType: EntityType | undefined = + locationState?.entityType ?? undefined; + const prefilledEntityInputValue: string | undefined = + locationState?.entityInputValue ?? undefined; const _prefilledTicketType: TicketType | undefined = prefilledTicketType ?? locationState?.ticketType ?? undefined; @@ -184,8 +199,13 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { valuesFromStorage.description ), entityId: _prefilledEntity?.id ? String(_prefilledEntity.id) : '', - entityInputValue: '', - entityType: _prefilledEntity?.type ?? 'general', + entityInputValue: prefilledEntityInputValue ?? '', + entityType: + _prefilledEntity?.type ?? + prefilledEntityType ?? + (valuesFromStorage.entityType === 'general' + ? 'none' + : (valuesFromStorage.entityType ?? 'none')), summary: getInitialValue(newPrefilledTitle, valuesFromStorage.summary), ticketType: _prefilledTicketType ?? 'general', }, @@ -195,12 +215,21 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { const { description, entityId, + entityInputValue, entityType, selectedSeverity, summary, ticketType, } = form.watch(); + const [liveChatFailed, setLiveChatFailed] = React.useState(false); + + const isAccountBillingTopic = + liveChat && + !liveChatFailed && + entityType === 'general' && + entityInputValue !== 'general'; + const { mutateAsync: createSupportTicket } = useCreateSupportTicketMutation(); const [files, setFiles] = React.useState([]); @@ -229,7 +258,14 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { React.useEffect(() => { // Store in-progress work to localStorage debouncedSave(form.getValues()); - }, [summary, description, entityId, entityType, selectedSeverity]); + }, [ + summary, + description, + entityId, + entityInputValue, + entityType, + selectedSeverity, + ]); /** * Clear the dialog completely if clearValues is passed (when canceling out of the dialog or successfully submitting) @@ -241,7 +277,11 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { description: clearValues ? '' : valuesFromStorage.description, entityId: clearValues ? '' : valuesFromStorage.entityId, entityInputValue: clearValues ? '' : valuesFromStorage.entityInputValue, - entityType: clearValues ? 'general' : valuesFromStorage.entityType, + entityType: clearValues + ? 'none' + : valuesFromStorage.entityType === 'general' + ? 'none' + : valuesFromStorage.entityType, selectedSeverity: clearValues ? undefined : valuesFromStorage.selectedSeverity, @@ -251,12 +291,13 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { }; const resetDialog = (clearValues: boolean = false) => { - resetTicket(clearValues); - setFiles([]); - if (clearValues) { + debouncedSave.cancel(); saveFormData(supportTicketStorageDefaults); } + + resetTicket(clearValues); + setFiles([]); }; const handleClose = () => { @@ -277,6 +318,51 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { setFiles(newFiles); }; + const handleStartLiveChat = async () => { + setSubmitting(true); + + try { + const response = (await getLiveChatToken()) as { + chat_token?: string; + data?: { chat_token?: string }; + }; + + const token = response?.chat_token ?? response?.data?.chat_token; + + if (!token) { + form.setError('root', { + message: + 'Unable to start live chat because no chat token was returned.', + }); + return; + } + + window.sessionStorage.setItem('LiveChatToken', token); + window.sessionStorage.setItem('LiveChatSubject', summary); + window.sessionStorage.setItem('LiveChatDescription', description); + window.sessionStorage.setItem('EnableLiveChat', 'true'); + + // Navigate first so listeners on /support can receive the event reliably. + await navigate({ to: '/support' }); + window.dispatchEvent(new Event('manager:enable-live-chat')); + + props.onClose(); + window.setTimeout(() => resetDialog(true), 500); + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response + ?.status; + if (status === 500) { + setLiveChatFailed(true); + } else { + form.setError('root', { + message: 'Unable to start live chat. Please try again.', + }); + } + } finally { + setSubmitting(false); + } + }; + /* Reducer passed into reduceAsync (previously Bluebird.reduce) below. * Unfortunately, this reducer has side effects. Uploads each file and accumulates a list of * any upload errors. Also tracks loading state of each individual file. */ @@ -349,6 +435,18 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { const handleSubmit = form.handleSubmit(async (values) => { const { onSuccess } = props; + if (values.entityType === 'none') { + form.setError('entityType', { + message: 'Please select a topic.', + }); + return; + } + + if (isAccountBillingTopic) { + await handleStartLiveChat(); + return; + } + const _description = formatDescription(values, ticketType); // If this is an account limit ticket, we needed the entity type but won't actually send a valid entity selection. @@ -459,7 +557,7 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { /> )} /> - {hasSeverityCapability && ( + {hasSeverityCapability && !isAccountBillingTopic && ( { )} {(!ticketType || ticketType === 'general') && ( <> + {showLiveChatFallbackWarning && ( + + )} {props.hideProductSelection ? null : ( - + )} - - ( - + + ( + + )} /> - )} - /> - - ({ mt: `${theme.spacing(0.5)} !important` })} // forcefully disable margin when accordion is expanded - > - - - + + ({ mt: `${theme.spacing(0.5)} !important` })} // forcefully disable margin when accordion is expanded + > + + + + + )} {form.formState.errors.root && ( { { - const { ticketType } = props; + const { liveChat, ticketType } = props; const { clearErrors, control, @@ -203,20 +210,33 @@ export const SupportTicketProductSelectionFields = (props: Props) => { (thisEntity) => String(thisEntity.value) === entityId ) || null; - const renderEntityTypes = () => { + const renderEntityTypes = (): TopicOption[] => { return Object.keys(ENTITY_MAP).map((key: string) => { return { label: key, value: ENTITY_MAP[key] }; }); }; - const topicOptions: { label: string; value: EntityType }[] = [ - { label: 'General/Account/Billing', value: 'general' }, + const topicOptions: TopicOption[] = [ + { + label: 'Account/Billing', + topicVariant: 'accountBilling', + value: 'general', + }, + { label: 'General', topicVariant: 'general', value: 'general' }, ...renderEntityTypes(), ]; - const selectedTopic = topicOptions.find((eachTopic) => { - return eachTopic.value === entityType; - }); + topicOptions.sort((a, b) => a.label.localeCompare(b.label)); + + const selectedGeneralTopicVariant = + entityInputValue === 'general' ? 'general' : 'accountBilling'; + + const selectedTopic = + entityType === 'general' + ? topicOptions.find( + (eachTopic) => eachTopic.topicVariant === selectedGeneralTopicVariant + ) + : topicOptions.find((eachTopic) => eachTopic.value === entityType); const _entityType = getEntityNameFromEntityType(entityType, true); @@ -248,22 +268,61 @@ export const SupportTicketProductSelectionFields = (props: Props) => { ( + render={({ field, fieldState }) => ( { + const currentTopicVariant = + entityType === 'general' + ? selectedGeneralTopicVariant + : undefined; + // Don't reset things if the type hasn't changed. - if (type.value === entityType) { + if ( + type?.value === entityType && + (type?.value !== 'general' || + type.topicVariant === currentTopicVariant) + ) { return; } - field.onChange(type.value); + field.onChange(type?.value ?? 'none'); setValue('entityId', ''); - setValue('entityInputValue', ''); + setValue( + 'entityInputValue', + type?.value === 'general' && type.topicVariant === 'general' + ? 'general' + : type?.value === 'general' && + type.topicVariant === 'accountBilling' + ? 'accountBilling' + : '' + ); clearErrors('entityId'); }} options={topicOptions} + placeholder="Select an option?" + renderOption={ + liveChat + ? (props, option) => ( +
  • + + {option.label} + {option.topicVariant === 'accountBilling' && ( + + )} + +
  • + ) + : undefined + } value={selectedTopic} /> )} diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx index f4e766fb8b4..88df6744911 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx @@ -87,6 +87,14 @@ export const SupportTicketsLanding = () => { navigate({ to: '/support/tickets', search: { dialogOpen: false }, + state: (prev) => ({ + ...prev, + description: undefined, + entityInputValue: undefined, + entityType: undefined, + liveChatDisabled: undefined, + title: undefined, + }), }) } onSuccess={handleAddTicketSuccess} diff --git a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts index 9838dac4b34..a1d96846cb8 100644 --- a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts +++ b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts @@ -1,6 +1,6 @@ import { getTickets } from '@linode/api-v4/lib/support'; import { useAccount } from '@linode/queries'; -import { isFeatureEnabled } from '@linode/utilities'; +import { isFeatureEnabled, isFeatureEnabledV2 } from '@linode/utilities'; import { useFlags } from 'src/hooks/useFlags'; @@ -74,6 +74,17 @@ export const useTicketSeverityCapability = () => { ); }; +export const useLiveChatCapability = () => { + const flags = useFlags(); + const { data: account } = useAccount(); + + return isFeatureEnabledV2( + 'Support Live Chat', + Boolean(flags.liveChat), + account?.capabilities ?? [] + ); +}; + /** * formatDescription * diff --git a/packages/manager/src/routes/support/SupportRoute.tsx b/packages/manager/src/routes/support/SupportRoute.tsx index fee1de52d15..4ac74bf7adc 100644 --- a/packages/manager/src/routes/support/SupportRoute.tsx +++ b/packages/manager/src/routes/support/SupportRoute.tsx @@ -1,10 +1,41 @@ -import { Outlet } from '@tanstack/react-router'; +import { Outlet, useNavigate } from '@tanstack/react-router'; import React from 'react'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { StatusBanners } from 'src/features/Help/StatusBanners'; export const SupportTicketsRoute = () => { + const navigate = useNavigate(); + + React.useEffect(() => { + const handleLiveChatFailed = (event: Event) => { + const { description, subject } = + (event as CustomEvent<{ description: string; subject: string }>) + .detail ?? {}; + + navigate({ + to: '/support/tickets', + search: { dialogOpen: true }, + state: (prev) => ({ + ...prev, + description: description || undefined, + entityInputValue: 'accountBilling', + entityType: 'general', + liveChatDisabled: true, + title: subject || undefined, + }), + }); + }; + + window.addEventListener('manager:live-chat-failed', handleLiveChatFailed); + return () => { + window.removeEventListener( + 'manager:live-chat-failed', + handleLiveChatFailed + ); + }; + }, [navigate]); + return ( }> diff --git a/packages/manager/src/utilities/storage.ts b/packages/manager/src/utilities/storage.ts index 80ec7e62a7d..c93e6c1d4c9 100644 --- a/packages/manager/src/utilities/storage.ts +++ b/packages/manager/src/utilities/storage.ts @@ -93,7 +93,7 @@ export const supportTicketStorageDefaults: SupportTicketFormFields = { description: '', entityId: '', entityInputValue: '', - entityType: 'general', + entityType: 'none', selectedSeverity: undefined, summary: '', ticketType: 'general',