@faststore/core 4.3.0-dev.1 → 4.3.0-dev.3

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 (32) hide show
  1. package/.turbo/turbo-generate.log +4 -4
  2. package/.turbo/turbo-test.log +99 -29
  3. package/@generated/cached-operations.json +3 -1
  4. package/@generated/gql.ts +29 -5
  5. package/@generated/graphql.ts +129 -8
  6. package/@generated/persisted-documents.json +5 -1
  7. package/@generated/schema.graphql +69 -0
  8. package/CHANGELOG.md +12 -0
  9. package/cms/faststore/pages/cms_content_type__landingpage.jsonc +6 -0
  10. package/cms/faststore/schema.json +123 -91
  11. package/cms/faststore/sections.json +5 -0
  12. package/package.json +6 -6
  13. package/src/components/auth/ProfileChallenge/ProfileChallenge.tsx +2 -2
  14. package/src/components/search/SearchInput/SearchInput.tsx +139 -332
  15. package/src/components/sections/Navbar/Navbar.tsx +1 -0
  16. package/src/components/sections/Navbar/section.module.scss +3 -0
  17. package/src/sdk/auth/index.ts +2 -2
  18. package/src/sdk/orderEntry/useOrderEntry.ts +58 -0
  19. package/src/sdk/orderEntry/useOrderEntryOperation.ts +132 -0
  20. package/src/sdk/orderEntry/useOrderEntryUpload.ts +96 -0
  21. package/src/sdk/orderEntry/useOrderFormItems.ts +57 -0
  22. package/src/styles/global/index.scss +16 -0
  23. package/test/components/auth/ProfileChallenge.test.tsx +51 -0
  24. package/test/components/search/SearchInput.test.ts +72 -0
  25. package/test/components/search/SearchInputComponent.test.tsx +276 -0
  26. package/test/sdk/auth/useAuth.test.ts +94 -0
  27. package/test/sdk/orderEntry/useOrderEntry.test.ts +139 -0
  28. package/test/sdk/orderEntry/useOrderEntryOperation.test.ts +205 -0
  29. package/test/sdk/orderEntry/useOrderEntryUpload.test.ts +142 -0
  30. package/test/sdk/orderEntry/useOrderFormItems.test.ts +132 -0
  31. package/test/server/index.test.ts +4 -0
  32. package/src/sdk/product/useBulkProductsQuery.ts +0 -128
