@graphcommerce/magento-product-downloadable 9.0.4-canary.9 → 9.1.0-canary.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -0
  3. package/components/DownloadableAccountMenuItem/DownloadableAccountMenuItem.graphql +11 -0
  4. package/components/DownloadableAccountMenuItem/DownloadableAccountMenuItem.tsx +32 -0
  5. package/components/DownloadableCartItemOptions/DownloadableCartItemOptions.tsx +39 -0
  6. package/components/DownloadableProductOptions/DownloadableProductOptions.tsx +43 -11
  7. package/components/DownloadsPage/DownloadsPage.graphql +11 -0
  8. package/components/DownloadsPage/DownloadsPage.tsx +134 -0
  9. package/copy/pages/account/downloads.tsx +26 -0
  10. package/copy/pages/downloadable/download/link/id/[id].tsx +104 -0
  11. package/copy/pages/downloadable/download/linkSample/link_id/[id].tsx +107 -0
  12. package/copy/pages/downloadable/download/sample/sample_id/[id].tsx +107 -0
  13. package/index.ts +5 -3
  14. package/package.json +13 -10
  15. package/plugins/DownloadableAccountMenuItem.tsx +19 -0
  16. package/plugins/DownloadableCartItemActionCard.tsx +30 -0
  17. package/plugins/DownloadableProductPagePrice.tsx +69 -0
  18. package/plugins/Downloadable_cartItemToCartItemInput.ts +36 -0
  19. package/DownloadableProductPage.graphql +0 -3
  20. package/ProductPageDownloadableQueryFragment.graphql +0 -22
  21. package/graphql/GetDownloadableTypeProduct.graphql +0 -9
  22. /package/{ProductListItemDownloadable.graphql → components/ProductListItemDownloadable/ProductListItemDownloadable.graphql} +0 -0
  23. /package/{ProductListItemDownloadable.tsx → components/ProductListItemDownloadable/ProductListItemDownloadable.tsx} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Change Log
2
2
 
