@graphcommerce/magento-store 9.1.0-canary.18 → 9.1.0-canary.20

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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/components/AttributeForm/AttributeFormField.tsx +27 -0
  3. package/components/AttributeForm/AttributesFormAutoLayout.tsx +91 -0
  4. package/components/AttributeForm/index.ts +3 -0
  5. package/components/AttributeForm/preloadAttributesForm.ts +10 -0
  6. package/components/AttributeForm/useAttributesForm.tsx +266 -0
  7. package/components/CurrencySymbol/CurrencySymbol.tsx +1 -1
  8. package/components/GlobalHead/GlobalHead.tsx +27 -4
  9. package/components/Money/Money.tsx +2 -2
  10. package/components/PageMeta/PageMeta.tsx +13 -5
  11. package/components/PriceModifiers/PriceModifiers.tsx +38 -0
  12. package/components/PriceModifiers/PriceModifiersList.tsx +43 -0
  13. package/components/PriceModifiers/PriceModifiersListChildItem.tsx +48 -0
  14. package/components/PriceModifiers/PriceModifiersListItem.tsx +25 -0
  15. package/components/PriceModifiers/index.ts +4 -0
  16. package/components/StoreSwitcher/StoreSwitcherGroupSelector.tsx +2 -2
  17. package/components/StoreSwitcher/StoreSwitcherList.tsx +1 -1
  18. package/components/StoreSwitcher/index.ts +0 -1
  19. package/components/StoreSwitcher/useStoreSwitcher.tsx +2 -4
  20. package/components/StoreSwitcherButton/StoreSwitcherButton.tsx +1 -1
  21. package/graphql/fragments/AttributeValueFragment.graphql +13 -0
  22. package/graphql/fragments/CustomAttributeMetadata.graphql +15 -0
  23. package/{queries → graphql/fragments}/StoreConfigFragment.graphql +13 -2
  24. package/graphql/fragments/index.ts +5 -0
  25. package/graphql/index.ts +2 -0
  26. package/graphql/queries/AttributesForm.graphql +13 -0
  27. package/{queries → graphql/queries}/CountryRegions.graphql +1 -0
  28. package/graphql/queries/index.ts +5 -0
  29. package/hooks/useFindCountry.ts +2 -2
  30. package/index.ts +3 -3
  31. package/package.json +10 -10
  32. package/utils/redirectOrNotFound.ts +2 -3
  33. /package/{components/Money → graphql/fragments}/Money.graphql +0 -0
  34. /package/{queries → graphql/fragments}/StoreConfigQueryFragment.graphql +0 -0
  35. /package/{utils → graphql/queries}/HandleRedirect.graphql +0 -0
  36. /package/{queries → graphql/queries}/StoreConfig.graphql +0 -0
  37. /package/{components/StoreSwitcher → graphql/queries}/StoreSwitcherList.graphql +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Change Log
2
2
 