@@ -0,0 +1,58 @@
1
+ import { useCallback, useState } from 'react'
2
+
3
+ import { useCart } from '../cart'
4
+ import { useOrderEntryOperation } from './useOrderEntryOperation'
5
+ import { useOrderEntryUpload } from './useOrderEntryUpload'
6
+
7
+ export function useOrderEntry() {
8
+ const cart = useCart()
9
+
10
+ const [isOperationStarting, setIsOperationStarting] = useState(false)
11
+
12
+ const {
13
+ uploadFile,
14
+ isUploading,
15
+ error: uploadError,
16
+ clearError: clearUploadError,
17
+ } = useOrderEntryUpload()
18
+ const {
19
+ startOperation,
20
+ status,
21
+ isLoading: isOperating,
22
+ error: operationError,
23
+ reset: resetOperation,
24
+ } = useOrderEntryOperation()
25
+
26
+ const reset = useCallback(() => {
27
+ clearUploadError()
28
+ resetOperation()
29
+ setIsOperationStarting(false)
30
+ }, [clearUploadError, resetOperation])
31
+
32
+ const submitFile = useCallback(
33
+ async (file: File) => {
34
+ const objectKey = await uploadFile(file)
35
+ if (!objectKey) return
36
+ setIsOperationStarting(true)
37
+ try {
38
+ await startOperation({
39
+ objectKey,
40
+ orderFormId: cart.id ?? '',
41
+ })
42
+ } finally {
43
+ setIsOperationStarting(false)
44
+ }
45
+ },
46
+ [uploadFile, startOperation, cart.id]
47
+ )
48
+
49
+ return {
50
+ submitFile,
51
+ status,
52
+ isLoading: isUploading || isOperationStarting || isOperating,
53
+ isUploading,
54
+ isProcessing: isOperationStarting || isOperating,
55
+ error: uploadError ? new Error(uploadError.message) : operationError,
56
+ reset,
57
+ }
58
+ }
@@ -0,0 +1,132 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ import { gql } from '@generated'
4
+ import type {
5
+ OrderEntryOperationQueryQuery,
6
+ OrderEntryOperationQueryQueryVariables,
7
+ StartOrderEntryOperationMutationMutation,
8
+ StartOrderEntryOperationMutationMutationVariables,
9
+ } from '@generated/graphql'
10
+
11
+ import { useLazyQuery } from '../graphql/useLazyQuery'
12
+ import { useQuery } from '../graphql/useQuery'
13
+
14
+ const StartOrderEntryOperationMutation = gql(`
15
+ mutation StartOrderEntryOperationMutation($data: IOrderEntryOperation!) {
16
+ startOrderEntryOperation(data: $data) {
17
+ operationId
18
+ }
19
+ }
20
+ `)
21
+
22
+ const OrderEntryOperationQuery = gql(`
23
+ query OrderEntryOperationQuery($operationId: String!) {
24
+ orderEntryOperation(operationId: $operationId) {
25
+ status
26
+ entityId
27
+ message
28
+ missingItems {
29
+ itemId
30
+ itemName
31
+ reason
32
+ }
33
+ }
34
+ }
35
+ `)
36
+
37
+ const TERMINAL_STATUSES = new Set(['SUCCESS', 'PARTIAL_SUCCESS', 'FAILED'])
38
+ const POLL_TIMEOUT_MS = 60_000
39
+
40
+ export type OrderEntryOperationStatus = NonNullable<
41
+ OrderEntryOperationQueryQuery['orderEntryOperation']
42
+ >
43
+
44
+ export type UseOrderEntryOperationReturn = {
45
+ startOperation: (data: {
46
+ objectKey: string
47
+ orderFormId: string
48
+ sessionToken?: string
49
+ }) => Promise<void>
50
+ status: OrderEntryOperationStatus | null
51
+ isLoading: boolean
52
+ error: Error | null
53
+ reset: () => void
54
+ }
55
+
56
+ export function useOrderEntryOperation(): UseOrderEntryOperationReturn {
57
+ const [operationId, setOperationId] = useState<string | null>(null)
58
+ const [timeoutError, setTimeoutError] = useState<Error | null>(null)
59
+ const pollStartRef = useRef<number | null>(null)
60
+
61
+ const [startOp, { isValidating: isStarting, error: startError }] =
62
+ useLazyQuery<
63
+ StartOrderEntryOperationMutationMutation,
64
+ StartOrderEntryOperationMutationMutationVariables
65
+ >(StartOrderEntryOperationMutation, {
66
+ data: { objectKey: '', orderFormId: '', sessionToken: null },
67
+ })
68
+
69
+ const { data: statusData, error: statusError } = useQuery<
70
+ OrderEntryOperationQueryQuery,
71
+ OrderEntryOperationQueryQueryVariables
72
+ >(
73
+ OrderEntryOperationQuery,
74
+ { operationId: operationId ?? '' },
75
+ {
76
+ doNotRun: !operationId,
77
+ refreshInterval: (latestData) => {
78
+ const s = latestData?.orderEntryOperation?.status
79
+ if (s && TERMINAL_STATUSES.has(s)) return 0
80
+ if (
81
+ pollStartRef.current !== null &&
82
+ Date.now() - pollStartRef.current >= POLL_TIMEOUT_MS
83
+ ) {
84
+ setTimeoutError(new Error('Operation timed out. Please try again.'))
85
+ return 0
86
+ }
87
+ return 2000
88
+ },
89
+ }
90
+ )
91
+
92
+ const startOperation = useCallback(
93
+ async (data: {
94
+ objectKey: string
95
+ orderFormId: string
96
+ sessionToken?: string
97
+ }) => {
98
+ const result = await startOp({
99
+ data: {
100
+ ...data,
101
+ sessionToken: data.sessionToken ?? null,
102
+ },
103
+ })
104
+ const id = result?.startOrderEntryOperation?.operationId
105
+ if (id) {
106
+ pollStartRef.current = Date.now()
107
+ setTimeoutError(null)
108
+ setOperationId(id)
109
+ }
110
+ },
111
+ [startOp]
112
+ )
113
+
114
+ const reset = useCallback(() => {
115
+ setOperationId(null)
116
+ setTimeoutError(null)
117
+ pollStartRef.current = null
118
+ }, [])
119
+
120
+ const currentStatus = statusData?.orderEntryOperation?.status ?? null
121
+ const isPolling =
122
+ !!operationId && !!currentStatus && !TERMINAL_STATUSES.has(currentStatus)
123
+ const isPending = !!operationId && !currentStatus
124
+
125
+ return {
126
+ startOperation,
127
+ status: operationId ? (statusData?.orderEntryOperation ?? null) : null,
128
+ isLoading: isStarting || isPolling || isPending,
129
+ error: (startError ?? statusError ?? timeoutError ?? null) as Error | null,
130
+ reset,
131
+ }
132
+ }
@@ -0,0 +1,96 @@
1
+ import { useCallback, useState } from 'react'
2
+
3
+ import { gql } from '@generated'
4
+ import type {
5
+ UploadFileToOrderEntryMutationMutation,
6
+ UploadFileToOrderEntryMutationMutationVariables,
7
+ } from '@generated/graphql'
8
+
9
+ import { request } from '../graphql/request'
10
+
11
+ const UploadFileToOrderEntryMutation = gql(`
12
+ mutation UploadFileToOrderEntryMutation($data: IOrderEntryUpload!) {
13
+ uploadFileToOrderEntry(data: $data) {
14
+ objectKey
15
+ }
16
+ }
17
+ `)
18
+
19
+ export type OrderEntryUploadError = {
20
+ message: string
21
+ }
22
+
23
+ export type UseOrderEntryUploadReturn = {
24
+ isUploading: boolean
25
+ error: OrderEntryUploadError | null
26
+ uploadFile: (file: File) => Promise<string | null>
27
+ clearError: () => void
28
+ }
29
+
30
+ /**
31
+ * Reads a File object and returns its content as a Base64-encoded string.
32
+ */
33
+ const readFileAsBase64 = (file: File): Promise<string> =>
34
+ new Promise((resolve, reject) => {
35
+ const reader = new FileReader()
36
+ reader.onload = () => {
37
+ const dataUrl = reader.result as string
38
+ // Strip the data URL prefix (e.g. "data:text/csv;base64,")
39
+ const base64 = dataUrl.split(',')[1] ?? ''
40
+ resolve(base64)
41
+ }
42
+ reader.onerror = () => reject(new Error('Failed to read file'))
43
+ reader.readAsDataURL(file)
44
+ })
45
+
46
+ /**
47
+ * Hook to upload a file to the Order Entry Service via the
48
+ * `uploadFileToOrderEntry` GraphQL mutation (defined in @faststore/api).
49
+ *
50
+ * The file is Base64-encoded client-side and decoded server-side by the
51
+ * resolver, which then forwards the multipart request to the OES with the
52
+ * proper VtexIdclientAutCookie auth header.
53
+ *
54
+ * @returns `{ isUploading, error, uploadFile, clearError }`
55
+ * - `uploadFile(file)` resolves to the `objectKey` string on success, or `null` on error.
56
+ */
57
+ export function useOrderEntryUpload(): UseOrderEntryUploadReturn {
58
+ const [isUploading, setIsUploading] = useState(false)
59
+ const [error, setError] = useState<OrderEntryUploadError | null>(null)
60
+
61
+ const uploadFile = useCallback(async (file: File): Promise<string | null> => {
62
+ setIsUploading(true)
63
+ setError(null)
64
+
65
+ try {
66
+ const fileContent = await readFileAsBase64(file)
67
+
68
+ const data = await request<
69
+ UploadFileToOrderEntryMutationMutation,
70
+ UploadFileToOrderEntryMutationMutationVariables
71
+ >(UploadFileToOrderEntryMutation, {
72
+ data: {
73
+ fileContent,
74
+ fileName: file.name,
75
+ mimeType: file.type || 'application/octet-stream',
76
+ },
77
+ })
78
+
79
+ return data.uploadFileToOrderEntry?.objectKey ?? null
80
+ } catch (err) {
81
+ const errorMessage =
82
+ err instanceof Error ? err.message : 'Failed to upload file'
83
+
84
+ setError({ message: errorMessage })
85
+ return null
86
+ } finally {
87
+ setIsUploading(false)
88
+ }
89
+ }, [])
90
+
91
+ const clearError = useCallback(() => {
92
+ setError(null)
93
+ }, [])
94
+
95
+ return { isUploading, error, uploadFile, clearError }
96
+ }
@@ -0,0 +1,57 @@
1
+ import { useCallback, useState } from 'react'
2
+
3
+ import { gql } from '@generated'
4
+ import type {
5
+ OrderFormItemsQueryQuery,
6
+ OrderFormItemsQueryQueryVariables,
7
+ } from '@generated/graphql'
8
+
9
+ import { useQuery } from '../graphql/useQuery'
10
+
11
+ const OrderFormItemsQuery = gql(`
12
+ query OrderFormItemsQuery($orderFormId: String!) {
13
+ orderFormItems(orderFormId: $orderFormId) {
14
+ id
15
+ name
16
+ price
17
+ listPrice
18
+ quantity
19
+ imageUrl
20
+ availability
21
+ seller
22
+ unitMultiplier
23
+ }
24
+ }
25
+ `)
26
+
27
+ export type OrderFormCartItem = NonNullable<
28
+ OrderFormItemsQueryQuery['orderFormItems']
29
+ >[number]
30
+
31
+ export function useOrderFormItems() {
32
+ const [orderFormId, setOrderFormId] = useState<string | null>(null)
33
+
34
+ const { data, error, isValidating } = useQuery<
35
+ OrderFormItemsQueryQuery,
36
+ OrderFormItemsQueryQueryVariables
37
+ >(
38
+ OrderFormItemsQuery,
39
+ { orderFormId: orderFormId ?? '' },
40
+ { doNotRun: !orderFormId }
41
+ )
42
+
43
+ const fetchOrderFormItems = useCallback((id: string) => {
44
+ setOrderFormId(id)
45
+ }, [])
46
+
47
+ const reset = useCallback(() => setOrderFormId(null), [])
48
+
49
+ return {
50
+ fetchOrderFormItems,
51
+ items: orderFormId ? (data?.orderFormItems ?? null) : null,
52
+ isLoading: !!orderFormId && !data && !error,
53
+ isValidating,
54
+ error: error as Error | null,
55
+ reset,
56
+ }
57
+ }
@@ -14,5 +14,21 @@
14
14
 
