Skip to content

Commit 98a9a07

Browse files
authored
feat(checkout): make offer error handling and formik refactor (#16350)
* make offer error * no offer amount error * fix offer issue * update to formik and remove default value * simplfy more * re-add on blur tracking * combine offer options * simplfy components * simplify input error * completed note style * update tests * priceoptions in jsx * fix tests
1 parent 91d3a5b commit 98a9a07

22 files changed

+682
-819
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Input } from "@artsy/palette"
2+
import { useField } from "formik"
3+
import type { FC } from "react"
4+
5+
export interface OfferInputProps {
6+
name: string
7+
onBlur?: (value: number | undefined) => void
8+
}
9+
10+
export const OfferInput: FC<OfferInputProps> = ({ name, onBlur }) => {
11+
const [field, meta, helpers] = useField<number>(name)
12+
13+
const formatValueForDisplay = (val: number | undefined) => {
14+
if (val !== undefined && val > 0) {
15+
return val.toLocaleString("en-US")
16+
}
17+
return ""
18+
}
19+
20+
const handleBlur = () => {
21+
if (onBlur) {
22+
onBlur(field.value)
23+
}
24+
}
25+
26+
return (
27+
<Input
28+
title="Your offer"
29+
type="text"
30+
pattern="[0-9]"
31+
error={!!meta.error}
32+
inputMode={"numeric"}
33+
onBlur={handleBlur}
34+
value={formatValueForDisplay(field.value)}
35+
data-testid="offer-input"
36+
onChange={event => {
37+
const currentValue = event.currentTarget.value
38+
const cleanedValue = currentValue.replace(/[^\d]/g, "") // Remove non-digits
39+
helpers.setValue(Number(cleanedValue))
40+
}}
41+
/>
42+
)
43+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { Flex, Radio, RadioGroup, Spacer, Text } from "@artsy/palette"
2+
import { appendCurrencySymbol } from "Apps/Order/Utils/currencyUtils"
3+
import { OfferInput } from "Apps/Order2/Routes/Checkout/Components/OfferStep/Components/OfferInput"
4+
import type { OfferFormProps } from "Apps/Order2/Routes/Checkout/Components/OfferStep/types"
5+
import type { Order2OfferOptions_order$key } from "__generated__/Order2OfferOptions_order.graphql"
6+
import { useState } from "react"
7+
import { graphql, useFragment } from "react-relay"
8+
9+
interface PriceOption {
10+
key: string
11+
value: number
12+
description: string
13+
}
14+
15+
interface Order2OfferOptionsProps extends OfferFormProps {
16+
order: Order2OfferOptions_order$key
17+
}
18+
19+
export const Order2OfferOptions: React.FC<Order2OfferOptionsProps> = ({
20+
order,
21+
onOfferOptionSelected,
22+
onCustomOfferBlur,
23+
}) => {
24+
const orderData = useFragment(FRAGMENT, order)
25+
const [selectedRadio, setSelectedRadio] = useState<string>()
26+
27+
const getPriceOptions = (): PriceOption[] => {
28+
const artwork = orderData.lineItems?.[0]?.artworkOrEditionSet
29+
const artworkListPrice = artwork?.listPrice
30+
31+
// Handle price range case
32+
if (artworkListPrice?.__typename === "PriceRange") {
33+
const minPriceRange = artworkListPrice?.minPrice?.major
34+
const maxPriceRange = artworkListPrice?.maxPrice?.major
35+
36+
if (!minPriceRange || !maxPriceRange) {
37+
return []
38+
}
39+
40+
const midPriceRange = Math.round((minPriceRange + maxPriceRange) / 2)
41+
42+
return [
43+
{
44+
key: "price-option-max",
45+
value: Math.round(maxPriceRange),
46+
description: "Top-end of range",
47+
},
48+
{
49+
key: "price-option-mid",
50+
value: midPriceRange,
51+
description: "Midpoint",
52+
},
53+
{
54+
key: "price-option-min",
55+
value: Math.round(minPriceRange),
56+
description: "Low-end of range",
57+
},
58+
]
59+
}
60+
61+
// Handle exact price case
62+
const listPrice = orderData.lineItems?.[0]?.listPrice
63+
const listPriceMajor = listPrice?.major
64+
65+
if (!listPriceMajor) {
66+
return []
67+
}
68+
69+
return [
70+
{
71+
key: "price-option-max",
72+
value: Math.round(listPriceMajor),
73+
description: "List price",
74+
},
75+
{
76+
key: "price-option-mid",
77+
value: Math.round(listPriceMajor * 0.9), // 10% below
78+
description: "10% below list price",
79+
},
80+
{
81+
key: "price-option-min",
82+
value: Math.round(listPriceMajor * 0.8), // 20% below
83+
description: "20% below list price",
84+
},
85+
]
86+
}
87+
88+
const priceOptions = getPriceOptions()
89+
90+
const formatCurrency = (amount: number) => {
91+
return appendCurrencySymbol(
92+
amount.toLocaleString("en-US", {
93+
minimumFractionDigits: 2,
94+
style: "currency",
95+
currency: orderData.currencyCode,
96+
}),
97+
orderData.currencyCode,
98+
)
99+
}
100+
101+
const handleRadioSelect = (value: string) => {
102+
const option = priceOptions.find(opt => opt.key === value)
103+
104+
if (option) {
105+
onOfferOptionSelected(option.value, option.description)
106+
} else if (
107+
value === "price-option-custom" &&
108+
selectedRadio !== "price-option-custom"
109+
) {
110+
onOfferOptionSelected(0, "Custom amount")
111+
}
112+
113+
setSelectedRadio(value)
114+
}
115+
116+
return (
117+
<RadioGroup onSelect={handleRadioSelect} defaultValue={selectedRadio}>
118+
{[
119+
...priceOptions.map(({ value: optionValue, description, key }) => (
120+
<Radio value={key} label={formatCurrency(optionValue)} key={key}>
121+
<Spacer y={1} />
122+
<Text variant="sm-display" color="mono60">
123+
{description}
124+
</Text>
125+
<Spacer y={4} />
126+
</Radio>
127+
)),
128+
<Radio
129+
key="price-option-custom"
130+
value="price-option-custom"
131+
label="Other amount"
132+
>
133+
{selectedRadio === "price-option-custom" && (
134+
<Flex flexDirection="column" mt={2}>
135+
<OfferInput name="offerValue" onBlur={onCustomOfferBlur} />
136+
</Flex>
137+
)}
138+
</Radio>,
139+
]}
140+
</RadioGroup>
141+
)
142+
}
143+
144+
const FRAGMENT = graphql`
145+
fragment Order2OfferOptions_order on Order {
146+
currencyCode
147+
lineItems {
148+
listPrice {
149+
__typename
150+
... on Money {
151+
major
152+
}
153+
}
154+
artworkOrEditionSet {
155+
... on Artwork {
156+
listPrice {
157+
__typename
158+
... on PriceRange {
159+
maxPrice {
160+
major
161+
}
162+
minPrice {
163+
major
164+
}
165+
}
166+
}
167+
}
168+
... on EditionSet {
169+
listPrice {
170+
__typename
171+
... on PriceRange {
172+
maxPrice {
173+
major
174+
}
175+
minPrice {
176+
major
177+
}
178+
}
179+
}
180+
}
181+
}
182+
}
183+
}
184+
`

0 commit comments

Comments
 (0)