3
+ ## 9.1.0-canary.20
4
+
5
+ ## 9.1.0-canary.19
6
+
7
+ ### Minor Changes
8
+
9
+ - [#2499](https://github.com/graphcommerce-org/graphcommerce/pull/2499) [`9f040a4`](https://github.com/graphcommerce-org/graphcommerce/commit/9f040a4c0947f05f2f27e4c5078a684b04e711e1) - Implemented the `query { attributesForm }` to be able to dynamically render forms with useAttributesForm/preloadAttributesForm and AttributesFormAutoLayout, and additional utilities to handle form submissions. ([@paales](https://github.com/paales))
10
+
11
+ ### Patch Changes
12
+
13
+ - [#2499](https://github.com/graphcommerce-org/graphcommerce/pull/2499) [`798c122`](https://github.com/graphcommerce-org/graphcommerce/commit/798c12271e90cf0e02f021e7ad4aa088b4c7c2a3) - Use the `storeConfig.head_shortcut_icon` when configured, if not configured it will use the favicon.ico and favicon.svg from the public folder. ([@paales](https://github.com/paales))
14
+
15
+ - [#2499](https://github.com/graphcommerce-org/graphcommerce/pull/2499) [`6b2b44c`](https://github.com/graphcommerce-org/graphcommerce/commit/6b2b44ca853279144d7768067f3462d4d4bf0af1) - Created a new PriceModifiers component that is implemented on CartItems, allowing different product types to render their options in a consistent manner and allow rendering a base price so that the sum in the cart is correct. ([@paales](https://github.com/paales))
16
+
3
17
  ## 9.1.0-canary.18
4
18
 
5
19
  ## 9.1.0-canary.17
@@ -0,0 +1,27 @@
1
+ import type { Control, FieldValues } from '@graphcommerce/react-hook-form'
2
+ import type { SxProps, Theme } from '@mui/material'
3
+ import type { CustomAttributeMetadata, CustomAttributeMetadataTypename } from './useAttributesForm'
4
+
5
+ export type AttributeFormFieldProps<
6
+ TFieldValues extends FieldValues = FieldValues,
7
+ Typename extends CustomAttributeMetadataTypename = CustomAttributeMetadataTypename,
8
+ > = {
9
+ control: Control<TFieldValues>
10
+ sx?: SxProps<Theme>
11
+ metadata: CustomAttributeMetadata<Typename>
12
+ }
13
+
14
+ export function AttributeFormField<
15
+ TFieldValues extends FieldValues = FieldValues,
16
+ Typename extends CustomAttributeMetadataTypename = CustomAttributeMetadataTypename,
17
+ >(props: AttributeFormFieldProps<TFieldValues, Typename>) {
18
+ const { metadata, control, sx } = props
19
+
20
+ if (process.env.NODE_ENV === 'production') return null
21
+
22
+ return (
23
+ <div>
24
+ Please specify a renderer for {metadata.__typename}:{metadata.code}
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ nonNullable,
3
+ SectionContainer,
4
+ sxx,
5
+ type SectionContainerProps,
6
+ } from '@graphcommerce/next-ui'
7
+ import type { Control, FieldValues } from '@graphcommerce/react-hook-form'
8
+ import { Box, type SxProps, type Theme } from '@mui/material'
9
+ import { AttributeFormField, type AttributeFormFieldProps } from './AttributeFormField'
10
+ import type {
11
+ CustomAttributeMetadata,
12
+ CustomAttributeMetadataTypename,
13
+ GridArea,
14
+ } from './useAttributesForm'
15
+
16
+ export type AttributeFormAutoLayoutFieldset = {
17
+ label?: React.ReactNode
18
+ gridAreas: GridArea[]
19
+ sx?: SxProps<Theme>
20
+ }
21
+
22
+ export type AttributeFormAutoLayoutProps<
23
+ TFieldValues extends FieldValues = FieldValues,
24
+ Typename extends CustomAttributeMetadataTypename = CustomAttributeMetadataTypename,
25
+ > = {
26
+ control: Control<TFieldValues>
27
+ fieldsets?: AttributeFormAutoLayoutFieldset[]
28
+ attributes: CustomAttributeMetadata<Typename>[]
29
+ render?: React.FC<AttributeFormFieldProps<NoInfer<TFieldValues>, Typename>>
30
+ sectionContainer?: Omit<SectionContainerProps, 'labelLeft'>
31
+ }
32
+
33
+ export function AttributesFormAutoLayout<
34
+ TFieldValues extends FieldValues = FieldValues,
35
+ Typename extends CustomAttributeMetadataTypename = CustomAttributeMetadataTypename,
36
+ >(props: AttributeFormAutoLayoutProps<TFieldValues, Typename>) {
37
+ const {
38
+ control,
39
+ fieldsets,
40
+ attributes: incomingAttributes,
41
+ render: Component = AttributeFormField,
42
+ sectionContainer,
43
+ } = props
44
+
45
+ let itemsRemaining = incomingAttributes
46
+ const byFieldSet = (fieldsets ?? []).map(({ gridAreas, ...rest }) => ({
47
+ ...rest,
48
+ attributes: gridAreas
49
+ .map((gridArea) => {
50
+ const item = itemsRemaining.find((i) => i.gridArea === gridArea)
51
+ if (item) itemsRemaining = itemsRemaining.filter((i) => i.gridArea !== item.gridArea)
52
+ return item
53
+ })
54
+ .filter(nonNullable),
55
+ }))
56
+
57
+ if (itemsRemaining.length > 0) byFieldSet.push({ label: 'Other', attributes: itemsRemaining })
58
+
59
+ return byFieldSet.map((fieldSet) => {
60
+ const key = fieldSet.attributes.map((fieldName) => fieldName.gridArea).join('-')
61
+
62
+ const fields = (
63
+ <Box
64
+ sx={sxx(
65
+ (theme) => ({
66
+ display: 'grid',
67
+ gridTemplateAreas: fieldSet.attributes
68
+ .map((metadata) => `"${metadata.gridArea}"`)
69
+ .join('\n'),
70
+ py: theme.spacings.xs,
71
+ columnGap: theme.spacings.xs,
72
+ rowGap: theme.spacings.xxs,
73
+ }),
74
+ fieldSet.sx,
75
+ )}
76
+ >
77
+ {fieldSet.attributes.map((metadata) => (
78
+ <Component key={metadata.gridArea} control={control} metadata={metadata} />
79
+ ))}
80
+ </Box>
81
+ )
82
+
83
+ return fieldSet.label ? (
84
+ <SectionContainer labelLeft={fieldSet.label} key={key} {...sectionContainer}>
85
+ {fields}
86
+ </SectionContainer>
87
+ ) : (
88
+ fields
89
+ )
90
+ })
91
+ }
@@ -0,0 +1,3 @@
1
+ export * from './AttributesFormAutoLayout'
2
+ export * from './useAttributesForm'
3
+ export * from './preloadAttributesForm'
@@ -0,0 +1,10 @@
1
+ import type { ApolloClient, NormalizedCacheObject } from '@graphcommerce/graphql'
2
+ import { AttributesFormDocument } from '../../graphql'
3
+ import type { CustomAttributeFormCode } from './useAttributesForm'
4
+
5
+ export function preloadAttributesForm(
6
+ client: ApolloClient<NormalizedCacheObject>,
7
+ formCode: CustomAttributeFormCode,
8
+ ) {
9
+ return client.query({ query: AttributesFormDocument, variables: { formCode } })
10
+ }
@@ -0,0 +1,266 @@
1
+ import { useQuery } from '@graphcommerce/graphql'
2
+ import type { AttributeValueInput } from '@graphcommerce/graphql-mesh'
3
+ import { nonNullable, useMemoObject } from '@graphcommerce/next-ui'
4
+ import { useMemo } from 'react'
5
+ import { AttributesFormDocument } from '../../graphql'
6
+ import type { AttributeValueFragment, CustomAttributeMetadataFragment } from '../../graphql'
7
+
8
+ type CustomAttributesFormFieldValue = string | string[] | boolean | undefined
9
+ type CustomAttributesFormField = Record<string, CustomAttributesFormFieldValue>
10
+
11
+ export type CustomAttributesFormValues<
12
+ AdditionalKnownAttributes extends Record<string, unknown> = Record<string, unknown>,
13
+ > = {
14
+ custom_attributes?: CustomAttributesFormField & AdditionalKnownAttributes
15
+ }
16
+
17
+ /**
18
+ * Convert the GraphQL custom_attributes field to the CustomAttributesFormField so the data is
19
+ * correctly mapped to the form.
20
+ *
21
+ * So for example it maps this data:
22
+ *
23
+ * ```graphql
24
+ * type Customer {
25
+ * """Customer's custom attributes."""
26
+ * custom_attributes(attributeCodes: [ID!]): [AttributeValueInterface]
27
+ * }
28
+ * ```
29
+ *
30
+ * To a format that is compatible with the useForm hook with the `CustomAttributesFormValues` type.
31
+ */
32
+ export function AttributesValueArray_to_CustomAttributesFormField(
33
+ attributes: CustomAttributeMetadata[],
34
+ custom_attributes: (AttributeValueFragment | null | undefined)[] | null | undefined,
35
+ ): CustomAttributesFormField {
36
+ const result: CustomAttributesFormField = {}
37
+
38
+ attributes.forEach((metadata) => {
39
+ const attr = (custom_attributes ?? [])
40
+ .filter(nonNullable)
41
+ .find((ca) => ca.code === metadata.code)
42
+
43
+ // Handle multiselect
44
+ if (
45
+ metadata.frontend_input === 'MULTISELECT' ||
46
+ attr?.__typename === 'AttributeSelectedOptions'
47
+ ) {
48
+ const value =
49
+ attr?.__typename === 'AttributeSelectedOptions'
50
+ ? (attr?.selected_options ?? [])
51
+ .filter(nonNullable)
52
+ .map((option) => option.value)
53
+ .filter((v) => !!v)
54
+ : metadata.options
55
+ ?.filter(nonNullable)
56
+ .filter((o) => o.is_default)
57
+ .map((o) => o.value)
58
+
59
+ result[metadata.code] = value
60
+ return
61
+ }
62
+
63
+ // Handle MULTILINE fields
64
+ if (metadata.frontend_input === 'MULTILINE' && metadata.index !== undefined) {
65
+ const value = attr?.value.split('\n')[metadata.index]
66
+ const valueArray = result[metadata.code] ?? []
67
+ valueArray[metadata.index] = value ?? metadata.default_value ?? undefined
68
+ result[metadata.code] = valueArray
69
+ return
70
+ }
71
+
72
+ // Handle DATE fields
73
+ if (metadata.frontend_input === 'DATE' && attr?.__typename === 'AttributeValue' && attr.value) {
74
+ const [date] = attr.value.split(' ')
75
+ result[metadata.code] = date
76
+ return
77
+ }
78
+
79
+ // Handle regular fields
80
+ result[metadata.code] = attr?.value ?? metadata.default_value ?? undefined
81
+ })
82
+
83
+ return result
84
+ }
85
+
86
+ /**
87
+ * Covert the CustomAttributesFormField to the AttributeValueInput[] so the data is correctly mapped
88
+ * To the Magento GraphQL mutation.
89
+ *
90
+ * So the CustomAttributesFormField is converted to the input for a mutation which can look lke:s
91
+ *
92
+ * ```graphql
93
+ * input CustomerUpdateInput {
94
+ * """The customer's custom attributes."""
95
+ * custom_attributes: [AttributeValueInput]
96
+ * }
97
+ * ```
98
+ */
99
+ export function CustomAttributesField_to_AttributeValueInputs(
100
+ attributes: CustomAttributeMetadata[],
101
+ custom_attributes: CustomAttributesFormField,
102
+ ): AttributeValueInput[] {
103
+ const values: Record<string, AttributeValueInput> = {}
104
+
105
+ attributes.forEach((metadata) => {
106
+ const attribute_code = metadata.code
107
+ const value = custom_attributes[metadata.code]
108
+
109
+ if (!value) return
110
+
111
+ if (
112
+ (metadata.__typename === 'CustomerAttributeMetadata' &&
113
+ metadata.frontend_input === 'BOOLEAN') ||
114
+ typeof value === 'boolean'
115
+ ) {
116
+ values[metadata.code] = { attribute_code, value: value ? '1' : '0' }
117
+ return
118
+ }
119
+
120
+ if (Array.isArray(value)) {
121
+ if (metadata.frontend_input === 'MULTILINE') {
122
+ values[metadata.code] = { attribute_code, value: value.join('\n') }
123
+ }
124
+
125
+ if (metadata.frontend_input === 'MULTISELECT') {
126
+ values[metadata.code] = { attribute_code, value: value.join(',') }
127
+ }
128
+ } else {
129
+ values[metadata.code] = { attribute_code, value }
130
+ }
131
+ })
132
+
133
+ return Object.values(values)
134
+ }
135
+
136
+ export type CustomAttributeFormCode = (
137
+ | 'customer_account_create'
138
+ | 'customer_account_edit'
139
+ | 'customer_address_edit'
140
+ | 'customer_register_address'
141
+ ) &
142
+ (string & {})
143
+
144
+ /**
145
+ * Magento attribute code, the actual possible values are deterimined in the database of Magento and
146
+ * therefor are now known at compile time.
147
+ */
148
+ export type MagentoAttributeCode = string
149
+
150
+ /**
151
+ * For each field a gridArea is set. Used for the name of the field that doesn't contain any dots.
152
+ * etc.
153
+ */
154
+ export type GridArea = string
155
+
156
+ export type CustomAttributeMetadataTypename = CustomAttributeMetadataFragment['__typename']
157
+
158
+ export type CustomAttributeMetadata<
159
+ Typename extends CustomAttributeMetadataTypename = CustomAttributeMetadataTypename,
160
+ > = Extract<CustomAttributeMetadataFragment, { __typename: Typename }> & {
161
+ name: string
162
+ index?: number
163
+ gridArea: GridArea
164
+ }
165
+
166
+ export type UseAttributesFormConfig<
167
+ Typename extends CustomAttributeMetadata['__typename'] = CustomAttributeMetadata['__typename'],
168
+ > = {
169
+ /** The form code that is used to retrieve the attributes */
170
+ formCode: CustomAttributeFormCode | (string & Record<never, never>)
171
+ /** A list of attribute codes that should be excluded from the form completely */
172
+ exclude?: MagentoAttributeCode[]
173
+
174
+ typename?: Typename
175
+
176
+ attributeToName?: Record<MagentoAttributeCode, string>
177
+
178
+ customizeAttributes?: (
179
+ attribute: CustomAttributeMetadata<Typename>,
180
+ ) => CustomAttributeMetadata<Typename>
181
+ }
182
+
183
+ /**
184
+ * Retrieve the available attributes.
185
+ *
186
+ * Example:
187
+ *
188
+ * ```tsx
189
+ * const attributes = useAttributesForm({
190
+ * formCode: 'customer_account_create',
191
+ * exclude: ['email'],
192
+ * })
193
+ * ```
194
+ *
195
+ * Note: When using this hook, make sure the page also calls preloadAttributesForm in getStaticProps
196
+ * so the query doesn't need to run in the browser. Example:
197
+ *
198
+ * ```tsx:
199
+ * const client = graphqlSharedClient(context)
200
+ * await preloadAttributesForm(client, 'customer_account_create')
201
+ * ```
202
+ */
203
+ export function useAttributesForm<
204
+ Typename extends CustomAttributeMetadata['__typename'] = CustomAttributeMetadata['__typename'],
205
+ >({
206
+ customizeAttributes,
207
+ ...incomingConfig
208
+ }: UseAttributesFormConfig<Typename>): CustomAttributeMetadata<Typename>[] {
209
+ const config = useMemoObject(incomingConfig)
210
+
211
+ const { data } = useQuery(AttributesFormDocument, {
212
+ variables: { formCode: config.formCode },
213
+ })
214
+
215
+ return useMemo(() => {
216
+ const items = (data?.attributesForm?.items ?? [])
217
+ .filter(nonNullable)
218
+ .filter((item) => !config.exclude?.includes(item.code))
219
+ .map((item) => ({
220
+ ...item,
221
+ gridArea: item.code,
222
+ name: config.attributeToName?.[item.code] ?? `custom_attributes.${item.code}`,
223
+ }))
224
+
225
+ /**
226
+ * We handle frontend_input=MULTILINE input fields a bit different as they have multiple values.
227
+ * In this case we replace the original field and add multiple fields for the same attributes.
228
+ */
229
+ const newItems = items
230
+ .map((item) => {
231
+ if (
232
+ item.frontend_input === 'MULTILINE' &&
233
+ item.__typename === 'CustomerAttributeMetadata' &&
234
+ item.multiline_count
235
+ ) {
236
+ const defaultValues = item.default_value?.split('\n') ?? []
237
+ return Array.from({ length: item.multiline_count }, (_, index) => ({
238
+ ...item,
239
+ label: index === 0 ? item.label : undefined,
240
+ index,
241
+ gridArea: `${item.code}_${index}`,
242
+ name: `custom_attributes.${item.code}.${index}`,
243
+ default_value: defaultValues[index] ?? undefined,
244
+ }))
245
+ }
246
+ return [item]
247
+ })
248
+ .flat(1)
249
+
250
+ return newItems
251
+ .filter((item): item is CustomAttributeMetadata<Typename> =>
252
+ config.typename ? item.__typename === config.typename : true,
253
+ )
254
+ .map((attribute) => customizeAttributes?.(attribute) || attribute)
255
+ }, [data?.attributesForm?.items, config, customizeAttributes])
256
+ }
257
+
258
+ export function extractAttributes(
259
+ attributes: CustomAttributeMetadataFragment[],
260
+ attributeCodes: MagentoAttributeCode[],
261
+ ): [extracted: CustomAttributeMetadataFragment[], remaining: CustomAttributeMetadataFragment[]] {
262
+ return [
263
+ attributes.filter((attribute) => attributeCodes.includes(attribute.code)),
264
+ attributes.filter((attribute) => !attributeCodes.includes(attribute.code)),
265
+ ]
266
+ }
@@ -3,7 +3,7 @@ import {
3
3
  CurrencySymbol as CurrencySymbolBase,
4
4
  type CurrencySymbolProps,
5
5
  } from '@graphcommerce/next-ui'
6
- import { StoreConfigDocument } from '../../queries/StoreConfig.gql'
6
+ import { StoreConfigDocument } from '../../graphql'
7
7
 
8
8
  export function CurrencySymbol(props: CurrencySymbolProps) {
9
9
  const { currency } = props
@@ -1,11 +1,34 @@
1
1
  import { useQuery } from '@graphcommerce/graphql'
2
2
  import type { GlobalHeadProps as GlobalHeadPropsBase } from '@graphcommerce/next-ui'
3
3
  import { GlobalHead as GlobalHeadBase } from '@graphcommerce/next-ui'
4
- import { StoreConfigDocument } from '../../queries/StoreConfig.gql'
4
+ // eslint-disable-next-line @typescript-eslint/no-restricted-imports
5
+ import { getImageProps } from 'next/image'
6
+ import React from 'react'
7
+ import { StoreConfigDocument } from '../../graphql'
5
8
 
6
9
  export type GlobalHeadProps = Omit<GlobalHeadPropsBase, 'name'>
7
10
 
8
- export function GlobalHead(props: GlobalHeadProps) {
11
+ export const GlobalHead = React.memo<GlobalHeadProps>((props) => {
12
+ const { children } = props
9
13
  const name = useQuery(StoreConfigDocument).data?.storeConfig?.website_name ?? ''
10
- return <GlobalHeadBase name={name} {...props} />
11
- }
14
+
15
+ const { head_shortcut_icon, secure_base_media_url } =
16
+ useQuery(StoreConfigDocument).data?.storeConfig ?? {}
17
+
18
+ let icon: string | undefined
19
+ if (head_shortcut_icon && secure_base_media_url) {
20
+ icon = getImageProps({
21
+ src: `${secure_base_media_url}favicon/${head_shortcut_icon}`,
22
+ alt: 'favicon',
23
+ width: 16,
24
+ height: 16,
25
+ }).props.src
26
+ }
27
+
28
+ return (
29
+ <GlobalHeadBase name={name} {...props}>
30
+ <link rel='icon' href={icon ?? '/favicon.ico'} sizes='any' key='icon' />
31
+ {!icon && <link rel='icon' href='/favicon.svg' type='image/svg+xml' key='icon-svg' />}
32
+ </GlobalHeadBase>
33
+ )
34
+ })
@@ -2,8 +2,8 @@ import { useQuery } from '@graphcommerce/graphql'
2
2
  import type { CurrencyFormatProps } from '@graphcommerce/next-ui'
3
3
  import { CurrencyFormat } from '@graphcommerce/next-ui'
4
4
  import type { SxProps, Theme } from '@mui/material'
5
- import { StoreConfigDocument } from '../../queries/StoreConfig.gql'
6
- import type { MoneyFragment } from './Money.gql'
5
+ import type { MoneyFragment } from '../../graphql'
6
+ import { StoreConfigDocument } from '../../graphql'
7
7
 
8
8
  type OverridableProps = {
9
9
  round?: boolean
@@ -1,14 +1,16 @@
1
1
  import { useQuery } from '@graphcommerce/graphql'
2
2
  import type { PageMetaProps as NextPageMetaProps } from '@graphcommerce/next-ui'
3
3
  import { PageMeta as NextPageMeta } from '@graphcommerce/next-ui'
4
- import { StoreConfigDocument } from '../../queries/StoreConfig.gql'
4
+ import { StoreConfigDocument } from '../../graphql'
5
5
 
6
- export type PageMetaProps = Omit<NextPageMetaProps, 'canonical'> & {
7
- canonical?: string
6
+ export type PageMetaProps = Omit<NextPageMetaProps, 'metaDescription' | 'title' | 'canonical'> & {
7
+ metaDescription?: string | null
8
+ title?: string | null
9
+ canonical?: string | null
8
10
  }
9
11
 
10
12
  export function PageMeta(props: PageMetaProps) {
11
- const { children, title, ...pageMetaProps } = props
13
+ const { children, title, metaDescription, metaKeywords, canonical, ...pageMetaProps } = props
12
14
  const config = useQuery(StoreConfigDocument)
13
15
 
14
16
  const prefix = config.data?.storeConfig?.title_prefix ?? ''
@@ -22,7 +24,13 @@ export function PageMeta(props: PageMetaProps) {
22
24
  if (suffix) pageTitle += ` ${suffix}`
23
25
 
24
26
  return (
25
- <NextPageMeta title={pageTitle ?? ''} {...pageMetaProps}>
27
+ <NextPageMeta
28
+ title={pageTitle}
29
+ metaDescription={metaDescription ?? config.data?.storeConfig?.default_description}
30
+ metaKeywords={metaKeywords ?? config.data?.storeConfig?.default_keywords}
31
+ canonical={canonical ?? undefined}
32
+ {...pageMetaProps}
33
+ >
26
34
  {children}
27
35
  </NextPageMeta>
28
36
  )
@@ -0,0 +1,38 @@
1
+ import type { CurrencyEnum } from '@graphcommerce/graphql-mesh'
2
+
3
+ export function sumPriceModifiers(modifiers: PriceModifier[]) {
4
+ return modifiers.reduce(
5
+ (price, mod) =>
6
+ price +
7
+ (mod.amount ?? 0) * (mod.quantity ?? 1) +
8
+ (mod.items ?? []).reduce(
9
+ (itemPrice, item) => itemPrice + (item.amount ?? 0) * (item.quantity ?? 1),
10
+ 0,
11
+ ),
12
+ 0,
13
+ )
14
+ }
15
+
16
+ export type PriceModifierItem = {
17
+ key: string
18
+ label: React.ReactNode
19
+ secondary?: React.ReactNode
20
+ quantity?: number
21
+ amount?: number
22
+ }
23
+
24
+ export type PriceModifier = {
25
+ position?: number
26
+ key: string
27
+ label?: React.ReactNode
28
+ amount?: number
29
+ quantity?: number
30
+ items?: PriceModifierItem[]
31
+ }
32
+
33
+ export type PriceModifiersProps = {
34
+ label: React.ReactNode
35
+ total: number
36
+ currency: CurrencyEnum | null | undefined
37
+ modifiers: PriceModifier[]
38
+ }
@@ -0,0 +1,43 @@
1
+ import { sumPriceModifiers, type PriceModifiersProps } from './PriceModifiers'
2
+ import { PriceModifierListChildItem } from './PriceModifiersListChildItem'
3
+ import { PriceModifierListItem } from './PriceModifiersListItem'
4
+
5
+ /**
6
+ * A utility component to display price modifiers in a table format. Used for:
7
+ *
8
+ * Abstracts aways rendering from the price calculations.
9
+ *
10
+ * Renders a base price if it's different from the row total.
11
+ *
12
+ * - `<CartItemActionCard />`
13
+ * - `<OrderItem />`
14
+ * - `<InvoiceItem />`
15
+ * - `<CreditMemoItem />`
16
+ * - `<ShipmentItem />`
17
+ * - `<ReturnItem />`
18
+ */
19
+ export function PriceModifiersList(props: PriceModifiersProps) {
20
+ const { total: row_total, currency, modifiers, label } = props
21
+ const basePrice = row_total - sumPriceModifiers(modifiers)
22
+
23
+ const sortedModifiers = [...modifiers].sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
24
+
25
+ if (modifiers.length === 0) return null
26
+
27
+ return (
28
+ <>
29
+ {basePrice > 0 && basePrice !== row_total && (
30
+ <PriceModifierListChildItem
31
+ key='base-price'
32
+ label={label}
33
+ amount={basePrice}
34
+ currency={currency}
35
+ color='text.primary'
36
+ />
37
+ )}
38
+ {sortedModifiers.map((mod) => (
39
+ <PriceModifierListItem key={mod.key} label={mod.label} items={mod.items} />
40
+ ))}
41
+ </>
42
+ )
43
+ }
@@ -0,0 +1,48 @@
1
+ import type { CurrencyEnum } from '@graphcommerce/graphql-mesh'
2
+ import { extendableComponent, type ExtendableComponentProps } from '@graphcommerce/next-ui'
3
+ import { Box } from '@mui/material'
4
+ import { Money } from '../Money/Money'
5
+ import type { PriceModifierItem } from './PriceModifiers'
6
+
7
+ const name = 'PriceModifierListChildItem'
8
+ const slots = ['root', 'label', 'quantity', 'amount'] as const
9
+ const { withState } = extendableComponent(name, slots)
10
+
11
+ type PriceModifierListChildItemProps = Omit<PriceModifierItem, 'key'> & {
12
+ currency?: CurrencyEnum | null | undefined
13
+ color?: 'text.primary' | 'text.secondary'
14
+ } & ExtendableComponentProps<typeof slots>
15
+
16
+ export function PriceModifierListChildItem(props: PriceModifierListChildItemProps) {
17
+ const { label, amount = 0, quantity = 1, secondary, currency, color = 'text.secondary' } = props
18
+
19
+ const classes = withState(props)
20
+
21
+ return (
22
+ <Box className={classes.root} sx={(theme) => ({ display: 'flex', gap: theme.spacings.xxs })}>
23
+ <Box className={classes.label} sx={{ color }}>
24
+ {label}
25
+ </Box>
26
+
27
+ {(quantity > 1 || secondary) && (
28
+ <Box className={classes.quantity} sx={{ color: 'text.secondary', whiteSpace: 'nowrap' }}>
29
+ {quantity > 1 && (
30
+ <>
31
+ {quantity} &times; <Money value={amount} currency={currency} />
32
+ </>
33
+ )}
34
+ {secondary}
35
+ </Box>
36
+ )}
37
+
38
+ {amount !== 0 && amount && (
39
+ <Box
40
+ className={classes.amount}
41
+ sx={(theme) => ({ position: 'absolute', right: theme.spacings.xs })}
42
+ >
43
+ <Money value={quantity * amount} currency={currency} />
44
+ </Box>
45
+ )}
46
+ </Box>
47
+ )
48
+ }
@@ -0,0 +1,25 @@
1
+ import { extendableComponent, type ExtendableComponentProps } from '@graphcommerce/next-ui'
2
+ import { Box } from '@mui/material'
3
+ import type { PriceModifier } from './PriceModifiers'
4
+ import { PriceModifierListChildItem } from './PriceModifiersListChildItem'
5
+
6
+ const name = 'PriceModifierListItem'
7
+ const slots = ['root'] as const
8
+ const { withState } = extendableComponent(name, slots)
9
+
10
+ type PriceModifierListItemProps = ExtendableComponentProps<typeof slots> & PriceModifier
11
+
12
+ export function PriceModifierListItem(props: PriceModifierListItemProps) {
13
+ const { label, items } = props
14
+
15
+ const classes = withState(props)
16
+
17
+ return (
18
+ <Box className={classes.root}>
19
+ {label && <PriceModifierListChildItem label={label} color='text.primary' />}
20
+ {items?.map(({ key: itemKey, ...item }) => (
21
+ <PriceModifierListChildItem key={itemKey} {...item} color='text.secondary' />
22
+ ))}
23
+ </Box>
24
+ )
25
+ }
@@ -0,0 +1,4 @@
1
+ export * from './PriceModifiers'
2
+ export * from './PriceModifiersList'
3
+ export * from './PriceModifiersListItem'
4
+ export * from './PriceModifiersListChildItem'
@@ -42,7 +42,7 @@ export function StoreSwitcherGroupSelector(props: StoreSwitcherGroupSelectorProp
42
42
  group,
43
43
  image: group.country ? <FlagAvatar country={group.country} size='40px' /> : undefined,
44
44
  title: <>{group.store_group_name}</>,
45
- details: showStores && (
45
+ details: showStores ? (
46
46
  <>
47
47
  {group.stores.length <= showStores && (
48
48
  <ListFormat listStyle='short' type='unit'>
@@ -59,7 +59,7 @@ export function StoreSwitcherGroupSelector(props: StoreSwitcherGroupSelectorProp
59
59
  </ListFormat>
60
60
  )}
61
61
  </>
62
- ),
62
+ ) : undefined,
63
63
  disabled: group.disabled,
64
64
  value: group.store_group_code,
65
65
  slotProps: {
@@ -3,8 +3,8 @@ import { extendableComponent, FlagAvatar, NextLink } from '@graphcommerce/next-u
3
3
  import type { SxProps, Theme } from '@mui/material'
4
4
  import { Collapse, List, ListItemAvatar, ListItemButton, ListItemText } from '@mui/material'
5
5
  import React from 'react'
6
+ import type { StoreSwitcherListQuery } from '../../graphql'
6
7
  import { localeToStore, storeToLocale } from '../../utils/localeToStore'
7
- import type { StoreSwitcherListQuery } from './StoreSwitcherList.gql'
8
8
 
9
9
  type Store = NonNullable<NonNullable<StoreSwitcherListQuery['availableStores']>[0]>
10
10
 
@@ -2,6 +2,5 @@ export * from './StoreSwitcherApply'
2
2
  export * from './StoreSwitcherCurrencySelector'
3
3
  export * from './StoreSwitcherGroupSelector'
4
4
  export * from './StoreSwitcherList'
5
- export * from './StoreSwitcherList.gql'
6
5
  export * from './StoreSwitcherStoreSelector'
7
6
  export * from './useStoreSwitcher'
@@ -8,10 +8,8 @@ import {
8
8
  useStorefrontConfig,
9
9
  type RequiredKeys,
10
10
  } from '@graphcommerce/next-ui'
11
- import { useRouter } from 'next/router'
12
- import { createContext, useContext, useEffect, useLayoutEffect, useMemo } from 'react'
13
- import { storeToLocale } from '../../utils/localeToStore'
14
- import { type StoreSwitcherListQuery } from './StoreSwitcherList.gql'
11
+ import { createContext, useContext, useMemo } from 'react'
12
+ import { type StoreSwitcherListQuery } from '../../graphql'
15
13
 
16
14
  export type StoreSwitcherStore = RequiredKeys<
17
15
  NonNullable<NonNullable<StoreSwitcherListQuery['availableStores']>[0]>,
@@ -4,7 +4,7 @@ import type { SxProps, Theme } from '@mui/material'
4
4
  import { Button } from '@mui/material'
5
5
  import { useRouter } from 'next/router'
6
6
  import { useMemo } from 'react'
7
- import { StoreConfigDocument } from '../../queries/StoreConfig.gql'
7
+ import { StoreConfigDocument } from '../../graphql'
8
8
 
9
9
  export type StoreSwitcherButtonProps = { sx?: SxProps<Theme> }
10
10
 
@@ -0,0 +1,13 @@
1
+ fragment AttributeValueFragment on AttributeValueInterface {
2
+ __typename
3
+ code
4
+ ... on AttributeValue {
5
+ value
6
+ }
7
+ ... on AttributeSelectedOptions {
8
+ selected_options {
9
+ label
10
+ value
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,15 @@
1
+ fragment CustomAttributeMetadata on CustomAttributeMetadataInterface {
2
+ __typename
3
+ code
4
+ default_value
5
+ frontend_class
6
+ frontend_input
7
+ is_required
8
+ is_unique
9
+ label
10
+ options {
11
+ is_default
12
+ label
13
+ value
14
+ }
15
+ }
@@ -3,21 +3,32 @@ fragment StoreConfigFragment on StoreConfig {
3
3
  store_code
4
4
  store_name
5
5
 
6
+ header_logo_src
7
+ logo_width
8
+ logo_height
9
+ logo_alt
10
+
11
+ copyright
12
+
6
13
  locale
7
14
  base_currency_code
15
+
8
16
  default_display_currency_code
9
17
 
18
+ head_shortcut_icon
19
+ head_includes
10
20
  title_suffix
11
21
  title_prefix
12
22
  title_separator
13
23
  default_title
14
-
15
- cms_home_page
24
+ default_description
25
+ default_keywords
16
26
 
17
27
  catalog_default_sort_by
18
28
  category_url_suffix
19
29
  product_url_suffix
20
30
  secure_base_link_url
31
+ secure_base_media_url
21
32
  secure_base_url
22
33
 
23
34
  root_category_uid
@@ -0,0 +1,5 @@
1
+ export * from './AttributeValueFragment.gql'
2
+ export * from './CustomAttributeMetadata.gql'
3
+ export * from './Money.gql'
4
+ export * from './StoreConfigFragment.gql'
5
+ export * from './StoreConfigQueryFragment.gql'
@@ -0,0 +1,2 @@
1
+ export * from './fragments'
2
+ export * from './queries'
@@ -0,0 +1,13 @@
1
+ query AttributesForm($formCode: String!) {
2
+ attributesForm(formCode: $formCode) {
3
+ errors {
4
+ message
5
+ type
6
+ }
7
+ items {
8
+ __typename
9
+ code
10
+ ...CustomAttributeMetadata
11
+ }
12
+ }
13
+ }
@@ -1,5 +1,6 @@
1
1
  query CountryRegions {
2
2
  countries {
3
+ id
3
4
  full_name_locale
4
5
  two_letter_abbreviation
5
6
  three_letter_abbreviation
@@ -0,0 +1,5 @@
1
+ export * from './AttributesForm.gql'
2
+ export * from './CountryRegions.gql'
3
+ export * from './HandleRedirect.gql'
4
+ export * from './StoreConfig.gql'
5
+ export * from './StoreSwitcherList.gql'
@@ -1,6 +1,6 @@
1
1
  import { useQuery } from '@graphcommerce/graphql'
2
- import type { CountryRegionsQuery } from '../queries/CountryRegions.gql'
3
- import { CountryRegionsDocument } from '../queries/CountryRegions.gql'
2
+ import type { CountryRegionsQuery } from '../graphql'
3
+ import { CountryRegionsDocument } from '../graphql'
4
4
 
5
5
  export function useFindCountry(
6
6
  countryCode?: string | null,
package/index.ts CHANGED
@@ -1,13 +1,13 @@
1
+ export * from './components/AttributeForm'
1
2
  export * from './components/CurrencySymbol/CurrencySymbol'
2
3
  export * from './components/GlobalHead/GlobalHead'
3
4
  export * from './components/Money/Money'
4
- export * from './components/Money/Money.gql'
5
+ export * from './graphql'
5
6
  export * from './components/PageMeta/PageMeta'
6
7
  export * from './components/StoreSwitcherButton/StoreSwitcherButton'
7
8
  export * from './hooks/useFindCountry'
8
9
  export * from './hooks/useFindRegion'
9
- export * from './queries/CountryRegions.gql'
10
- export * from './queries/StoreConfig.gql'
11
10
  export * from './utils/localeToStore'
12
11
  export * from './utils/redirectOrNotFound'
13
12
  export * from './components/StoreSwitcher'
13
+ export * from './components/PriceModifiers'
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@graphcommerce/magento-store",
3
3
  "homepage": "https://www.graphcommerce.org/",
4
4
  "repository": "github:graphcommerce-org/graphcommerce",
5
- "version": "9.1.0-canary.18",
5
+ "version": "9.1.0-canary.20",
6
6
  "sideEffects": false,
7
7
  "prettier": "@graphcommerce/prettier-config-pwa",
8
8
  "eslintConfig": {
@@ -12,15 +12,15 @@
12
12
  }
13
13
  },
14
14
  "peerDependencies": {
15
- "@graphcommerce/ecommerce-ui": "^9.1.0-canary.18",
16
- "@graphcommerce/eslint-config-pwa": "^9.1.0-canary.18",
17
- "@graphcommerce/framer-utils": "^9.1.0-canary.18",
18
- "@graphcommerce/graphql": "^9.1.0-canary.18",
19
- "@graphcommerce/graphql-mesh": "^9.1.0-canary.18",
20
- "@graphcommerce/image": "^9.1.0-canary.18",
21
- "@graphcommerce/next-ui": "^9.1.0-canary.18",
22
- "@graphcommerce/prettier-config-pwa": "^9.1.0-canary.18",
23
- "@graphcommerce/typescript-config-pwa": "^9.1.0-canary.18",
15
+ "@graphcommerce/ecommerce-ui": "^9.1.0-canary.20",
16
+ "@graphcommerce/eslint-config-pwa": "^9.1.0-canary.20",
17
+ "@graphcommerce/framer-utils": "^9.1.0-canary.20",
18
+ "@graphcommerce/graphql": "^9.1.0-canary.20",
19
+ "@graphcommerce/graphql-mesh": "^9.1.0-canary.20",
20
+ "@graphcommerce/image": "^9.1.0-canary.20",
21
+ "@graphcommerce/next-ui": "^9.1.0-canary.20",
22
+ "@graphcommerce/prettier-config-pwa": "^9.1.0-canary.20",
23
+ "@graphcommerce/typescript-config-pwa": "^9.1.0-canary.20",
24
24
  "@lingui/core": "^4.2.1",
25
25
  "@lingui/macro": "^4.2.1",
26
26
  "@lingui/react": "^4.2.1",
@@ -3,9 +3,8 @@ import type { ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@gr
3
3
  import { flushMeasurePerf } from '@graphcommerce/graphql'
4
4
  import { isTypename, nonNullable, storefrontConfig } from '@graphcommerce/next-ui'
5
5
  import type { Redirect } from 'next'
6
- import type { StoreConfigQuery } from '../queries/StoreConfig.gql'
7
- import type { HandleRedirectQuery } from './HandleRedirect.gql'
8
- import { HandleRedirectDocument } from './HandleRedirect.gql'
6
+ import type { HandleRedirectQuery, StoreConfigQuery } from '../graphql'
7
+ import { HandleRedirectDocument } from '../graphql'
9
8
  import { defaultLocale } from './localeToStore'
10
9
 
11
10
  export type RedirectOr404Return = Promise<