15
15
  // Importing this component because is being used outside the context of the Section
16
16
  @include meta.load-css("~@faststore/ui/src/components/molecules/Dropdown/styles.scss");
17
+ @include meta.load-css("~@faststore/ui/src/components/molecules/Card/styles.scss");
18
+ @include meta.load-css("~@faststore/ui/src/components/molecules/FileUploadCard/styles.scss");
19
+ @include meta.load-css("~@faststore/ui/src/components/molecules/FileUploadStatus/styles.scss");
17
20
  @include meta.load-css("~@faststore/ui/src/components/organisms/QuickOrderDrawer/styles.scss");
18
21
  @include meta.load-css("~@faststore/ui/src/components/organisms/SlideOver/styles.scss");
22
+
23
+ // QuickOrderDrawer renders via createPortal (document.body), outside .section scope.
24
+ // Wrapped in @layer components to match the same specificity as section-scoped styles.
25
+ @layer components {
26
+ @include meta.load-css("~@faststore/ui/src/components/atoms/Badge/styles.scss");
27
+ @include meta.load-css("~@faststore/ui/src/components/atoms/Button/styles.scss");
28
+ @include meta.load-css("~@faststore/ui/src/components/atoms/Icon/styles.scss");
29
+ @include meta.load-css("~@faststore/ui/src/components/atoms/Price/styles.scss");
30
+ @include meta.load-css("~@faststore/ui/src/components/molecules/Alert/styles.scss");
31
+ @include meta.load-css("~@faststore/ui/src/components/molecules/QuantitySelector/styles.scss");
32
+ @include meta.load-css("~@faststore/ui/src/components/molecules/Table/styles.scss");
33
+ @include meta.load-css("~@faststore/ui/src/components/molecules/Tooltip/styles.scss");
34
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { render, screen } from '@testing-library/react'
6
+ import React from 'react'
7
+ import { describe, expect, it, vi } from 'vitest'
8
+
9
+ const mockUseAuth = vi.hoisted(() => vi.fn())
10
+ vi.mock('src/sdk/auth', () => ({ useAuth: mockUseAuth }))
11
+
12
+ import ProfileChallenge from '../../../src/components/auth/ProfileChallenge/ProfileChallenge'
13
+
14
+ describe('ProfileChallenge', () => {
15
+ it('renders children when authenticated', () => {
16
+ mockUseAuth.mockReturnValue({ isAuthenticated: true })
17
+
18
+ render(
19
+ <ProfileChallenge>
20
+ <span>Protected content</span>
21
+ </ProfileChallenge>
22
+ )
23
+
24
+ expect(screen.queryByText('Protected content')).not.toBeNull()
25
+ })
26
+
27
+ it('renders fallback when not authenticated', () => {
28
+ mockUseAuth.mockReturnValue({ isAuthenticated: false })
29
+
30
+ render(
31
+ <ProfileChallenge fallback={<span>Please log in</span>}>
32
+ <span>Protected content</span>
33
+ </ProfileChallenge>
34
+ )
35
+
36
+ expect(screen.queryByText('Please log in')).not.toBeNull()
37
+ expect(screen.queryByText('Protected content')).toBeNull()
38
+ })
39
+
40
+ it('renders nothing by default when not authenticated', () => {
41
+ mockUseAuth.mockReturnValue({ isAuthenticated: false })
42
+
43
+ render(
44
+ <ProfileChallenge>
45
+ <span>Protected content</span>
46
+ </ProfileChallenge>
47
+ )
48
+
49
+ expect(screen.queryByText('Protected content')).toBeNull()
50
+ })
51
+ })
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { mapOrderFormItemsToProducts } from '../../../src/components/search/SearchInput/SearchInput'
4
+
5
+ const makeItem = (overrides = {}) => ({
6
+ id: 'sku-1',
7
+ name: 'Product A',
8
+ price: 100,
9
+ listPrice: 120,
10
+ quantity: 2,
11
+ imageUrl: 'https://example.com/img.jpg',
12
+ availability: 'available',
13
+ seller: '1',
14
+ unitMultiplier: 1,
15
+ ...overrides,
16
+ })
17
+
18
+ describe('mapOrderFormItemsToProducts', () => {
19
+ it('maps a single available item correctly', () => {
20
+ const result = mapOrderFormItemsToProducts([makeItem()])
21
+
22
+ expect(result).toHaveLength(1)
23
+ expect(result[0]).toMatchObject({
24
+ id: 'sku-1',
25
+ name: 'Product A',
26
+ price: 100,
27
+ quantityUpdated: false,
28
+ image: { url: 'https://example.com/img.jpg', alternateName: 'Product A' },
29
+ inventory: 9999,
30
+ availability: 'available',
31
+ selectedCount: 2,
32
+ })
33
+ })
34
+
35
+ it('sets inventory to 0 and availability to outOfStock for unavailable items', () => {
36
+ const result = mapOrderFormItemsToProducts([
37
+ makeItem({ availability: 'unavailable' }),
38
+ ])
39
+
40
+ expect(result[0].inventory).toBe(0)
41
+ expect(result[0].availability).toBe('outOfStock')
42
+ })
43
+
44
+ it('falls back to empty string when imageUrl is null', () => {
45
+ const result = mapOrderFormItemsToProducts([makeItem({ imageUrl: null })])
46
+
47
+ expect(result[0].image.url).toBe('')
48
+ })
49
+
50
+ it('returns empty array for empty input', () => {
51
+ expect(mapOrderFormItemsToProducts([])).toEqual([])
52
+ })
53
+
54
+ it('maps multiple items preserving order', () => {
55
+ const items = [
56
+ makeItem({ id: 'a', name: 'A', quantity: 1 }),
57
+ makeItem({ id: 'b', name: 'B', quantity: 3 }),
58
+ ]
59
+
60
+ const result = mapOrderFormItemsToProducts(items)
61
+
62
+ expect(result[0].id).toBe('a')
63
+ expect(result[1].id).toBe('b')
64
+ expect(result[0].selectedCount).toBe(1)
65
+ expect(result[1].selectedCount).toBe(3)
66
+ })
67
+
68
+ it('always sets quantityUpdated to false', () => {
69
+ const result = mapOrderFormItemsToProducts([makeItem()])
70
+ expect(result[0].quantityUpdated).toBe(false)
71
+ })
72
+ })