From f5ba65d0c9afd0da2b732703b1f6c2b5a2b47cbe Mon Sep 17 00:00:00 2001 From: Foundups Date: Sat, 8 Nov 2025 21:20:52 +0900 Subject: [PATCH 1/3] feat(gotjunk): Fix long-press interactions on classification modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windsurf Protocol Implementation: - Created useLongPress hook with iOS Safari compatibility - Added ActionSheetDiscount for quick discount % selection (25%/50%/75%) - Added ActionSheetBid for quick bid duration selection (24h/48h/72h) - Updated ClassificationModal with tap/long-press gesture support - Updated App.tsx to handle discount % and bid duration parameters UX Improvements: - Single tap: Select classification with defaults (75% discount, 48h bid) - Long-press Discount: Opens bottom sheet with 25%/50%/75% options - Long-press Bid: Opens bottom sheet with 24h/48h/72h options - Haptic feedback on long-press trigger and selection - iOS Safari optimizations: touch-action manipulation, prevent context menu Technical: - Pointer Events with Touch/Mouse fallback - 450ms long-press threshold, 10px movement cancellation - Debounce: 800ms between long-press triggers - Mutually exclusive tap/long-press gestures - Helper text: 'Tap to select • Long-press to edit' Fixes #issue (classification modal not allowing option selection) --- modules/foundups/gotjunk/frontend/App.tsx | 31 +-- .../frontend/components/ActionSheetBid.tsx | 109 +++++++++++ .../components/ActionSheetDiscount.tsx | 109 +++++++++++ .../components/ClassificationModal.tsx | 99 +++++++++- .../gotjunk/frontend/hooks/useLongPress.ts | 183 ++++++++++++++++++ 5 files changed, 508 insertions(+), 23 deletions(-) create mode 100644 modules/foundups/gotjunk/frontend/components/ActionSheetBid.tsx create mode 100644 modules/foundups/gotjunk/frontend/components/ActionSheetDiscount.tsx create mode 100644 modules/foundups/gotjunk/frontend/hooks/useLongPress.ts diff --git a/modules/foundups/gotjunk/frontend/App.tsx b/modules/foundups/gotjunk/frontend/App.tsx index 2a459bff4..e18e29ac9 100644 --- a/modules/foundups/gotjunk/frontend/App.tsx +++ b/modules/foundups/gotjunk/frontend/App.tsx @@ -234,7 +234,7 @@ const App: React.FC = () => { }); }; - const handleClassify = async (classification: ItemClassification) => { + const handleClassify = async (classification: ItemClassification, discountPercent?: number, bidDurationHours?: number) => { if (!pendingClassificationItem) return; const { blob, url, location } = pendingClassificationItem; @@ -244,10 +244,15 @@ const App: React.FC = () => { const defaultPrice = 100; // Placeholder until Vision API integration let price = 0; + // Use provided values or defaults + const finalDiscountPercent = discountPercent || 75; + const finalBidDurationHours = bidDurationHours || 48; + if (classification === 'free') { price = 0; } else if (classification === 'discount') { - price = defaultPrice * 0.25; // 75% OFF + // Use the selected discount percentage + price = defaultPrice * (1 - finalDiscountPercent / 100); } else if (classification === 'bid') { price = defaultPrice * 0.5; // Starting bid at 50% OFF } @@ -261,8 +266,8 @@ const App: React.FC = () => { classification, price, originalPrice: defaultPrice, - discountPercent, - bidDurationHours, + discountPercent: classification === 'discount' ? finalDiscountPercent : undefined, + bidDurationHours: classification === 'bid' ? finalBidDurationHours : undefined, createdAt: Date.now(), ...location, }; @@ -352,29 +357,29 @@ const App: React.FC = () => { // Re-classify existing item - const handleReclassify = async (item: CapturedItem, newClassification: ItemClassification) => { + const handleReclassify = async (item: CapturedItem, newClassification: ItemClassification, discountPercent?: number, bidDurationHours?: number) => { const defaultPrice = 100; // Will be from Google Vision API + // Use provided values or defaults + const finalDiscountPercent = discountPercent || 75; + const finalBidDurationHours = bidDurationHours || 48; + let price = 0; - let discountPercent = 75; - let bidDurationHours = 48; if (newClassification === 'free') { price = 0; } else if (newClassification === 'discount') { - price = defaultPrice * 0.25; // 75% OFF by default - discountPercent = 75; + price = defaultPrice * (1 - finalDiscountPercent / 100); } else if (newClassification === 'bid') { price = defaultPrice * 0.5; // 50% OFF starting bid - bidDurationHours = 48; // default } const updatedItem: CapturedItem = { ...item, classification: newClassification, price, - discountPercent, - bidDurationHours, + discountPercent: classification === 'discount' ? finalDiscountPercent : undefined, + bidDurationHours: classification === 'bid' ? finalBidDurationHours : undefined, }; // Update in state @@ -607,7 +612,7 @@ const currentReviewItem = myDrafts.length > 0 ? myDrafts[0] : null; handleReclassify(reclassifyingItem, newClassification)} + onClassify={(newClassification, discountPercent, bidDurationHours) => handleReclassify(reclassifyingItem, newClassification, discountPercent, bidDurationHours)} /> )} diff --git a/modules/foundups/gotjunk/frontend/components/ActionSheetBid.tsx b/modules/foundups/gotjunk/frontend/components/ActionSheetBid.tsx new file mode 100644 index 000000000..f9e289d80 --- /dev/null +++ b/modules/foundups/gotjunk/frontend/components/ActionSheetBid.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface ActionSheetBidProps { + isOpen: boolean; + currentDurationHours: number; // 24, 48, or 72 + onSelect: (durationHours: number) => void; + onClose: () => void; +} + +/** + * ActionSheetBid - Quick edit sheet for bid/auction duration + * Opens on long-press of Bid button + * Options: 24h, 48h, 72h + */ +export const ActionSheetBid: React.FC = ({ + isOpen, + currentDurationHours, + onSelect, + onClose, +}) => { + const options = [ + { hours: 24, label: '24 Hours', color: 'amber-300' }, + { hours: 48, label: '48 Hours', color: 'amber-400' }, + { hours: 72, label: '72 Hours', color: 'amber-500' }, + ]; + + const handleSelect = (hours: number) => { + // Haptic success feedback + if (navigator.vibrate) { + navigator.vibrate([10, 50, 10]); // Success pattern + } + onSelect(hours); + onClose(); + }; + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Action Sheet */} + + {/* Handle bar */} +
+ + {/* Title */} +

