diff --git a/packages/api/src/resolvers/subscriptions/index.ts b/packages/api/src/resolvers/subscriptions/index.ts index 766eb6b0f..7936b25e5 100644 --- a/packages/api/src/resolvers/subscriptions/index.ts +++ b/packages/api/src/resolvers/subscriptions/index.ts @@ -37,25 +37,22 @@ export const subscriptionsResolver = authorized< try { const sortBy = sort?.by === SortBy.UpdatedTime ? 'updatedAt' : 'createdAt' const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC' - const user = await getRepository(User).findOne({ - where: { id: uid, subscriptions: { status: SubscriptionStatus.Active } }, - relations: { - subscriptions: true, - }, - order: { - subscriptions: { - [sortBy]: sortOrder, - }, - }, - }) + const user = await getRepository(User).findOneBy({ id: uid }) if (!user) { return { errorCodes: [SubscriptionsErrorCode.Unauthorized], } } + const subscriptions = await getRepository(Subscription).find({ + where: { user: { id: uid }, status: SubscriptionStatus.Active }, + order: { + [sortBy]: sortOrder, + }, + }) + return { - subscriptions: user.subscriptions || [], + subscriptions, } } catch (error) { log.error(error) @@ -73,9 +70,12 @@ export const unsubscribeResolver = authorized< log.info('unsubscribeResolver') try { - const subscription = await getRepository(Subscription).findOneBy({ - name, - user: { id: uid }, + const subscription = await getRepository(Subscription).findOne({ + where: { + name, + user: { id: uid }, + }, + relations: ['user'], }) if (!subscription) { return { diff --git a/packages/db/migrations/0082.do.add_default_subscription_status.sql b/packages/db/migrations/0082.do.add_default_subscription_status.sql new file mode 100755 index 000000000..8683078f9 --- /dev/null +++ b/packages/db/migrations/0082.do.add_default_subscription_status.sql @@ -0,0 +1,10 @@ +-- Type: DO +-- Name: add_default_subscription_status +-- Description: Add default value to subscription status field + +BEGIN; + +ALTER TABLE omnivore.subscriptions + ALTER COLUMN status SET DEFAULT 'ACTIVE'; + +COMMIT; diff --git a/packages/db/migrations/0082.undo.add_default_subscription_status.sql b/packages/db/migrations/0082.undo.add_default_subscription_status.sql new file mode 100755 index 000000000..c5c4771a3 --- /dev/null +++ b/packages/db/migrations/0082.undo.add_default_subscription_status.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: add_default_subscription_status +-- Description: Add default value to subscription status field + +BEGIN; + +ALTER TABLE omnivore.subscriptions ALTER status DROP DEFAULT; + +COMMIT; diff --git a/packages/web/components/elements/Table.tsx b/packages/web/components/elements/Table.tsx new file mode 100644 index 000000000..38ba1587f --- /dev/null +++ b/packages/web/components/elements/Table.tsx @@ -0,0 +1,229 @@ +import { Box, HStack, SpanBox, VStack } from './LayoutPrimitives' +import { styled } from '../tokens/stitches.config' +import { StyledText } from './StyledText' +import { InfoLink } from './InfoLink' +import { Button } from './Button' +import { Plus, Trash } from 'phosphor-react' +import { isDarkTheme } from '../../lib/themeUpdater' + +interface TableProps { + heading: string + infoLink?: string + onAdd?: () => void + headers: string[] + rows: string[][] + onDelete?: (id: string) => void +} + +const HeaderWrapper = styled(Box, { + width: '100%', + '@md': { + display: 'block', + }, +}) + +const TableCard = styled(Box, { + backgroundColor: '$grayBg', + display: 'flex', + alignItems: 'center', + padding: '10px 12px', + border: '0.5px solid $grayBgActive', + width: '100%', + + '&:hover': { + border: '0.5px solid #FFD234', + }, + '@md': { + paddingLeft: '0', + }, +}) + +const TableHeading = styled(Box, { + backgroundColor: '$grayBgActive', + border: '1px solid rgba(0, 0, 0, 0.06)', + display: 'none', + alignItems: 'center', + padding: '14px 0 14px 40px', + borderRadius: '5px 5px 0px 0px', + width: '100%', + '@md': { + display: 'flex', + }, +}) + +const Input = styled('input', { + backgroundColor: 'transparent', + color: '$grayTextContrast', + marginTop: '5px', + '&[disabled]': { + border: 'none', + }, +}) + +const IconButton = styled(Button, { + variants: { + style: { + ctaWhite: { + color: 'red', + padding: '10px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: '1px solid $grayBorder', + boxSizing: 'border-box', + borderRadius: 6, + width: 40, + height: 40, + }, + }, + }, +}) + +export function Table(props: TableProps): JSX.Element { + const iconColor = isDarkTheme() ? '#D8D7D5' : '#5F5E58' + + return ( + + + + + {props.heading} + + {props.infoLink && } + {props.onAdd && ( + + )} + + + + {props.headers.map((header, index) => ( + + + {header} + + + ))} + + {props.rows.map((row, index) => ( + + + {row.map((cell, index) => ( + + + + ))} + {props.onDelete && ( + { + props.onDelete && props.onDelete(row[0]) + }} + > + + + )} + + + ))} + + ) +} diff --git a/packages/web/components/patterns/DropdownMenu.tsx b/packages/web/components/patterns/DropdownMenu.tsx index 317af1ade..973047a68 100644 --- a/packages/web/components/patterns/DropdownMenu.tsx +++ b/packages/web/components/patterns/DropdownMenu.tsx @@ -1,9 +1,9 @@ import { ReactNode, useMemo, useState } from 'react' -import { Box, HStack, VStack } from './../elements/LayoutPrimitives' +import { HStack, VStack } from './../elements/LayoutPrimitives' import { Dropdown, - DropdownSeparator, DropdownOption, + DropdownSeparator, } from '../elements/DropdownElements' import { StyledText } from '../elements/StyledText' import { Button } from '../elements/Button' @@ -18,6 +18,7 @@ export type HeaderDropdownAction = | 'navigate-to-emails' | 'navigate-to-labels' | 'navigate-to-profile' + | 'navigate-to-subscriptions' | 'increaseFontSize' | 'decreaseFontSize' | 'logout' @@ -38,19 +39,27 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { return ( - Theme + Theme - - @@ -67,6 +76,10 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { onSelect={() => props.actionHandler('navigate-to-labels')} title="Labels" /> + {/* props.actionHandler('navigate-to-subscriptions')} + title="Subscriptions" + /> */} window.Intercom('show')} title="Feedback" diff --git a/packages/web/components/patterns/PrimaryHeader.tsx b/packages/web/components/patterns/PrimaryHeader.tsx index 12c7e4754..79d7b8e4b 100644 --- a/packages/web/components/patterns/PrimaryHeader.tsx +++ b/packages/web/components/patterns/PrimaryHeader.tsx @@ -1,9 +1,9 @@ -import { Box, HStack, SpanBox } from '../elements/LayoutPrimitives' +import { Box, HStack } from '../elements/LayoutPrimitives' import { OmnivoreNameLogo } from './../elements/images/OmnivoreNameLogo' import { DropdownMenu, HeaderDropdownAction } from './../patterns/DropdownMenu' -import { updateTheme } from '../../lib/themeUpdater' +import { darkenTheme, lightenTheme, updateTheme } from '../../lib/themeUpdater' import { AvatarDropdown } from './../elements/AvatarDropdown' -import { theme, ThemeId } from './../tokens/stitches.config' +import { ThemeId } from './../tokens/stitches.config' import { useCallback, useEffect, useState } from 'react' import { ScrollOffsetChangeset, @@ -12,7 +12,6 @@ import { import { useRouter } from 'next/router' import { useKeyboardShortcuts } from '../../lib/keyboardShortcuts/useKeyboardShortcuts' import { primaryCommands } from '../../lib/keyboardShortcuts/navigationShortcuts' -import { darkenTheme, lightenTheme } from '../../lib/themeUpdater' import { UserBasicData } from '../../lib/networking/queries/useGetViewerQuery' import { setupAnalytics } from '../../lib/analytics' import { Button } from '../elements/Button' @@ -111,6 +110,9 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element { router.push(`/${props.user.profile.username}`) } break + case 'navigate-to-subscriptions': + router.push('/settings/subscriptions') + break case 'logout': props.setShowLogoutConfirmation(true) break @@ -187,13 +189,17 @@ function NavHeader(props: NavHeaderProps): JSX.Element { {props.toolbarControl && ( - {props.toolbarControl} diff --git a/packages/web/lib/networking/mutations/unsubscribeMutation.ts b/packages/web/lib/networking/mutations/unsubscribeMutation.ts new file mode 100644 index 000000000..3eba481ab --- /dev/null +++ b/packages/web/lib/networking/mutations/unsubscribeMutation.ts @@ -0,0 +1,39 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' +import { Subscription } from '../queries/useGetSubscriptionsQuery' + +type UnsubscribeResult = { + unsubscribe: Unsubscribe + errorCodes?: unknown[] +} + +type Unsubscribe = { + subscription: Subscription +} + +export async function unsubscribeMutation( + subscribeName: string +): Promise { + const mutation = gql` + mutation { + unsubscribe(name: "${subscribeName}") { + ... on UnsubscribeSuccess { + subscription { + id + } + } + ... on UnsubscribeError { + errorCodes + } + } + } + ` + + try { + const data = (await gqlFetcher(mutation)) as UnsubscribeResult + return data.errorCodes ? undefined : data.unsubscribe.subscription.id + } catch (error) { + console.log('unsubscribeMutation error', error) + return undefined + } +} diff --git a/packages/web/lib/networking/queries/useGetSubscriptionsQuery.tsx b/packages/web/lib/networking/queries/useGetSubscriptionsQuery.tsx new file mode 100644 index 000000000..92ec8241a --- /dev/null +++ b/packages/web/lib/networking/queries/useGetSubscriptionsQuery.tsx @@ -0,0 +1,83 @@ +import { gql } from 'graphql-request' +import useSWR from 'swr' +import { publicGqlFetcher } from '../networkHelpers' + +export type SubscriptionStatus = 'ACTIVE' | 'DELETED' | 'UNSUBSCRIBED' + +export type Subscription = { + id: string + name: string + newsletterEmail: string + + url?: string + description?: string + + status: SubscriptionStatus + createdAt: Date + updatedAt: Date +} + +type SubscriptionsQueryResponse = { + isValidating: boolean + subscriptions: Subscription[] + revalidate: () => void +} + +type SubscriptionsResponseData = { + subscriptions: SubscriptionsData +} + +type SubscriptionsData = { + subscriptions: unknown +} + +export function useGetSubscriptionsQuery(): SubscriptionsQueryResponse { + const query = gql` + query GetSubscriptions { + subscriptions(sort: { by: UPDATED_TIME }) { + ... on SubscriptionsSuccess { + subscriptions { + id + name + newsletterEmail + url + description + status + unsubscribeMailTo + unsubscribeHttpUrl + createdAt + updatedAt + } + } + ... on SubscriptionsError { + errorCodes + } + } + } + ` + + const { data, mutate, error, isValidating } = useSWR(query, publicGqlFetcher) + console.log('subscriptions data', data) + + try { + if (data) { + const result = data as SubscriptionsResponseData + const subscriptions = result.subscriptions.subscriptions as Subscription[] + return { + isValidating, + subscriptions, + revalidate: () => { + mutate() + }, + } + } + } catch (error) { + console.log('error', error) + } + return { + isValidating: false, + subscriptions: [], + // eslint-disable-next-line @typescript-eslint/no-empty-function + revalidate: () => {}, + } +} diff --git a/packages/web/pages/settings/subscriptions.tsx b/packages/web/pages/settings/subscriptions.tsx new file mode 100644 index 000000000..fba1a7b43 --- /dev/null +++ b/packages/web/pages/settings/subscriptions.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react' +import { PrimaryLayout } from '../../components/templates/PrimaryLayout' +import { Toaster } from 'react-hot-toast' +import { applyStoredTheme } from '../../lib/themeUpdater' +import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' +import { useGetSubscriptionsQuery } from '../../lib/networking/queries/useGetSubscriptionsQuery' +import { unsubscribeMutation } from '../../lib/networking/mutations/unsubscribeMutation' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' +import { Table } from '../../components/elements/Table' + +export default function SubscriptionsPage(): JSX.Element { + const { subscriptions, revalidate } = useGetSubscriptionsQuery() + const [confirmUnsubscribeName, setConfirmUnsubscribeName] = useState< + string | null + >(null) + + applyStoredTheme(false) + + async function onUnsubscribe(name: string): Promise { + const result = await unsubscribeMutation(name) + if (result) { + showSuccessToast('Unsubscribed', { position: 'bottom-right' }) + } else { + showErrorToast('Failed to unsubscribe', { position: 'bottom-right' }) + } + revalidate() + } + + const headers = ['Name', 'Email', 'Updated Time'] + const rows = subscriptions.map((subscription) => [ + subscription.name, + subscription.newsletterEmail, + subscription.updatedAt.toString(), + ]) + + return ( + + + + {confirmUnsubscribeName ? ( + { + await onUnsubscribe(confirmUnsubscribeName) + setConfirmUnsubscribeName(null) + }} + onOpenChange={() => setConfirmUnsubscribeName(null)} + /> + ) : null} + + + {/* TODO: Dynamically loaded from API response */} +
+ + ) +} diff --git a/yarn.lock b/yarn.lock index 3f11d970d..c5a3eceeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11328,9 +11328,9 @@ core-js-compat@^3.20.2, core-js-compat@^3.21.0: semver "7.0.0" core-js-compat@^3.8.1: - version "3.22.2" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.22.2.tgz#eec621eb276518efcf718d0a6d9d042c3d0cad48" - integrity sha512-Fns9lU06ZJ07pdfmPMu7OnkIKGPKDzXKIiuGlSvHHapwqMUF2QnnsWwtueFZtSyZEilP0o6iUeHQwpn7LxtLUw== + version "3.22.1" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.22.1.tgz#47b9c5e79efbf13935f637449fa1cdec8cd9515f" + integrity sha512-CWbNqTluLMvZg1cjsQUbGiCM91dobSHKfDIyCoxuqxthdjGuUlaMbCsSehP3CBiVvG0C7P6UIrC1v0hgFE75jw== dependencies: browserslist "^4.20.2" semver "7.0.0" @@ -11341,14 +11341,14 @@ core-js-pure@^3.16.0: integrity sha512-oxKe64UH049mJqrKkynWp6Vu0Rlm/BTXO/bJZuN2mmR3RtOFNepLlSWDd1eo16PzHpQAoNG97rLU1V/YxesJjw== core-js-pure@^3.8.1, core-js-pure@^3.8.2: - version "3.22.2" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.22.2.tgz#c10bffdc3028d25c2aae505819a05543db61544f" - integrity sha512-Lb+/XT4WC4PaCWWtZpNPaXmjiNDUe5CJuUtbkMrIM1kb1T/jJoAIp+bkVP/r5lHzMr+ZAAF8XHp7+my6Ol0ysQ== + version "3.22.1" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.22.1.tgz#4d94e0c9a7b710da20dadd727fe98b43543119f0" + integrity sha512-TChjCtgcMDc8t12RiwAsThjqrS/VpBlEvDgL009ot4HESzBo3h2FSZNa6ZS1nWKZEPDoulnszxUll9n0/spflQ== core-js@^3.0.4, core-js@^3.6.5, core-js@^3.8.2: - version "3.22.2" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.22.2.tgz#3ea0a245b0895fa39d1faa15fe75d91ade504a01" - integrity sha512-Z5I2vzDnEIqO2YhELVMFcL1An2CIsFe9Q7byZhs8c/QxummxZlAHw33TUHbIte987LkisOgL0LwQ1P9D6VISnA== + version "3.22.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.22.1.tgz#1936e4f1da82675fe22ae10ee60ef638cd9752fd" + integrity sha512-l6CwCLq7XgITOQGhv1dIUmwCFoqFjyQ6zQHUCQlS0xKmb9d6OHIg8jDiEoswhaettT21BSF5qKr6kbvE+aKwxw== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2"