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"