accountSubscriptions.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment-timezone';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {DateTime} from 'sentry/components/dateTime';
  6. import EmptyMessage from 'sentry/components/emptyMessage';
  7. import LoadingError from 'sentry/components/loadingError';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import Panel from 'sentry/components/panels/panel';
  10. import PanelBody from 'sentry/components/panels/panelBody';
  11. import PanelHeader from 'sentry/components/panels/panelHeader';
  12. import PanelItem from 'sentry/components/panels/panelItem';
  13. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  14. import Switch from 'sentry/components/switchButton';
  15. import {IconToggle} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import {
  19. setApiQueryData,
  20. useApiQuery,
  21. useMutation,
  22. useQueryClient,
  23. } from 'sentry/utils/queryClient';
  24. import useApi from 'sentry/utils/useApi';
  25. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  26. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  27. const ENDPOINT = '/users/me/subscriptions/';
  28. export type Subscription = {
  29. email: string;
  30. listDescription: string;
  31. listId: number;
  32. listName: string;
  33. subscribed: boolean;
  34. subscribedDate: string | null;
  35. unsubscribedDate: string | null;
  36. };
  37. function AccountSubscriptions() {
  38. const {
  39. data: subscriptions = [],
  40. isPending,
  41. isError,
  42. refetch,
  43. } = useApiQuery<Subscription[]>([ENDPOINT], {
  44. staleTime: 2 * 60 * 1000,
  45. });
  46. const queryClient = useQueryClient();
  47. const api = useApi();
  48. const {mutate: updateSubscription} = useMutation({
  49. mutationFn: (data: Subscription) =>
  50. api.requestPromise(ENDPOINT, {
  51. method: 'PUT',
  52. data,
  53. }),
  54. onSuccess: (_resp, subscription: Subscription) => {
  55. addSuccessMessage(
  56. `${subscription.subscribed ? 'Subscribed' : 'Unsubscribed'} to ${subscription.listName}`
  57. );
  58. // Update the subscription in the list
  59. setApiQueryData<Subscription[]>(queryClient, [ENDPOINT], subs => {
  60. return subs.map(sub => {
  61. if (sub.listId === subscription.listId) {
  62. return subscription;
  63. }
  64. return sub;
  65. });
  66. });
  67. },
  68. onError: (subscription: Subscription) => {
  69. if (subscription) {
  70. addErrorMessage(
  71. `Unable to ${subscription.subscribed ? '' : 'un'}subscribe to ${subscription.listName}`
  72. );
  73. } else {
  74. addErrorMessage('An unknown error occurred, please try again later.');
  75. }
  76. },
  77. });
  78. if (isPending) {
  79. return <LoadingIndicator />;
  80. }
  81. if (isError) {
  82. return (
  83. <LoadingError
  84. onRetry={() => {
  85. refetch();
  86. }}
  87. />
  88. );
  89. }
  90. const subGroups = Object.entries(
  91. subscriptions.reduce<Record<string, Subscription[]>>((acc, sub) => {
  92. (acc[sub.email] = acc[sub.email] || []).push(sub);
  93. return acc;
  94. }, {})
  95. );
  96. subGroups.sort(([a], [b]) => a[0]?.localeCompare(b[0]));
  97. const handleToggle = (subscription: Subscription) => {
  98. const subscribed = !subscription.subscribed;
  99. updateSubscription({
  100. ...subscription,
  101. subscribed,
  102. });
  103. };
  104. const subscriptionText = t('Subscriptions');
  105. return (
  106. <div>
  107. <SentryDocumentTitle title={subscriptionText} />
  108. <SettingsPageHeader title={subscriptionText} />
  109. <TextBlock>
  110. {t(`Sentry is committed to respecting your inbox. Our goal is to
  111. provide useful content and resources that make fixing errors less
  112. painful. Enjoyable even.`)}
  113. </TextBlock>
  114. <TextBlock>
  115. {t(`As part of our compliance with the EU’s General Data Protection
  116. Regulation (GDPR), starting on 25 May 2018, we’ll only email you
  117. according to the marketing categories to which you’ve explicitly
  118. opted-in.`)}
  119. </TextBlock>
  120. <Panel>
  121. {subscriptions?.length ? (
  122. <div>
  123. <PanelHeader>{t('Subscription')}</PanelHeader>
  124. <PanelBody>
  125. {subGroups.map(([email, subs]) => (
  126. <Fragment key={email}>
  127. {subGroups.length > 1 && (
  128. <Heading>
  129. <IconToggle /> {t('Subscriptions for %s', email)}
  130. </Heading>
  131. )}
  132. {subs
  133. .sort((a, b) => a.listId - b.listId)
  134. .map((subscription, i) => (
  135. <PanelItem center key={`${email}-${subscription.listId}-${i}`}>
  136. <SubscriptionDetails
  137. htmlFor={`${subscription.email}-${subscription.listId}`}
  138. aria-label={subscription.listName}
  139. >
  140. <SubscriptionName>{subscription.listName}</SubscriptionName>
  141. {subscription.listDescription && (
  142. <Description>{subscription.listDescription}</Description>
  143. )}
  144. {subscription.subscribed ? (
  145. <SubscribedDescription>
  146. <div>
  147. {tct('[email] on [date]', {
  148. email: subscription.email,
  149. date: (
  150. <DateTime
  151. date={moment(subscription.subscribedDate!)}
  152. />
  153. ),
  154. })}
  155. </div>
  156. </SubscribedDescription>
  157. ) : (
  158. <SubscribedDescription>
  159. {t('Not currently subscribed')}
  160. </SubscribedDescription>
  161. )}
  162. </SubscriptionDetails>
  163. <div>
  164. <Switch
  165. id={`${subscription.email}-${subscription.listId}`}
  166. isActive={subscription.subscribed}
  167. size="lg"
  168. toggle={() => handleToggle(subscription)}
  169. />
  170. </div>
  171. </PanelItem>
  172. ))}
  173. </Fragment>
  174. ))}
  175. </PanelBody>
  176. </div>
  177. ) : (
  178. <EmptyMessage>{t("There's no subscription backend present.")}</EmptyMessage>
  179. )}
  180. </Panel>
  181. <TextBlock>
  182. {t(`We’re applying GDPR consent and privacy policies to all Sentry
  183. contacts, regardless of location. You’ll be able to manage your
  184. subscriptions here and from an Unsubscribe link in the footer of
  185. all marketing emails.`)}
  186. </TextBlock>
  187. <TextBlock>
  188. {tct(
  189. 'Please contact [email:learn@sentry.io] with any questions or suggestions.',
  190. {email: <a href="mailto:learn@sentry.io" />}
  191. )}
  192. </TextBlock>
  193. </div>
  194. );
  195. }
  196. const Heading = styled(PanelItem)`
  197. display: grid;
  198. grid-template-columns: max-content 1fr;
  199. gap: ${space(1)};
  200. align-items: center;
  201. font-size: ${p => p.theme.fontSizeMedium};
  202. padding: ${space(1.5)} ${space(2)};
  203. background: ${p => p.theme.backgroundSecondary};
  204. color: ${p => p.theme.subText};
  205. `;
  206. const SubscriptionDetails = styled('label')`
  207. font-weight: initial;
  208. padding-right: ${space(2)};
  209. width: 85%;
  210. @media (min-width: ${p => p.theme.breakpoints.small}) {
  211. width: 75%;
  212. }
  213. @media (min-width: ${p => p.theme.breakpoints.large}) {
  214. width: 50%;
  215. }
  216. `;
  217. const SubscriptionName = styled('div')`
  218. font-size: ${p => p.theme.fontSizeMedium};
  219. `;
  220. const Description = styled('div')`
  221. font-size: ${p => p.theme.fontSizeSmall};
  222. color: ${p => p.theme.subText};
  223. margin-top: ${space(0.5)};
  224. `;
  225. const SubscribedDescription = styled(Description)`
  226. color: ${p => p.theme.subText};
  227. `;
  228. export default AccountSubscriptions;