3
+ ## 9.1.0-canary.16
4
+
5
+ ## 9.1.0-canary.15
6
+
7
+ ### Minor Changes
8
+
9
+ - [#2493](https://github.com/graphcommerce-org/graphcommerce/pull/2493) [`01ba1a7`](https://github.com/graphcommerce-org/graphcommerce/commit/01ba1a73c86778ea9b34059ec092723b4a56b92b) - Improved Downloadable products functionality:
10
+
11
+ - Account Dashboard Link and Download Page
12
+ - Download samples / linkSamples and links from the backend.
13
+ - CartItem edit functionality for the DownloadableCartItem
14
+ - Dynamic ProductPagePrice for downloadable options ([@paales](https://github.com/paales))
15
+
16
+ ## 9.0.4-canary.14
17
+
18
+ ## 9.0.4-canary.13
19
+
20
+ ## 9.0.4-canary.12
21
+
22
+ ## 9.0.4-canary.11
23
+
24
+ ## 9.0.4-canary.10
25
+
3
26
  ## 9.0.4-canary.9
4
27
 
5
28
  ## 9.0.4-canary.8
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # Magento Downloadable Products
@@ -0,0 +1,11 @@
1
+ fragment DownloadableAccountMenuItem on Query @inject(into: ["AccountDashboardQueryFragment"]) {
2
+ customerDownloadableProducts {
3
+ items {
4
+ date
5
+ download_url
6
+ order_increment_id
7
+ remaining_downloads
8
+ status
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ AccountDashboardDocument,
3
+ AccountMenuItem,
4
+ useCustomerQuery,
5
+ type AccountMenuItemProps,
6
+ } from '@graphcommerce/magento-customer'
7
+ import { filterNonNullableKeys, iconDownload } from '@graphcommerce/next-ui'
8
+ import { Trans } from '@lingui/macro'
9
+ import type { SetOptional } from 'type-fest'
10
+
11
+ type StoreCreditAccountMenuItemProps = SetOptional<
12
+ Omit<AccountMenuItemProps, 'href'>,
13
+ 'iconSrc' | 'title'
14
+ >
15
+
16
+ export function DownloadableAccountMenuItem(props: StoreCreditAccountMenuItemProps) {
17
+ const dashboard = useCustomerQuery(AccountDashboardDocument, { fetchPolicy: 'cache-only' })
18
+ const downloadable = dashboard.data?.customerDownloadableProducts
19
+
20
+ const items = filterNonNullableKeys(downloadable?.items)
21
+ const count = items.length
22
+
23
+ return (
24
+ <AccountMenuItem
25
+ href='/account/downloads'
26
+ iconSrc={iconDownload}
27
+ title={<Trans id='Downloads'>Downloads</Trans>}
28
+ subtitle={<Trans>You have {count} downloads available.</Trans>}
29
+ {...props}
30
+ />
31
+ )
32
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ SelectedCustomizableOptions,
3
+ type CartItemActionCardProps,
4
+ } from '@graphcommerce/magento-cart-items'
5
+ import { Money } from '@graphcommerce/magento-store'
6
+ import { filterNonNullableKeys } from '@graphcommerce/next-ui'
7
+ import { Box } from '@mui/material'
8
+ import React from 'react'
9
+
10
+ export function DownloadableCartItemOptions(props: CartItemActionCardProps) {
11
+ const { cartItem } = props
12
+
13
+ if (cartItem.__typename !== 'DownloadableCartItem') return null
14
+
15
+ const links = filterNonNullableKeys(cartItem.links, ['title', 'price'])
16
+
17
+ return (
18
+ <>
19
+ <Box
20
+ sx={(theme) => ({
21
+ display: 'grid',
22
+ gridTemplateColumns: 'auto auto',
23
+ gap: theme.spacings.xs,
24
+ })}
25
+ >
26
+ {links.map((link) => (
27
+ <React.Fragment key={link.uid}>
28
+ <span>{link.title}</span>
29
+ <Box>
30
+ <Money value={link.price} currency={cartItem.prices?.price.currency} />
31
+ </Box>
32
+ </React.Fragment>
33
+ ))}
34
+ </Box>
35
+
36
+ <SelectedCustomizableOptions {...cartItem} />
37
+ </>
38
+ )
39
+ }
@@ -3,7 +3,13 @@ import type { AddToCartItemSelector } from '@graphcommerce/magento-product'
3
3
  import { useFormAddProductsToCart } from '@graphcommerce/magento-product'
4
4
  import { Money } from '@graphcommerce/magento-store'
5
5
  import type { ActionCardProps } from '@graphcommerce/next-ui'
6
- import { ActionCard, filterNonNullableKeys } from '@graphcommerce/next-ui'
6
+ import {
7
+ ActionCard,
8
+ filterNonNullableKeys,
9
+ ListFormat,
10
+ SectionHeader,
11
+ } from '@graphcommerce/next-ui'
12
+ import { Box, Link } from '@mui/material'
7
13
  import { useMemo } from 'react'
8
14
  import type { DownloadableProductOptionsFragment } from './DownloadableProductOptions.gql'
9
15
 
@@ -15,28 +21,54 @@ export function DownloadableProductOptions(props: DownloadableProductOptionsProp
15
21
  const { product, index = 0 } = props
16
22
  const { control } = useFormAddProductsToCart()
17
23
 
18
- const items = useMemo(
24
+ const options = useMemo(
19
25
  () =>
20
26
  filterNonNullableKeys(product.downloadable_product_links, ['title']).map((item) => {
21
27
  const newItem: ActionCardProps = {
22
28
  value: item.uid,
23
29
  title: item.title,
24
30
  price: <Money value={item.price} />,
31
+ details: item.sample_url ? <Link href={item.sample_url}>View Sample</Link> : null,
25
32
  }
26
33
  return newItem
27
34
  }),
28
35
  [product.downloadable_product_links],
29
36
  )
30
37
 
38
+ const samples = filterNonNullableKeys(product.downloadable_product_samples, [
39
+ 'sort_order',
40
+ 'title',
41
+ 'sample_url',
42
+ ]).sort((a, b) => a.sort_order - b.sort_order)
43
+
31
44
  return (
32
- <ActionCardListForm
33
- size='medium'
34
- required
35
- errorMessage='Please select an option'
36
- control={control}
37
- name={`cartItems.${index}.selected_options.0`}
38
- render={ActionCard}
39
- items={items}
40
- />
45
+ <>
46
+ <Box>
47
+ <SectionHeader labelLeft='Downloadable Option' />
48
+ <ActionCardListForm
49
+ multiple
50
+ size='medium'
51
+ required
52
+ errorMessage='Please select an option'
53
+ control={control}
54
+ name={`cartItems.${index}.selected_options`}
55
+ render={ActionCard}
56
+ items={options}
57
+ />
58
+ </Box>
59
+
60
+ {samples.length > 0 ? (
61
+ <Box>
62
+ <SectionHeader labelLeft='Samples' />
63
+ <ListFormat>
64
+ {samples.map((sample) => (
65
+ <Link key={sample.sample_url} href={sample.sample_url}>
66
+ {sample.title}
67
+ </Link>
68
+ ))}
69
+ </ListFormat>
70
+ </Box>
71
+ ) : null}
72
+ </>
41
73
  )
42
74
  }
@@ -0,0 +1,11 @@
1
+ query DownloadsPage {
2
+ customerDownloadableProducts {
3
+ items {
4
+ date
5
+ download_url
6
+ order_increment_id
7
+ remaining_downloads
8
+ status
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,134 @@
1
+ import type { ApolloClient, NormalizedCacheObject } from '@graphcommerce/graphql'
2
+ import {
3
+ getCustomerAccountIsDisabled,
4
+ useCustomerQuery,
5
+ useCustomerSession,
6
+ WaitForCustomer,
7
+ } from '@graphcommerce/magento-customer'
8
+ import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store'
9
+ import {
10
+ Button,
11
+ DateFormat,
12
+ filterNonNullableKeys,
13
+ iconCreditCard,
14
+ iconDownload,
15
+ LayoutOverlayHeader,
16
+ LayoutTitle,
17
+ RelativeToTimeFormat,
18
+ SectionContainer,
19
+ type GetStaticProps,
20
+ } from '@graphcommerce/next-ui'
21
+ import { i18n } from '@lingui/core'
22
+ import { t, Trans } from '@lingui/macro'
23
+ import { Box, Container, Link, Typography } from '@mui/material'
24
+ import React from 'react'
25
+ import { DownloadsPageDocument } from './DownloadsPage.gql'
26
+
27
+ export type DownloadsPageProps = Record<string, unknown>
28
+ type DownloadsGetStaticProps = GetStaticProps<Record<string, unknown>, DownloadsPageProps>
29
+
30
+ export function DownloadsPage() {
31
+ const dashboard = useCustomerQuery(DownloadsPageDocument, {
32
+ fetchPolicy: 'cache-and-network',
33
+ })
34
+
35
+ const downloads = filterNonNullableKeys(dashboard.data?.customerDownloadableProducts?.items)
36
+
37
+ const session = useCustomerSession()
38
+
39
+ return (
40
+ <>
41
+ <LayoutOverlayHeader>
42
+ <LayoutTitle size='small' component='span' icon={iconDownload}>
43
+ <Trans id='Downloads'>Downloads</Trans>
44
+ </LayoutTitle>
45
+ </LayoutOverlayHeader>
46
+
47
+ <Container maxWidth='md'>
48
+ <WaitForCustomer waitFor={dashboard}>
49
+ <PageMeta title={t`Downloads`} metaRobots={['noindex']} />
50
+
51
+ <LayoutTitle
52
+ icon={iconDownload}
53
+ sx={(theme) => ({ mb: theme.spacings.xs })}
54
+ gutterBottom={false}
55
+ >
56
+ <Trans id='Downloads'>Downloads</Trans>
57
+ </LayoutTitle>
58
+
59
+ <Box>
60
+ <SectionContainer labelLeft={<Trans id='Balance history'>Balance history</Trans>}>
61
+ <Box
62
+ sx={{
63
+ display: 'grid',
64
+ gridTemplateColumns: 'repeat(5, auto)',
65
+ gap: 1,
66
+ alignItems: 'center',
67
+ }}
68
+ >
69
+ <Typography variant='subtitle1'>
70
+ <Trans id='Order'>Order</Trans>
71
+ </Typography>
72
+ <Typography variant='subtitle1'>
73
+ <Trans id='Download'>Download</Trans>
74
+ </Typography>
75
+ <Typography variant='subtitle1'>
76
+ <Trans id='Remaining'>Remaining</Trans>
77
+ </Typography>
78
+ <Typography variant='subtitle1'>
79
+ <Trans id='Status'>Status</Trans>
80
+ </Typography>
81
+ <Typography variant='subtitle1'>
82
+ <Trans id='Date'>Date</Trans>
83
+ </Typography>
84
+
85
+ {downloads.map((item, index) => (
86
+ // eslint-disable-next-line react/no-array-index-key
87
+ <React.Fragment key={index}>
88
+ <Box>
89
+ <Link href={`/account/orders/view?orderNumber=${item.order_increment_id}`}>
90
+ {item.order_increment_id}
91
+ </Link>
92
+ </Box>
93
+ <Box>
94
+ {item.status === 'available' ? (
95
+ <Link href={item.download_url} color='secondary'>
96
+ Download now
97
+ </Link>
98
+ ) : (
99
+ <Typography variant='subtitle1'>Order not completed</Typography>
100
+ )}
101
+ </Box>
102
+ <Box>{item.remaining_downloads}</Box>
103
+ <Box>{item.status}</Box>
104
+ <Box>
105
+ <RelativeToTimeFormat date={item.date} />
106
+ </Box>
107
+ </React.Fragment>
108
+ ))}
109
+ </Box>
110
+ </SectionContainer>
111
+ </Box>
112
+ </WaitForCustomer>
113
+ </Container>
114
+ </>
115
+ )
116
+ }
117
+
118
+ export function createGetStaticProps(
119
+ client: ApolloClient<NormalizedCacheObject>,
120
+ ): DownloadsGetStaticProps {
121
+ return async (context) => {
122
+ if (getCustomerAccountIsDisabled(context.locale)) return { notFound: true }
123
+
124
+ const conf = client.query({ query: StoreConfigDocument })
125
+
126
+ return {
127
+ props: {
128
+ apolloState: await conf.then(() => client.cache.extract()),
129
+ variantMd: 'bottom',
130
+ up: { href: '/account', title: i18n._('Account') },
131
+ },
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,26 @@
1
+ // eslint-disable-next-line import/no-extraneous-dependencies
2
+ import type { PageOptions } from '@graphcommerce/framer-next-pages'
3
+ import type { DownloadsPageProps } from '@graphcommerce/magento-product-downloadable'
4
+ // eslint-disable-next-line import/no-extraneous-dependencies
5
+ import { createGetStaticProps, DownloadsPage } from '@graphcommerce/magento-product-downloadable'
6
+ import type { GetStaticProps, LayoutOverlayProps } from '@graphcommerce/next-ui'
7
+ import { LayoutOverlay } from '@graphcommerce/next-ui'
8
+ import { graphqlSharedClient } from '../../lib/graphql/graphqlSsrClient'
9
+
10
+ type GetPageStaticProps = GetStaticProps<LayoutOverlayProps>
11
+
12
+ export default function Page(props: DownloadsPageProps) {
13
+ return <DownloadsPage {...props} />
14
+ }
15
+
16
+ const pageOptions: PageOptions<LayoutOverlayProps> = {
17
+ overlayGroup: 'account',
18
+ Layout: LayoutOverlay,
19
+ }
20
+
21
+ Page.pageOptions = pageOptions
22
+
23
+ export const getStaticProps: GetPageStaticProps = async (context) => {
24
+ const client = graphqlSharedClient(context)
25
+ return createGetStaticProps(client)(context)
26
+ }
@@ -0,0 +1,104 @@
1
+ // managed by: graphcommerce
2
+ // to modify this file, change it to managed by: local
3
+
4
+ import type { PageOptions } from '@graphcommerce/framer-next-pages'
5
+ import { cacheFirst } from '@graphcommerce/graphql'
6
+ import { StoreConfigDocument } from '@graphcommerce/magento-store'
7
+ import { FullPageMessage, iconError, IconSvg } from '@graphcommerce/next-ui'
8
+ import type { GetServerSideProps } from 'next'
9
+ import {
10
+ LayoutDocument,
11
+ LayoutNavigation,
12
+ type LayoutNavigationProps,
13
+ } from '../../../../../components'
14
+ import { graphqlSsrClient } from '../../../../../lib/graphql/graphqlSsrClient'
15
+
16
+ type ErrorType = 'not-sharable' | 'unknown'
17
+
18
+ type Props = { errorType: ErrorType }
19
+
20
+ export default function DownloadLinkPage(props: Props) {
21
+ const { errorType } = props
22
+ return (
23
+ <FullPageMessage
24
+ icon={<IconSvg src={iconError} size='xxl' />}
25
+ title={<>Error while downloading file</>}
26
+ >
27
+ {errorType === 'not-sharable' && (
28
+ <>
29
+ The requested file of found, but is not &ldquo;Sharable&rdquo; and can therefor not be
30
+ downloaded.
31
+ </>
32
+ )}
33
+ </FullPageMessage>
34
+ )
35
+ }
36
+
37
+ DownloadLinkPage.pageOptions = {
38
+ Layout: LayoutNavigation,
39
+ } as PageOptions
40
+
41
+ export const getServerSideProps: GetServerSideProps<Props & LayoutNavigationProps> = async (
42
+ context,
43
+ ) => {
44
+ const { query } = context
45
+ const id = query?.id
46
+
47
+ const staticClient = graphqlSsrClient(context)
48
+ const conf = await staticClient.query({ query: StoreConfigDocument })
49
+
50
+ const baseUrl = conf.data.storeConfig?.secure_base_url
51
+ if (!baseUrl) return { notFound: true }
52
+
53
+ const downloadUrl = new URL(baseUrl)
54
+ downloadUrl.pathname = `/downloadable/download/link/id/${id}`
55
+
56
+ const response = await fetch(downloadUrl, {
57
+ redirect: 'manual',
58
+ })
59
+
60
+ async function pageResponse(errorType: ErrorType) {
61
+ const layout = staticClient.query({
62
+ query: LayoutDocument,
63
+ fetchPolicy: cacheFirst(staticClient),
64
+ })
65
+ return {
66
+ props: {
67
+ errorType,
68
+ ...(await layout).data,
69
+ apolloState: staticClient.cache.extract(),
70
+ },
71
+ }
72
+ }
73
+
74
+ if (response.status === 302) {
75
+ const location = response.headers.get('Location')
76
+ const requestSignIn = location?.includes('customer/account/login')
77
+ if (requestSignIn) return pageResponse('not-sharable')
78
+ return pageResponse('unknown')
79
+ }
80
+
81
+ if (response.status !== 200 || !response.body) return { notFound: true }
82
+
83
+ context.res.setHeader(
84
+ 'Content-Type',
85
+ response.headers.get('Content-Type') ?? 'application/octet-stream',
86
+ )
87
+ context.res.setHeader(
88
+ 'Content-Disposition',
89
+ response.headers.get('Content-Disposition') ?? 'attachment',
90
+ )
91
+
92
+ await response.body.pipeTo(
93
+ new WritableStream<Uint8Array>({
94
+ write: (chunk) => {
95
+ context.res.write(chunk)
96
+ },
97
+ close() {
98
+ context.res.end()
99
+ },
100
+ }),
101
+ )
102
+
103
+ return pageResponse('unknown')
104
+ }
@@ -0,0 +1,107 @@
1
+ // managed by: graphcommerce
2
+ // to modify this file, change it to managed by: local
3
+
4
+ // managed by: graphcommerce
5
+ // to modify this file, change it to managed by: local
6
+
7
+ import type { PageOptions } from '@graphcommerce/framer-next-pages'
8
+ import { cacheFirst } from '@graphcommerce/graphql'
9
+ import { StoreConfigDocument } from '@graphcommerce/magento-store'
10
+ import { FullPageMessage, iconError, IconSvg } from '@graphcommerce/next-ui'
11
+ import type { GetServerSideProps } from 'next'
12
+ import {
13
+ LayoutDocument,
14
+ LayoutNavigation,
15
+ type LayoutNavigationProps,
16
+ } from '../../../../../components'
17
+ import { graphqlSsrClient } from '../../../../../lib/graphql/graphqlSsrClient'
18
+
19
+ type ErrorType = 'not-sharable' | 'unknown'
20
+
21
+ type Props = { errorType: ErrorType }
22
+
23
+ export default function DownloadLinkPage(props: Props) {
24
+ const { errorType } = props
25
+ return (
26
+ <FullPageMessage
27
+ icon={<IconSvg src={iconError} size='xxl' />}
28
+ title={<>Error while downloading file</>}
29
+ >
30
+ {errorType === 'not-sharable' && (
31
+ <>
32
+ The requested file of found, but is not &ldquo;Sharable&rdquo; and can therefor not be
33
+ downloaded.
34
+ </>
35
+ )}
36
+ </FullPageMessage>
37
+ )
38
+ }
39
+
40
+ DownloadLinkPage.pageOptions = {
41
+ Layout: LayoutNavigation,
42
+ } as PageOptions
43
+
44
+ export const getServerSideProps: GetServerSideProps<Props & LayoutNavigationProps> = async (
45
+ context,
46
+ ) => {
47
+ const { query } = context
48
+ const id = query?.id
49
+
50
+ const staticClient = graphqlSsrClient(context)
51
+ const conf = await staticClient.query({ query: StoreConfigDocument })
52
+
53
+ const baseUrl = conf.data.storeConfig?.secure_base_url
54
+ if (!baseUrl) return { notFound: true }
55
+
56
+ const downloadUrl = new URL(baseUrl)
57
+ downloadUrl.pathname = `/downloadable/download/linkSample/link_id/${id}`
58
+
59
+ const response = await fetch(downloadUrl, {
60
+ redirect: 'manual',
61
+ })
62
+
63
+ async function pageResponse(errorType: ErrorType) {
64
+ const layout = staticClient.query({
65
+ query: LayoutDocument,
66
+ fetchPolicy: cacheFirst(staticClient),
67
+ })
68
+ return {
69
+ props: {
70
+ errorType,
71
+ ...(await layout).data,
72
+ apolloState: staticClient.cache.extract(),
73
+ },
74
+ }
75
+ }
76
+
77
+ if (response.status === 302) {
78
+ const location = response.headers.get('Location')
79
+ const requestSignIn = location?.includes('customer/account/login')
80
+ if (requestSignIn) return pageResponse('not-sharable')
81
+ return pageResponse('unknown')
82
+ }
83
+
84
+ if (response.status !== 200 || !response.body) return { notFound: true }
85
+
86
+ context.res.setHeader(
87
+ 'Content-Type',
88
+ response.headers.get('Content-Type') ?? 'application/octet-stream',
89
+ )
90
+ context.res.setHeader(
91
+ 'Content-Disposition',
92
+ response.headers.get('Content-Disposition') ?? 'attachment',
93
+ )
94
+
95
+ await response.body.pipeTo(
96
+ new WritableStream<Uint8Array>({
97
+ write: (chunk) => {
98
+ context.res.write(chunk)
99
+ },
100
+ close() {
101
+ context.res.end()
102
+ },
103
+ }),
104
+ )
105
+
106
+ return pageResponse('unknown')
107
+ }
@@ -0,0 +1,107 @@
1
+ // managed by: graphcommerce
2
+ // to modify this file, change it to managed by: local
3
+
4
+ // managed by: graphcommerce
5
+ // to modify this file, change it to managed by: local
6
+
7
+ import type { PageOptions } from '@graphcommerce/framer-next-pages'
8
+ import { cacheFirst } from '@graphcommerce/graphql'
9
+ import { StoreConfigDocument } from '@graphcommerce/magento-store'
10
+ import { FullPageMessage, iconError, IconSvg } from '@graphcommerce/next-ui'
11
+ import type { GetServerSideProps } from 'next'
12
+ import {
13
+ LayoutDocument,
14
+ LayoutNavigation,
15
+ type LayoutNavigationProps,
16
+ } from '../../../../../components'
17
+ import { graphqlSsrClient } from '../../../../../lib/graphql/graphqlSsrClient'
18
+
19
+ type ErrorType = 'not-sharable' | 'unknown'
20
+
21
+ type Props = { errorType: ErrorType }
22
+
23
+ export default function DownloadLinkPage(props: Props) {
24
+ const { errorType } = props
25
+ return (
26
+ <FullPageMessage
27
+ icon={<IconSvg src={iconError} size='xxl' />}
28
+ title={<>Error while downloading file</>}
29
+ >
30
+ {errorType === 'not-sharable' && (
31
+ <>
32
+ The requested file of found, but is not &ldquo;Sharable&rdquo; and can therefor not be
33
+ downloaded.
34
+ </>
35
+ )}
36
+ </FullPageMessage>
37
+ )
38
+ }
39
+
40
+ DownloadLinkPage.pageOptions = {
41
+ Layout: LayoutNavigation,
42
+ } as PageOptions
43
+
44
+ export const getServerSideProps: GetServerSideProps<Props & LayoutNavigationProps> = async (
45
+ context,
46
+ ) => {
47
+ const { query } = context
48
+ const id = query?.id
49
+
50
+ const staticClient = graphqlSsrClient(context)
51
+ const conf = await staticClient.query({ query: StoreConfigDocument })
52
+
53
+ const baseUrl = conf.data.storeConfig?.secure_base_url
54
+ if (!baseUrl) return { notFound: true }
55
+
56
+ const downloadUrl = new URL(baseUrl)
57
+ downloadUrl.pathname = `/downloadable/download/sample/sample_id/${id}`
58
+
59
+ const response = await fetch(downloadUrl, {
60
+ redirect: 'manual',
61
+ })
62
+
63
+ async function pageResponse(errorType: ErrorType) {
64
+ const layout = staticClient.query({
65
+ query: LayoutDocument,
66
+ fetchPolicy: cacheFirst(staticClient),
67
+ })
68
+ return {
69
+ props: {
70
+ errorType,
71
+ ...(await layout).data,
72
+ apolloState: staticClient.cache.extract(),
73
+ },
74
+ }
75
+ }
76
+
77
+ if (response.status === 302) {
78
+ const location = response.headers.get('Location')
79
+ const requestSignIn = location?.includes('customer/account/login')
80
+ if (requestSignIn) return pageResponse('not-sharable')
81
+ return pageResponse('unknown')
82
+ }
83
+
84
+ if (response.status !== 200 || !response.body) return { notFound: true }
85
+
86
+ context.res.setHeader(
87
+ 'Content-Type',
88
+ response.headers.get('Content-Type') ?? 'application/octet-stream',
89
+ )
90
+ context.res.setHeader(
91
+ 'Content-Disposition',
92
+ response.headers.get('Content-Disposition') ?? 'attachment',
93
+ )
94
+
95
+ await response.body.pipeTo(
96
+ new WritableStream<Uint8Array>({
97
+ write: (chunk) => {
98
+ context.res.write(chunk)
99
+ },
100
+ close() {
101
+ context.res.end()
102
+ },
103
+ }),
104
+ )
105
+
106
+ return pageResponse('unknown')
107
+ }
package/index.ts CHANGED
@@ -1,4 +1,6 @@
1
- export * from './DownloadableProductPage.gql'
2
- export * from './ProductListItemDownloadable'
3
- export * from './ProductListItemDownloadable.gql'
1
+ export * from './components/ProductListItemDownloadable/ProductListItemDownloadable'
2
+ export * from './components/ProductListItemDownloadable/ProductListItemDownloadable.gql'
4
3
  export * from './components/DownloadableProductOptions'
4
+ export * from './components/DownloadableAccountMenuItem/DownloadableAccountMenuItem'
5
+ export * from './components/DownloadableAccountMenuItem/DownloadableAccountMenuItem.gql'
6
+ export * from './components/DownloadsPage/DownloadsPage'
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@graphcommerce/magento-product-downloadable",
3
3
  "homepage": "https://www.graphcommerce.org/",
4
4
  "repository": "github:graphcommerce-org/graphcommerce",
5
- "version": "9.0.4-canary.9",
5
+ "version": "9.1.0-canary.16",
6
6
  "sideEffects": false,
7
7
  "prettier": "@graphcommerce/prettier-config-pwa",
8
8
  "eslintConfig": {
@@ -12,18 +12,21 @@
12
12
  }
13
13
  },
14
14
  "peerDependencies": {
15
- "@graphcommerce/ecommerce-ui": "^9.0.4-canary.9",
16
- "@graphcommerce/eslint-config-pwa": "^9.0.4-canary.9",
17
- "@graphcommerce/graphql": "^9.0.4-canary.9",
18
- "@graphcommerce/magento-cart": "^9.0.4-canary.9",
19
- "@graphcommerce/magento-product": "^9.0.4-canary.9",
20
- "@graphcommerce/magento-store": "^9.0.4-canary.9",
21
- "@graphcommerce/next-ui": "^9.0.4-canary.9",
22
- "@graphcommerce/prettier-config-pwa": "^9.0.4-canary.9",
23
- "@graphcommerce/typescript-config-pwa": "^9.0.4-canary.9",
15
+ "@graphcommerce/ecommerce-ui": "^9.1.0-canary.16",
16
+ "@graphcommerce/eslint-config-pwa": "^9.1.0-canary.16",
17
+ "@graphcommerce/graphql": "^9.1.0-canary.16",
18
+ "@graphcommerce/magento-cart": "^9.1.0-canary.16",
19
+ "@graphcommerce/magento-cart-items": "^9.1.0-canary.16",
20
+ "@graphcommerce/magento-customer": "^9.1.0-canary.16",
21
+ "@graphcommerce/magento-product": "^9.1.0-canary.16",
22
+ "@graphcommerce/magento-store": "^9.1.0-canary.16",
23
+ "@graphcommerce/next-ui": "^9.1.0-canary.16",
24
+ "@graphcommerce/prettier-config-pwa": "^9.1.0-canary.16",
25
+ "@graphcommerce/typescript-config-pwa": "^9.1.0-canary.16",
24
26
  "@lingui/core": "^4.2.1",
25
27
  "@lingui/macro": "^4.2.1",
26
28
  "@lingui/react": "^4.2.1",
29
+ "@mui/material": "^5.10.16",
27
30
  "next": "*",
28
31
  "react": "^18.2.0",
29
32
  "react-dom": "^18.2.0"
@@ -0,0 +1,19 @@
1
+ import type { AccountMenuItemProps } from '@graphcommerce/magento-customer'
2
+ import type { PluginConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import { DownloadableAccountMenuItem } from '../components/DownloadableAccountMenuItem/DownloadableAccountMenuItem'
4
+
5
+ export const config: PluginConfig = {
6
+ module: '@graphcommerce/magento-customer',
7
+ type: 'component',
8
+ }
9
+
10
+ export function AccountMenuItem(props: PluginProps<AccountMenuItemProps>) {
11
+ const { Prev, href, ...rest } = props
12
+
13
+ return (
14
+ <>
15
+ <Prev {...rest} href={href} />
16
+ {href === '/account/orders' && <DownloadableAccountMenuItem />}
17
+ </>
18
+ )
19
+ }
@@ -0,0 +1,30 @@
1
+ import {
2
+ SelectedCustomizableOptions,
3
+ type CartItemActionCardProps,
4
+ } from '@graphcommerce/magento-cart-items'
5
+ import { Money } from '@graphcommerce/magento-store'
6
+ import type { PluginConfig, PluginProps } from '@graphcommerce/next-config'
7
+ import { filterNonNullableKeys, isTypename } from '@graphcommerce/next-ui'
8
+ import { Box } from '@mui/material'
9
+ import React from 'react'
10
+ import { DownloadableCartItemOptions } from '../components/DownloadableCartItemOptions/DownloadableCartItemOptions'
11
+
12
+ export const config: PluginConfig = {
13
+ type: 'component',
14
+ module: '@graphcommerce/magento-cart-items',
15
+ }
16
+
17
+ export function CartItemActionCard(props: PluginProps<CartItemActionCardProps>) {
18
+ const { Prev, ...rest } = props
19
+
20
+ return (
21
+ <Prev
22
+ {...rest}
23
+ details={
24
+ <>
25
+ {rest.details} <DownloadableCartItemOptions {...rest} />
26
+ </>
27
+ }
28
+ />
29
+ )
30
+ }
@@ -0,0 +1,69 @@
1
+ import { useWatch } from '@graphcommerce/ecommerce-ui'
2
+ import {
3
+ useFormAddProductsToCart,
4
+ type AddToCartItemSelector,
5
+ type ProductPagePriceProps,
6
+ } from '@graphcommerce/magento-product'
7
+ import type { PluginConfig, PluginProps } from '@graphcommerce/next-config'
8
+ import type { DownloadableProductOptionsFragment } from '../components/DownloadableProductOptions/DownloadableProductOptions.gql'
9
+ import type { ProductListItemDownloadableFragment } from '../components/ProductListItemDownloadable/ProductListItemDownloadable.gql'
10
+
11
+ export const config: PluginConfig = {
12
+ type: 'component',
13
+ module: '@graphcommerce/magento-product',
14
+ }
15
+
16
+ function isDownloadableProduct(
17
+ product:
18
+ | ProductPagePriceProps['product']
19
+ | (ProductPagePriceProps['product'] & DownloadableProductOptionsFragment),
20
+ ): product is ProductPagePriceProps['product'] & DownloadableProductOptionsFragment {
21
+ return (
22
+ product.__typename === 'DownloadableProduct' &&
23
+ Array.isArray((product as DownloadableProductOptionsFragment).downloadable_product_links)
24
+ )
25
+ }
26
+
27
+ export function ProductPagePrice(
28
+ props: PluginProps<ProductPagePriceProps> & AddToCartItemSelector,
29
+ ) {
30
+ const { Prev, product, index = 0, ...rest } = props
31
+
32
+ const form = useFormAddProductsToCart()
33
+ const selectedOptions = useWatch({
34
+ control: form.control,
35
+ name: `cartItems.${index}.selected_options`,
36
+ })
37
+
38
+ if (!isDownloadableProduct(product)) return <Prev product={product} index={index} {...rest} />
39
+
40
+ const selectedLinks = product.downloadable_product_links?.filter((link) =>
41
+ selectedOptions?.includes(link?.uid ?? ''),
42
+ )
43
+
44
+ const totalPrice = selectedLinks?.reduce((acc, link) => acc + (link?.price ?? 0), 0) ?? 0
45
+
46
+ return (
47
+ <Prev
48
+ product={{
49
+ ...product,
50
+ price_range: {
51
+ ...product.price_range,
52
+ minimum_price: {
53
+ ...product.price_range.minimum_price,
54
+ regular_price: {
55
+ currency: product.price_range.minimum_price.regular_price.currency,
56
+ value: (product.price_range.minimum_price.regular_price.value ?? 0) + totalPrice,
57
+ },
58
+ final_price: {
59
+ currency: product.price_range.minimum_price.final_price.currency,
60
+ value: (product.price_range.minimum_price.final_price.value ?? 0) + totalPrice,
61
+ },
62
+ },
63
+ },
64
+ }}
65
+ index={index}
66
+ {...rest}
67
+ />
68
+ )
69
+ }
@@ -0,0 +1,36 @@
1
+ import type { CartItemInput } from '@graphcommerce/graphql-mesh'
2
+ import { type cartItemToCartItemInput as cartItemToCartItemInputType } from '@graphcommerce/magento-cart-items'
3
+ import type { FunctionPlugin, PluginConfig } from '@graphcommerce/next-config'
4
+ import { filterNonNullableKeys, isTypename } from '@graphcommerce/next-ui'
5
+
6
+ export const config: PluginConfig = {
7
+ type: 'function',
8
+ module: '@graphcommerce/magento-cart-items',
9
+ }
10
+
11
+ export const cartItemToCartItemInput: FunctionPlugin<typeof cartItemToCartItemInputType> = (
12
+ prev,
13
+ props,
14
+ ) => {
15
+ const result = prev(props)
16
+ const { product, cartItem } = props
17
+
18
+ if (!result) return result
19
+ if (!isTypename(product, ['DownloadableProduct'])) return result
20
+ if (!isTypename(cartItem, ['DownloadableCartItem'])) return result
21
+
22
+ const links = filterNonNullableKeys(cartItem.links, ['title', 'price'])
23
+ const productLinks = filterNonNullableKeys(product.downloadable_product_links, ['title', 'price'])
24
+
25
+ const selected_options: NonNullable<CartItemInput['selected_options']> = []
26
+
27
+ productLinks.forEach((link) => {
28
+ const linkIndex = links.findIndex((l) => l.uid === link.uid)
29
+ if (linkIndex !== -1) selected_options[linkIndex] = link.uid
30
+ })
31
+
32
+ return {
33
+ ...result,
34
+ selected_options,
35
+ } satisfies CartItemInput
36
+ }
@@ -1,3 +0,0 @@
1
- query DownloadableProductPage($urlKey: String) {
2
- ...ProductPageDownloadableQueryFragment
3
- }
@@ -1,22 +0,0 @@
1
- fragment ProductPageDownloadableQueryFragment on Query {
2
- typeProducts: products(filter: { url_key: { eq: $urlKey } }) {
3
- items {
4
- __typename
5
- uid
6
- ... on DownloadableProduct {
7
- downloadable_product_links {
8
- price
9
- sample_url
10
- sort_order
11
- title
12
- uid
13
- }
14
- downloadable_product_samples {
15
- title
16
- sort_order
17
- sample_url
18
- }
19
- }
20
- }
21
- }
22
- }
@@ -1,9 +0,0 @@
1
- query DownloadableProductPage($urlKey: String) {
2
- typeProducts: products(filter: { url_key: { eq: $urlKey } }) {
3
- items {
4
- __typename
5
- uid
6
- ...DownloadableProductOptions
7
- }
8
- }
9
- }