Skip to content

Commit ad1dbf6

Browse files
Merge branch 'main' into main
2 parents 69bee5d + 0d0fdb0 commit ad1dbf6

File tree

4 files changed

+109
-42
lines changed

4 files changed

+109
-42
lines changed

src/components/shop/CartDrawer.tsx

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,37 +26,34 @@ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) {
2626
return (
2727
<Dialog.Root open={open} onOpenChange={onOpenChange}>
2828
<Dialog.Portal>
29-
<Dialog.Overlay
30-
className={twMerge(
31-
'fixed inset-0 z-[100] bg-black/40',
32-
'data-[state=open]:animate-in data-[state=open]:fade-in-0',
33-
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
34-
)}
35-
/>
29+
<Dialog.Overlay className="cart-overlay fixed inset-0 z-[100] bg-black/20" />
3630
<Dialog.Content
3731
className={twMerge(
38-
'fixed right-0 top-0 bottom-0 z-[100] w-full sm:max-w-md flex flex-col',
39-
'bg-white dark:bg-gray-950 shadow-xl border-l border-gray-200 dark:border-gray-800',
40-
'data-[state=open]:animate-in data-[state=open]:slide-in-from-right',
41-
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right',
32+
'cart-panel',
33+
'fixed right-4 top-[calc(var(--navbar-height,56px)+0.5rem)] z-[100]',
34+
'w-[calc(100vw-2rem)] sm:w-[24rem]',
35+
'max-h-[calc(100dvh-var(--navbar-height,56px)-1rem)]',
36+
'flex flex-col',
37+
'rounded-xl shadow-2xl',
38+
'bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800',
4239
)}
4340
aria-describedby={undefined}
4441
>
45-
<header className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
46-
<Dialog.Title className="font-semibold">
42+
<header className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-800">
43+
<Dialog.Title className="font-semibold text-sm">
4744
Cart{totalQuantity > 0 ? ` (${totalQuantity})` : ''}
4845
</Dialog.Title>
4946
<Dialog.Close
50-
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-900"
47+
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-900"
5148
aria-label="Close cart"
5249
>
53-
<X className="w-4 h-4" />
50+
<X className="w-3.5 h-3.5" />
5451
</Dialog.Close>
5552
</header>
5653

5754
{hasLines ? (
5855
<>
59-
<ul className="flex-1 overflow-y-auto px-6 divide-y divide-gray-200 dark:divide-gray-800">
56+
<ul className="overflow-y-auto px-5 divide-y divide-gray-200 dark:divide-gray-800">
6057
{cart.lines.nodes.map((line) => (
6158
<DrawerCartLine
6259
key={line.id}

src/hooks/useCart.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,31 @@ export const CART_QUERY_KEY = ['shopify', 'cart'] as const
2626
const CART_MUTATION_KEY = ['shopify', 'cart', 'mutate'] as const
2727

2828
/**
29-
* Only invalidate (refetch from server) when no other cart mutations are
30-
* in flight. This prevents a settled mutation's refetch from overwriting
31-
* another mutation's optimistic state with stale server data.
32-
*
33-
* Each `onMutate` reads the *current* cache (which may already reflect
34-
* earlier optimistic writes) and layers its own change on top. When the
35-
* last mutation settles, the refetch reconciles everything with the
36-
* server's final truth.
29+
* Explicit in-flight counter. We don't rely on `queryClient.isMutating()`
30+
* because its exact semantics at `onSettled` time (does it still count the
31+
* current mutation?) vary across React Query versions and are under-documented.
32+
* A module-level counter is unambiguous: increment in onMutate, decrement in
33+
* onSettled, invalidate when the count hits zero.
34+
*/
35+
let cartMutationsInFlight = 0
36+
37+
function trackMutationStart() {
38+
cartMutationsInFlight++
39+
}
40+
41+
/**
42+
* Call from every cart mutation's `onSettled`. Decrements the in-flight
43+
* counter, and when the last mutation settles, triggers a single background
44+
* refetch to reconcile all accumulated optimistic changes with server truth.
3745
*
3846
* Returns the invalidation promise so the mutation stays in `isPending`
39-
* until the background refetch completes (per TkDodo's recommendation).
47+
* until the refetch completes.
4048
*
4149
* @see https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query
4250
*/
4351
function settleWhenIdle(qc: ReturnType<typeof useQueryClient>) {
44-
// isMutating counts mutations that haven't settled yet. At the time
45-
// onSettled fires, the *current* mutation is still counted, so
46-
// 1 means "I'm the last one in flight."
47-
if (qc.isMutating({ mutationKey: CART_MUTATION_KEY }) === 1) {
52+
cartMutationsInFlight = Math.max(0, cartMutationsInFlight - 1)
53+
if (cartMutationsInFlight === 0) {
4854
return qc.invalidateQueries({ queryKey: CART_QUERY_KEY })
4955
}
5056
}
@@ -108,6 +114,7 @@ export function useAddToCart() {
108114
}),
109115

110116
onMutate: async (input) => {
117+
trackMutationStart()
111118
const quantity = input.quantity ?? 1
112119
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
113120
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
@@ -128,7 +135,6 @@ export function useAddToCart() {
128135
} else {
129136
const lineTotal = String(Number(snap.price.amount) * quantity)
130137
nextLines = [
131-
...previous.lines.nodes,
132138
{
133139
id: `optimistic-${Date.now()}`,
134140
quantity,
@@ -151,6 +157,7 @@ export function useAddToCart() {
151157
},
152158
},
153159
} as CartLineDetail,
160+
...previous.lines.nodes,
154161
]
155162
}
156163

@@ -193,6 +200,7 @@ export function useUpdateCartLine() {
193200
updateCartLine({ data: input }),
194201

195202
onMutate: async (input) => {
203+
trackMutationStart()
196204
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
197205
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
198206
if (previous) {
@@ -227,6 +235,7 @@ export function useRemoveCartLine() {
227235
mutationFn: (input: { lineId: string }) => removeCartLine({ data: input }),
228236

229237
onMutate: async (input) => {
238+
trackMutationStart()
230239
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
231240
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
232241
if (previous) {
@@ -259,6 +268,7 @@ export function useApplyDiscountCode() {
259268
mutationFn: (input: { code: string }) =>
260269
applyDiscountCode({ data: { code: input.code } }),
261270
onMutate: async () => {
271+
trackMutationStart()
262272
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
263273
},
264274
onSuccess: (cart) => {
@@ -274,6 +284,7 @@ export function useRemoveDiscountCode() {
274284
mutationKey: CART_MUTATION_KEY,
275285
mutationFn: () => removeDiscountCode(),
276286
onMutate: async () => {
287+
trackMutationStart()
277288
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
278289
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
279290
if (previous) {

src/routes/shop.products.$handle.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -314,24 +314,25 @@ function AddToCartButton({
314314
}) {
315315
const addToCart = useAddToCart()
316316
const openDrawer = useCartDrawerStore((s) => s.openDrawer)
317+
const [showAdded, setShowAdded] = React.useState(false)
317318

318-
const disabled = !variant || !variant.availableForSale || addToCart.isPending
319+
const disabled =
320+
!variant || !variant.availableForSale || (addToCart.isPending && !showAdded)
319321

320-
const label = addToCart.isSuccess
322+
const label = showAdded
321323
? 'Added ✓'
322-
: addToCart.isPending
323-
? 'Adding…'
324-
: !variant
325-
? 'Unavailable'
326-
: !variant.availableForSale
327-
? 'Sold out'
328-
: 'Add to cart'
324+
: !variant
325+
? 'Unavailable'
326+
: !variant.availableForSale
327+
? 'Sold out'
328+
: 'Add to cart'
329329

330+
// Reset the "Added ✓" confirmation after a moment
330331
React.useEffect(() => {
331-
if (!addToCart.isSuccess) return
332-
const id = window.setTimeout(() => addToCart.reset(), 1500)
332+
if (!showAdded) return
333+
const id = window.setTimeout(() => setShowAdded(false), 1500)
333334
return () => window.clearTimeout(id)
334-
}, [addToCart.isSuccess, addToCart])
335+
}, [showAdded])
335336

336337
return (
337338
<div className="flex flex-col gap-2">
@@ -340,6 +341,7 @@ function AddToCartButton({
340341
disabled={disabled}
341342
onClick={() => {
342343
if (!variant) return
344+
setShowAdded(true)
343345
openDrawer()
344346
addToCart.mutate({
345347
variantId: variant.id,

src/styles/app.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,63 @@ mark {
10601060
animation: dropdown-out 100ms ease-in;
10611061
}
10621062

1063+
/* Cart panel slide-in from right */
1064+
@keyframes cart-panel-in {
1065+
from {
1066+
opacity: 0;
1067+
transform: translateX(1rem);
1068+
}
1069+
to {
1070+
opacity: 1;
1071+
transform: translateX(0);
1072+
}
1073+
}
1074+
1075+
@keyframes cart-panel-out {
1076+
from {
1077+
opacity: 1;
1078+
transform: translateX(0);
1079+
}
1080+
to {
1081+
opacity: 0;
1082+
transform: translateX(1rem);
1083+
}
1084+
}
1085+
1086+
@keyframes cart-overlay-in {
1087+
from {
1088+
opacity: 0;
1089+
}
1090+
to {
1091+
opacity: 1;
1092+
}
1093+
}
1094+
1095+
@keyframes cart-overlay-out {
1096+
from {
1097+
opacity: 1;
1098+
}
1099+
to {
1100+
opacity: 0;
1101+
}
1102+
}
1103+
1104+
.cart-panel[data-state='open'] {
1105+
animation: cart-panel-in 200ms ease-out;
1106+
}
1107+
1108+
.cart-panel[data-state='closed'] {
1109+
animation: cart-panel-out 150ms ease-in;
1110+
}
1111+
1112+
.cart-overlay[data-state='open'] {
1113+
animation: cart-overlay-in 200ms ease-out;
1114+
}
1115+
1116+
.cart-overlay[data-state='closed'] {
1117+
animation: cart-overlay-out 150ms ease-in;
1118+
}
1119+
10631120
/* Doc Feedback Styles */
10641121
.doc-feedback-block {
10651122
transition:

0 commit comments

Comments
 (0)