+ Auction Duration +

+ + {/* Current value chip */} +
+
+ + Current: {currentDurationHours}h + +
+
+ + {/* Options */} +
+ {options.map(({ hours, label, color }) => ( + + ))} +
+ + {/* Cancel button */} + + + + )} + + ); +}; diff --git a/modules/foundups/gotjunk/frontend/components/ActionSheetDiscount.tsx b/modules/foundups/gotjunk/frontend/components/ActionSheetDiscount.tsx new file mode 100644 index 000000000..6c503bbc8 --- /dev/null +++ b/modules/foundups/gotjunk/frontend/components/ActionSheetDiscount.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface ActionSheetDiscountProps { + isOpen: boolean; + currentPercent: number; // 75 or 50 + onSelect: (percent: number) => void; + onClose: () => void; +} + +/** + * ActionSheetDiscount - Quick edit sheet for discount percentage + * Opens on long-press of Discount button + * Options: 25%, 50%, 75%, Custom + */ +export const ActionSheetDiscount: React.FC = ({ + isOpen, + currentPercent, + onSelect, + onClose, +}) => { + const options = [ + { percent: 25, label: '25% OFF', color: 'green-300' }, + { percent: 50, label: '50% OFF', color: 'green-400' }, + { percent: 75, label: '75% OFF', color: 'green-500' }, + ]; + + const handleSelect = (percent: number) => { + // Haptic success feedback + if (navigator.vibrate) { + navigator.vibrate([10, 50, 10]); // Success pattern + } + onSelect(percent); + onClose(); + }; + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Action Sheet */} + + {/* Handle bar */} +
+ + {/* Title */} +

+ Select Discount +

+ + {/* Current value chip */} +
+
+ + Current: {currentPercent}% OFF + +
+
+ + {/* Options */} +
+ {options.map(({ percent, label, color }) => ( + + ))} +
+ + {/* Cancel button */} + + + + )} + + ); +}; diff --git a/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx b/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx index 150ae24b0..4a953d454 100644 --- a/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx +++ b/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx @@ -1,25 +1,75 @@ -import React from 'react'; +import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { ItemClassification } from '../types'; import { FreeIcon } from './icons/FreeIcon'; import { DiscountIcon } from './icons/DiscountIcon'; import { BidIcon } from './icons/BidIcon'; +import { useLongPress } from '../hooks/useLongPress'; +import { ActionSheetDiscount } from './ActionSheetDiscount'; +import { ActionSheetBid } from './ActionSheetBid'; interface ClassificationModalProps { isOpen: boolean; imageUrl: string; - onClassify: (classification: ItemClassification) => void; + onClassify: (classification: ItemClassification, discountPercent?: number, bidDurationHours?: number) => void; } /** * ClassificationModal - Post-capture classification popup * User must choose Free, Discount, or Bid before item is saved + * + * Gestures: + * - Single tap: Select classification with defaults + * - Long press Discount: Open quick edit for 25%/50%/75% + * - Long press Bid: Open quick edit for 24h/48h/72h */ export const ClassificationModal: React.FC = ({ isOpen, imageUrl, onClassify, }) => { + // Action sheet state + const [discountSheetOpen, setDiscountSheetOpen] = useState(false); + const [bidSheetOpen, setBidSheetOpen] = useState(false); + + // Current values (defaults) + const [discountPercent, setDiscountPercent] = useState(75); + const [bidDurationHours, setBidDurationHours] = useState(48); + + // Long-press for Discount button + const discountLongPress = useLongPress({ + onLongPress: () => { + setDiscountSheetOpen(true); + }, + onTap: () => { + onClassify('discount', discountPercent, undefined); + }, + threshold: 450, + }); + + // Long-press for Bid button + const bidLongPress = useLongPress({ + onLongPress: () => { + setBidSheetOpen(true); + }, + onTap: () => { + onClassify('bid', undefined, bidDurationHours); + }, + threshold: 450, + }); + + // Handle Discount sheet selection + const handleDiscountSelect = (percent: number) => { + setDiscountPercent(percent); + onClassify('discount', percent, undefined); + }; + + // Handle Bid sheet selection + const handleBidSelect = (hours: number) => { + setBidDurationHours(hours); + onClassify('bid', undefined, hours); + }; + return ( {isOpen && ( @@ -28,6 +78,11 @@ export const ClassificationModal: React.FC = ({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-sm flex flex-col items-center justify-center p-6" + style={{ + WebkitTouchCallout: 'none', // Prevent iOS context menu + WebkitUserSelect: 'none', + userSelect: 'none', + }} > {/* Preview image */} = ({ {/* Classification buttons */}
- {/* Free button */} + {/* Free button - simple tap only */} onClassify('free')} className="w-full flex items-center justify-between p-5 bg-blue-500/20 hover:bg-blue-500/30 border-2 border-blue-400 rounded-2xl transition-all shadow-lg shadow-blue-500/20" + style={{ + touchAction: 'manipulation', // iOS Safari optimization + }} >
@@ -64,12 +122,15 @@ export const ClassificationModal: React.FC = ({ $0 - {/* Discount button */} + {/* Discount button - tap or long-press */} onClassify('discount')} + {...discountLongPress} className="w-full flex items-center justify-between p-5 bg-green-500/20 hover:bg-green-500/30 border-2 border-green-400 rounded-2xl transition-all shadow-lg shadow-green-500/20" + style={{ + touchAction: 'manipulation', // iOS Safari optimization + }} >
@@ -80,15 +141,18 @@ export const ClassificationModal: React.FC = ({

Sell it fast

- 75% OFF + {discountPercent}% OFF
- {/* Bid button */} + {/* Bid button - tap or long-press */} onClassify('bid')} + {...bidLongPress} className="w-full flex items-center justify-between p-5 bg-amber-500/20 hover:bg-amber-500/30 border-2 border-amber-400 rounded-2xl transition-all shadow-lg shadow-amber-500/20" + style={{ + touchAction: 'manipulation', // iOS Safari optimization + }} >
@@ -99,14 +163,29 @@ export const ClassificationModal: React.FC = ({

Let buyers compete

- AUCTION + {bidDurationHours}h
{/* Helper text */}

- You can change this later + Tap to select • Long-press to edit

+ + {/* Action Sheets */} + setDiscountSheetOpen(false)} + /> + + setBidSheetOpen(false)} + /> )} diff --git a/modules/foundups/gotjunk/frontend/hooks/useLongPress.ts b/modules/foundups/gotjunk/frontend/hooks/useLongPress.ts new file mode 100644 index 000000000..d4a9f235a --- /dev/null +++ b/modules/foundups/gotjunk/frontend/hooks/useLongPress.ts @@ -0,0 +1,183 @@ +import { useCallback, useRef } from 'react'; + +interface UseLongPressOptions { + onLongPress: (event: PointerEvent | TouchEvent | MouseEvent) => void; + onTap?: (event: PointerEvent | TouchEvent | MouseEvent) => void; + threshold?: number; // ms before long-press fires (default 450ms) + moveThreshold?: number; // px movement to cancel long-press (default 10px) +} + +interface UseLongPressReturn { + onPointerDown: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerCancel: (event: React.PointerEvent) => void; + onTouchStart: (event: React.TouchEvent) => void; + onTouchEnd: (event: React.TouchEvent) => void; + onTouchMove: (event: React.TouchEvent) => void; + onTouchCancel: (event: React.TouchEvent) => void; + onMouseDown: (event: React.MouseEvent) => void; + onMouseUp: (event: React.MouseEvent) => void; + onMouseMove: (event: React.MouseEvent) => void; + onMouseLeave: (event: React.MouseEvent) => void; +} + +/** + * useLongPress Hook + * + * Robust long-press detection with iOS Safari compatibility + * - Uses Pointer Events with fallback to Touch/Mouse + * - Threshold: 450ms (configurable) + * - Cancels on movement > 10px (configurable) + * - Prevents iOS context menu + * - Haptic feedback on trigger + * - Mutually exclusive tap/long-press + */ +export function useLongPress({ + onLongPress, + onTap, + threshold = 450, + moveThreshold = 10, +}: UseLongPressOptions): UseLongPressReturn { + const timerRef = useRef(null); + const longPressTriggeredRef = useRef(false); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + const lastLongPressTimeRef = useRef(0); + + const clear = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const triggerLongPress = useCallback((event: PointerEvent | TouchEvent | MouseEvent) => { + const now = Date.now(); + + // Debounce: ignore if triggered within 800ms + if (now - lastLongPressTimeRef.current < 800) { + return; + } + + longPressTriggeredRef.current = true; + lastLongPressTimeRef.current = now; + + // Haptic feedback (iOS Safari) + if (navigator.vibrate) { + navigator.vibrate(10); // Light impact + } + + onLongPress(event); + clear(); + }, [onLongPress, clear]); + + const handleStart = useCallback((clientX: number, clientY: number, event: PointerEvent | TouchEvent | MouseEvent) => { + // Prevent iOS context menu + event.preventDefault(); + + longPressTriggeredRef.current = false; + startPosRef.current = { x: clientX, y: clientY }; + + clear(); + timerRef.current = window.setTimeout(() => { + triggerLongPress(event); + }, threshold); + }, [threshold, triggerLongPress, clear]); + + const handleMove = useCallback((clientX: number, clientY: number) => { + if (!startPosRef.current) return; + + const deltaX = Math.abs(clientX - startPosRef.current.x); + const deltaY = Math.abs(clientY - startPosRef.current.y); + + // Cancel if moved too much + if (deltaX > moveThreshold || deltaY > moveThreshold) { + clear(); + } + }, [moveThreshold, clear]); + + const handleEnd = useCallback((event: PointerEvent | TouchEvent | MouseEvent) => { + clear(); + + // Fire tap only if long-press wasn't triggered + if (!longPressTriggeredRef.current && onTap) { + onTap(event); + } + + startPosRef.current = null; + }, [onTap, clear]); + + const handleCancel = useCallback(() => { + clear(); + longPressTriggeredRef.current = false; + startPosRef.current = null; + }, [clear]); + + // Pointer Events (preferred) + const onPointerDown = useCallback((event: React.PointerEvent) => { + handleStart(event.clientX, event.clientY, event.nativeEvent); + }, [handleStart]); + + const onPointerMove = useCallback((event: React.PointerEvent) => { + handleMove(event.clientX, event.clientY); + }, [handleMove]); + + const onPointerUp = useCallback((event: React.PointerEvent) => { + handleEnd(event.nativeEvent); + }, [handleEnd]); + + const onPointerCancel = useCallback((event: React.PointerEvent) => { + handleCancel(); + }, [handleCancel]); + + // Touch Events (iOS fallback) + const onTouchStart = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + handleStart(touch.clientX, touch.clientY, event.nativeEvent); + }, [handleStart]); + + const onTouchMove = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + handleMove(touch.clientX, touch.clientY); + }, [handleMove]); + + const onTouchEnd = useCallback((event: React.TouchEvent) => { + handleEnd(event.nativeEvent); + }, [handleEnd]); + + const onTouchCancel = useCallback((event: React.TouchEvent) => { + handleCancel(); + }, [handleCancel]); + + // Mouse Events (desktop fallback) + const onMouseDown = useCallback((event: React.MouseEvent) => { + handleStart(event.clientX, event.clientY, event.nativeEvent); + }, [handleStart]); + + const onMouseMove = useCallback((event: React.MouseEvent) => { + handleMove(event.clientX, event.clientY); + }, [handleMove]); + + const onMouseUp = useCallback((event: React.MouseEvent) => { + handleEnd(event.nativeEvent); + }, [handleEnd]); + + const onMouseLeave = useCallback((event: React.MouseEvent) => { + handleCancel(); + }, [handleCancel]); + + return { + onPointerDown, + onPointerUp, + onPointerMove, + onPointerCancel, + onTouchStart, + onTouchEnd, + onTouchMove, + onTouchCancel, + onMouseDown, + onMouseUp, + onMouseMove, + onMouseLeave, + }; +} From 44896c1d4a57ff35c63dcc864e681f989b4d55b8 Mon Sep 17 00:00:00 2001 From: Foundups Date: Sat, 8 Nov 2025 21:25:55 +0900 Subject: [PATCH 2/3] fix(gotjunk): Remove Framer Motion tap conflict from classification buttons Hotfix for PR #31: - Removed whileTap/whileHover from Discount and Bid buttons - Changed from motion.button to plain button elements - Added active:scale-95 CSS for visual feedback - Resolves gesture handler conflict preventing tap selection Issue: Framer Motion's whileTap was consuming tap events before custom useLongPress handlers could process them, breaking tap-to-select functionality. Solution: Use plain button elements with CSS transitions for visual feedback, allowing custom pointer event handlers to work correctly. --- .../frontend/components/ClassificationModal.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx b/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx index 4a953d454..ec04e263a 100644 --- a/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx +++ b/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx @@ -123,11 +123,9 @@ export const ClassificationModal: React.FC = ({ {/* Discount button - tap or long-press */} - = ({
{discountPercent}% OFF - + {/* Bid button - tap or long-press */} - = ({
{bidDurationHours}h - + {/* Helper text */} From b90d1e9b102931214938ff34c11dc1e4d8f30b96 Mon Sep 17 00:00:00 2001 From: Foundups Date: Sat, 8 Nov 2025 21:35:23 +0900 Subject: [PATCH 3/3] fix(gotjunk): Long-press should update defaults, not auto-save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two critical UX issues: 1. Long-press auto-save bug: - BEFORE: Long-press → select value → immediately saves item - AFTER: Long-press → select value → action sheet closes → user taps button to save - Fixed handleDiscountSelect and handleBidSelect to NOT call onClassify 2. Defaults don't persist: - BEFORE: Always reset to 75% discount / 48h bid on each capture - AFTER: Selected values persist as defaults for future captures - Uses localStorage: 'gotjunk_default_discount' and 'gotjunk_default_bid_duration' - Loads saved values on modal mount with lazy useState initialization UX Flow (now correct): 1. User captures photo → modal shows with saved defaults (or 75%/48h first time) 2. User long-presses Discount → action sheet opens 3. User selects 50% → action sheet closes, modal shows '50% OFF' 4. User taps Discount button → saves item with 50% discount 5. Next capture → modal opens with 50% as default Auto-posting investigation: - Auto-save was caused by handleDiscountSelect/handleBidSelect calling onClassify - Framer Motion gesture conflict (fixed in PR #32) may have also contributed - Both issues now resolved Technical: - Removed onClassify calls from action sheet handlers - Added localStorage persistence for user preferences - Added haptic feedback on value selection (vibrate pattern) - Lazy useState initialization to load defaults only once --- .../components/ClassificationModal.tsx | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx b/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx index ec04e263a..e30850957 100644 --- a/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx +++ b/modules/foundups/gotjunk/frontend/components/ClassificationModal.tsx @@ -32,9 +32,15 @@ export const ClassificationModal: React.FC = ({ const [discountSheetOpen, setDiscountSheetOpen] = useState(false); const [bidSheetOpen, setBidSheetOpen] = useState(false); - // Current values (defaults) - const [discountPercent, setDiscountPercent] = useState(75); - const [bidDurationHours, setBidDurationHours] = useState(48); + // Load saved defaults from localStorage or use fallbacks + const [discountPercent, setDiscountPercent] = useState(() => { + const saved = localStorage.getItem('gotjunk_default_discount'); + return saved ? parseInt(saved, 10) : 75; + }); + const [bidDurationHours, setBidDurationHours] = useState(() => { + const saved = localStorage.getItem('gotjunk_default_bid_duration'); + return saved ? parseInt(saved, 10) : 48; + }); // Long-press for Discount button const discountLongPress = useLongPress({ @@ -61,13 +67,29 @@ export const ClassificationModal: React.FC = ({ // Handle Discount sheet selection const handleDiscountSelect = (percent: number) => { setDiscountPercent(percent); - onClassify('discount', percent, undefined); + setDiscountSheetOpen(false); + + // Save as default for future captures + localStorage.setItem('gotjunk_default_discount', percent.toString()); + + // Haptic success feedback + if (navigator.vibrate) { + navigator.vibrate([10, 50, 10]); + } }; // Handle Bid sheet selection const handleBidSelect = (hours: number) => { setBidDurationHours(hours); - onClassify('bid', undefined, hours); + setBidSheetOpen(false); + + // Save as default for future captures + localStorage.setItem('gotjunk_default_bid_duration', hours.toString()); + + // Haptic success feedback + if (navigator.vibrate) { + navigator.vibrate([10, 50, 10]); + } }; return (