hey-pharmacist-ecommerce 1.1.30 → 1.1.32

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 (83) hide show
  1. package/dist/index.d.mts +1451 -1303
  2. package/dist/index.d.ts +1451 -1303
  3. package/dist/index.js +6162 -1563
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +5854 -1271
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +4 -3
  8. package/src/components/AccountReviewsTab.tsx +97 -0
  9. package/src/components/AccountSettingsTab.tsx +0 -50
  10. package/src/components/CouponCodeInput.tsx +190 -0
  11. package/src/components/Header.tsx +5 -1
  12. package/src/components/Notification.tsx +1 -1
  13. package/src/components/NotificationBell.tsx +33 -0
  14. package/src/components/NotificationCard.tsx +211 -0
  15. package/src/components/NotificationDrawer.tsx +188 -0
  16. package/src/components/OrderCard.tsx +164 -99
  17. package/src/components/ProductCard.tsx +3 -3
  18. package/src/components/ProductReviewsSection.tsx +30 -0
  19. package/src/components/RatingDistribution.tsx +86 -0
  20. package/src/components/ReviewCard.tsx +59 -0
  21. package/src/components/ReviewForm.tsx +207 -0
  22. package/src/components/ReviewPromptBanner.tsx +98 -0
  23. package/src/components/ReviewsList.tsx +151 -0
  24. package/src/components/StarRating.tsx +98 -0
  25. package/src/components/TabNavigation.tsx +1 -1
  26. package/src/components/ui/Button.tsx +1 -1
  27. package/src/hooks/useDiscounts.ts +7 -0
  28. package/src/hooks/useOrders.ts +15 -0
  29. package/src/hooks/useReviews.ts +230 -0
  30. package/src/index.ts +25 -0
  31. package/src/lib/Apis/apis/discounts-api.ts +23 -72
  32. package/src/lib/Apis/apis/notifications-api.ts +196 -231
  33. package/src/lib/Apis/apis/products-api.ts +84 -0
  34. package/src/lib/Apis/apis/review-api.ts +283 -4
  35. package/src/lib/Apis/apis/stores-api.ts +180 -0
  36. package/src/lib/Apis/models/bulk-channel-toggle-dto.ts +52 -0
  37. package/src/lib/Apis/models/cart-body-populated.ts +3 -3
  38. package/src/lib/Apis/models/channel-settings-dto.ts +39 -0
  39. package/src/lib/Apis/models/{discount-paginated-response.ts → completed-order-dto.ts} +21 -16
  40. package/src/lib/Apis/models/create-discount-dto.ts +31 -92
  41. package/src/lib/Apis/models/create-review-dto.ts +4 -4
  42. package/src/lib/Apis/models/create-shippo-account-dto.ts +45 -0
  43. package/src/lib/Apis/models/create-store-dto.ts +6 -0
  44. package/src/lib/Apis/models/discount.ts +37 -98
  45. package/src/lib/Apis/models/discounts-insights-dto.ts +12 -0
  46. package/src/lib/Apis/models/index.ts +13 -7
  47. package/src/lib/Apis/models/{manual-discount.ts → manual-discount-dto.ts} +10 -10
  48. package/src/lib/Apis/models/manual-order-dto.ts +3 -3
  49. package/src/lib/Apis/models/populated-discount.ts +41 -101
  50. package/src/lib/Apis/models/preference-update-item.ts +59 -0
  51. package/src/lib/Apis/models/product-light-dto.ts +40 -0
  52. package/src/lib/Apis/models/{check-notifications-response-dto.ts → review-status-dto.ts} +8 -7
  53. package/src/lib/Apis/models/review.ts +9 -3
  54. package/src/lib/Apis/models/reviewable-order-dto.ts +58 -0
  55. package/src/lib/Apis/models/reviewable-product-dto.ts +81 -0
  56. package/src/lib/Apis/models/shippo-account-response-dto.ts +51 -0
  57. package/src/lib/Apis/models/store-entity.ts +6 -0
  58. package/src/lib/Apis/models/store.ts +6 -0
  59. package/src/lib/Apis/models/update-discount-dto.ts +31 -92
  60. package/src/lib/Apis/models/update-notification-settings-dto.ts +28 -0
  61. package/src/lib/Apis/models/update-review-dto.ts +4 -4
  62. package/src/lib/Apis/models/update-store-dto.ts +6 -0
  63. package/src/lib/Apis/models/{pick-type-class.ts → variant-light-dto.ts} +20 -14
  64. package/src/lib/utils/discount.ts +155 -0
  65. package/src/lib/validations/discount.ts +11 -0
  66. package/src/providers/CartProvider.tsx +2 -2
  67. package/src/providers/DiscountProvider.tsx +97 -0
  68. package/src/providers/EcommerceProvider.tsx +13 -5
  69. package/src/providers/NotificationCenterProvider.tsx +420 -0
  70. package/src/screens/CartScreen.tsx +1 -1
  71. package/src/screens/CheckoutScreen.tsx +39 -12
  72. package/src/screens/NotificationSettingsScreen.tsx +321 -0
  73. package/src/screens/OrderDetailScreen.tsx +283 -0
  74. package/src/screens/OrderReviewsScreen.tsx +308 -0
  75. package/src/screens/OrdersScreen.tsx +31 -7
  76. package/src/screens/ProductDetailScreen.tsx +24 -11
  77. package/src/screens/ProfileScreen.tsx +5 -0
  78. package/src/styles/globals.css +4 -0
  79. package/styles/base.css +6 -0
  80. package/styles/globals.css +3 -0
  81. package/src/lib/Apis/models/create-notification-dto.ts +0 -75
  82. package/src/lib/Apis/models/notification.ts +0 -93
  83. package/src/lib/Apis/models/single-notification-dto.ts +0 -99
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hey-pharmacist-ecommerce",
3
- "version": "1.1.30",
3
+ "version": "1.1.32",
4
4
  "description": "Production-ready, multi-tenant e‑commerce UI + API adapter for Next.js with auth, carts, checkout, orders, theming, and pharmacist-focused UX.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -20,9 +20,10 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@hookform/resolvers": "^3.3.0",
23
- "@tanstack/react-query": "^5.90.5",
23
+ "@tanstack/react-query": "^5.90.16",
24
24
  "axios": "^1.6.0",
25
25
  "cookies-next": "^4.0.0",
26
+ "date-fns": "^4.1.0",
26
27
  "framer-motion": "^10.12.18",
27
28
  "lucide-react": "^0.294.0",
28
29
  "react-hook-form": "^7.0.0",
@@ -71,4 +72,4 @@
71
72
  "url": "https://github.com/yourusername/hey-pharmacist-customer/issues"
72
73
  },
73
74
  "homepage": "https://github.com/yourusername/hey-pharmacist-customer#readme"
74
- }
75
+ }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Star, MessageSquare } from 'lucide-react';
5
+ import { useUserReviews } from '@/hooks/useReviews';
6
+ import { ReviewCard } from './ReviewCard';
7
+ import { useRouter } from 'next/navigation';
8
+ import { useBasePath } from '@/providers/BasePathProvider';
9
+
10
+ export function AccountReviewsTab() {
11
+ const router = useRouter();
12
+ const { buildPath } = useBasePath();
13
+ const { reviews, isLoading, error } = useUserReviews();
14
+
15
+ if (isLoading) {
16
+ return (
17
+ <div className="p-6">
18
+ <div className="animate-pulse space-y-4">
19
+ {[1, 2, 3].map((i) => (
20
+ <div key={i} className="h-32 bg-gray-200 rounded-lg" />
21
+ ))}
22
+ </div>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ if (error) {
28
+ return (
29
+ <div className="p-6">
30
+ <div className="text-center py-12">
31
+ <p className="text-red-600 mb-4">Failed to load reviews</p>
32
+ <button
33
+ onClick={() => window.location.reload()}
34
+ className="text-[#E67E50] hover:underline text-sm"
35
+ >
36
+ Try again
37
+ </button>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ if (!reviews || reviews.length === 0) {
44
+ return (
45
+ <div className="p-6">
46
+ <div className="text-center py-12">
47
+ <div className="size-16 rounded-full bg-[#E67E50]/10 flex items-center justify-center mx-auto mb-4">
48
+ <Star className="size-8 text-[#E67E50]" />
49
+ </div>
50
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">
51
+ No reviews yet
52
+ </h3>
53
+ <p className="text-gray-600 mb-6">
54
+ Share your experience with products you've purchased
55
+ </p>
56
+ <button
57
+ onClick={() => router.push(buildPath('/reviews'))}
58
+ className="inline-flex items-center gap-2 px-6 py-3 bg-[#E67E50] text-white rounded-lg font-medium hover:bg-[#d66f40] transition-colors"
59
+ >
60
+ <Star className="size-4" />
61
+ Write Your First Review
62
+ </button>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <div className="p-6">
70
+ <div className="flex items-center justify-between mb-6">
71
+ <div>
72
+ <h2 className="text-xl font-semibold text-gray-900">My Reviews</h2>
73
+ <p className="text-sm text-gray-600 mt-1">
74
+ {reviews.length} {reviews.length === 1 ? 'review' : 'reviews'}
75
+ </p>
76
+ </div>
77
+ <button
78
+ onClick={() => router.push(buildPath('/reviews'))}
79
+ className="inline-flex items-center gap-2 px-4 py-2 border border-[#E67E50] text-[#E67E50] rounded-lg font-medium hover:bg-[#E67E50]/5 transition-colors text-sm"
80
+ >
81
+ <MessageSquare className="size-4" />
82
+ Write a Review
83
+ </button>
84
+ </div>
85
+
86
+ <div className="space-y-4">
87
+ {reviews.map((review) => (
88
+ <ReviewCard
89
+ key={review.id || review._id}
90
+ review={review}
91
+ showProductInfo={true}
92
+ />
93
+ ))}
94
+ </div>
95
+ </div>
96
+ );
97
+ }
@@ -14,9 +14,6 @@ export function AccountSettingsTab() {
14
14
  const router = useRouter();
15
15
  const { buildPath } = useBasePath();
16
16
  const { logout } = useAuth();
17
- const [emailNotifications, setEmailNotifications] = useState(true);
18
- const [orderUpdates, setOrderUpdates] = useState(true);
19
- const [promotionalEmails, setPromotionalEmails] = useState(false);
20
17
  const [showDeleteModal, setShowDeleteModal] = useState(false);
21
18
  const [isDeleting, setIsDeleting] = useState(false);
22
19
  const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -40,53 +37,6 @@ export function AccountSettingsTab() {
40
37
 
41
38
  return (
42
39
  <div className="p-6 space-y-6">
43
- {/* Account Preferences */}
44
- <div className="rounded-2xl border border-slate-200 bg-white p-6">
45
- <div className="flex items-center gap-2 mb-4">
46
- <Bell className="h-5 w-5 text-secondary" />
47
- <h3 className="text-lg font-semibold text-secondary">Account Preferences</h3>
48
- </div>
49
- <div className="space-y-4">
50
- <label className="flex items-center justify-between gap-4 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
51
- <div>
52
- <p className="text-sm font-medium text-secondary">Email Notifications</p>
53
- <p className="text-xs text-muted">Receive updates about your orders</p>
54
- </div>
55
- <input
56
- type="checkbox"
57
- checked={emailNotifications}
58
- onChange={(e) => setEmailNotifications(e.target.checked)}
59
- className="h-4 w-4 rounded-sm border-primary-300 text-secondary focus:ring-primary-500"
60
- />
61
- </label>
62
- <label className="flex items-center justify-between gap-4 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
63
- <div>
64
- <p className="text-sm font-medium text-secondary">Order Updates</p>
65
- <p className="text-xs text-muted">Get notified about shipping status</p>
66
- </div>
67
- <input
68
- type="checkbox"
69
- checked={orderUpdates}
70
- onChange={(e) => setOrderUpdates(e.target.checked)}
71
- className="h-4 w-4 rounded-sm border-primary-300 text-secondary focus:ring-primary-500"
72
- />
73
- </label>
74
- <label className="flex items-center justify-between gap-4 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
75
- <div>
76
- <p className="text-sm font-medium text-secondary">Promotional Emails</p>
77
- <p className="text-xs text-muted">Receive special offers and discounts</p>
78
- </div>
79
- <input
80
- type="checkbox"
81
- checked={promotionalEmails}
82
- onChange={(e) => setPromotionalEmails(e.target.checked)}
83
- className="h-4 w-4 rounded-sm border-primary-300 text-secondary focus:ring-primary-500"
84
- />
85
- </label>
86
- </div>
87
- </div>
88
-
89
- {/* Security */}
90
40
  <div className="rounded-2xl border border-slate-200 bg-white p-6">
91
41
  <div className="flex items-center gap-2 mb-4">
92
42
  <Lock className="h-5 w-5 text-secondary" />
@@ -0,0 +1,190 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useCallback } from 'react';
4
+ import { useForm } from 'react-hook-form';
5
+ import { zodResolver } from '@hookform/resolvers/zod';
6
+ import { Loader2, X, Check, Gift, Tag, AlertCircle } from 'lucide-react';
7
+
8
+ import { Button } from '@/components/ui/Button';
9
+ import { Input } from '@/components/ui/Input';
10
+ import { couponCodeSchema, type CouponCodeFormData } from '@/lib/validations/discount';
11
+ import { useDiscounts } from '@/hooks/useDiscounts';
12
+ import { formatDiscountDisplay } from '@/lib/utils/discount';
13
+
14
+ interface CouponCodeInputProps {
15
+ onApplyCoupon?: (code: string) => void;
16
+ onRemoveCoupon?: () => void;
17
+ userId?: string;
18
+ className?: string;
19
+ }
20
+
21
+ export function CouponCodeInput({
22
+ onApplyCoupon,
23
+ onRemoveCoupon,
24
+ userId,
25
+ className = '',
26
+ }: CouponCodeInputProps) {
27
+ const {
28
+ appliedCoupon,
29
+ couponError,
30
+ isValidatingCoupon,
31
+ validateAndApplyCoupon,
32
+ removeCoupon,
33
+ } = useDiscounts();
34
+
35
+ const [localError, setLocalError] = useState<string | null>(null);
36
+ const [isFocused, setIsFocused] = useState(false);
37
+ const inputRef = useRef<HTMLInputElement>(null);
38
+
39
+ const {
40
+ register,
41
+ handleSubmit,
42
+ reset,
43
+ watch,
44
+ formState: { errors, isSubmitting },
45
+ } = useForm<CouponCodeFormData>({
46
+ resolver: zodResolver(couponCodeSchema),
47
+ });
48
+
49
+ const couponCodeValue = watch('code');
50
+ const errorMessage =
51
+ errors.code?.message || localError || couponError || null;
52
+
53
+ const triggerShake = () => {
54
+ if (!inputRef.current) return;
55
+ inputRef.current.classList.add('animate-shake');
56
+ setTimeout(() => {
57
+ inputRef.current?.classList.remove('animate-shake');
58
+ }, 400);
59
+ };
60
+
61
+ const onSubmit = useCallback(
62
+ async (data: CouponCodeFormData) => {
63
+ setLocalError(null);
64
+ const result = await validateAndApplyCoupon(data.code, userId);
65
+
66
+ if (!result.success) {
67
+ setLocalError(result.error || 'Invalid coupon code');
68
+ triggerShake();
69
+ return;
70
+ }
71
+
72
+ reset();
73
+ onApplyCoupon?.(data.code);
74
+ inputRef.current?.blur();
75
+ },
76
+ [onApplyCoupon, reset, userId, validateAndApplyCoupon]
77
+ );
78
+
79
+ const handleRemove = () => {
80
+ removeCoupon();
81
+ onRemoveCoupon?.();
82
+ reset();
83
+ setLocalError(null);
84
+ };
85
+
86
+
87
+ if (appliedCoupon) {
88
+ return (
89
+ <div
90
+ className={`relative rounded-xl border border-emerald-200 bg-gradient-to-r from-emerald-50 to-green-50 p-4 shadow-sm ${className}`}
91
+ >
92
+ <div className="flex items-center justify-between">
93
+ <div className="flex items-center gap-3">
94
+ <div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
95
+ <Check className="h-5 w-5 text-emerald-600" />
96
+ </div>
97
+ <div>
98
+ <p className="text-sm font-semibold text-emerald-900">
99
+ Coupon Applied
100
+ </p>
101
+ <p className="text-sm font-medium text-emerald-700">
102
+ {appliedCoupon.code} • {formatDiscountDisplay(appliedCoupon)}
103
+ </p>
104
+ </div>
105
+ </div>
106
+
107
+ <button
108
+ onClick={handleRemove}
109
+ aria-label="Remove coupon"
110
+ className="group flex h-8 w-8 items-center justify-center rounded-lg border border-emerald-200 bg-white transition hover:border-red-300 hover:bg-red-50"
111
+ >
112
+ <X className="h-4 w-4 text-emerald-600 group-hover:text-red-600" />
113
+ </button>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <div className={`space-y-4 ${className}`}>
121
+ <div className="flex items-center gap-2">
122
+ <Gift className="h-5 w-5 text-slate-500" />
123
+ <label
124
+ htmlFor="coupon-code"
125
+ className="text-sm font-semibold text-slate-700"
126
+ >
127
+ Have a coupon code?
128
+ </label>
129
+ </div>
130
+
131
+ <div className="flex gap-3">
132
+ <div className="relative flex-1">
133
+ <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
134
+ <Tag
135
+ className={`h-5 w-5 transition-colors ${
136
+ isFocused ? 'text-slate-400' : 'text-slate-400'
137
+ }`}
138
+ />
139
+ </div>
140
+
141
+ <Input
142
+ id="coupon-code"
143
+ {...register('code')}
144
+ placeholder="Enter code (e.g. SAVE10)"
145
+ maxLength={50}
146
+ disabled={isValidatingCoupon || isSubmitting}
147
+ aria-invalid={!!errorMessage}
148
+ className={`pl-12 h-11 rounded-lg transition-all shadow-sm ${
149
+ errorMessage
150
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-red-500/20'
151
+ : 'border-slate-200 bg-white hover:border-slate-300'
152
+ }`}
153
+ onFocus={() => setIsFocused(true)}
154
+ onBlur={() => setIsFocused(false)}
155
+ />
156
+ </div>
157
+
158
+ <Button
159
+ type="button"
160
+ onClick={handleSubmit(onSubmit)}
161
+ disabled={!couponCodeValue || isValidatingCoupon || isSubmitting}
162
+ className='h-11 px-5 rounded-lg disabled:bg-slate-300 disabled:text-white text-white hover:opacity-80 transition-colors'
163
+ style={{
164
+ backgroundColor:'#04AA6D',
165
+ }}
166
+ >
167
+ {isValidatingCoupon ? (
168
+ <>
169
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
170
+ Checking
171
+ </>
172
+ ) : (
173
+ 'Apply'
174
+ )}
175
+ </Button>
176
+ </div>
177
+ {/* </div> */}
178
+
179
+ {errorMessage && (
180
+ <div
181
+ role="alert"
182
+ className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3"
183
+ >
184
+ <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
185
+ <p className="text-sm text-red-700">{errorMessage}</p>
186
+ </div>
187
+ )}
188
+ </div>
189
+ );
190
+ }
@@ -12,6 +12,7 @@ import { getInitials } from '@/lib/utils/format';
12
12
  import { useRouter } from 'next/navigation';
13
13
  import Link from 'next/link';
14
14
  import Image from 'next/image';
15
+ import { NotificationBell } from './NotificationBell';
15
16
 
16
17
  export function Header() {
17
18
  const { config } = useTheme();
@@ -129,8 +130,11 @@ export function Header() {
129
130
  </AnimatePresence>
130
131
  </div>
131
132
 
132
- {/* Wishlist and Cart */}
133
+ {/* Wishlist, Cart, and Notifications */}
133
134
  <div className="flex items-center gap-4">
135
+ {/* Notifications - only show when authenticated */}
136
+ {isAuthenticated && <NotificationBell />}
137
+
134
138
  <Link href={buildPath('/wishlist')} className="relative p-2 text-gray-700 hover:text-red-500 transition-colors">
135
139
  <Heart className="w-6 h-6" />
136
140
  {wishlistCount > 0 && (
@@ -135,7 +135,7 @@ interface NotificationContainerProps {
135
135
 
136
136
  export function NotificationContainer({ notifications, onDismiss }: NotificationContainerProps) {
137
137
  return (
138
- <div className="fixed top-4 right-4 z-9999 flex flex-col gap-3 pointer-events-none">
138
+ <div className="fixed top-4 right-4 z-50 flex flex-col gap-3 pointer-events-none">
139
139
  <AnimatePresence mode="popLayout">
140
140
  {notifications.map((notification) => (
141
141
  <div key={notification.id} className="pointer-events-auto">
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Bell } from 'lucide-react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { useNotificationCenter } from '@/providers/NotificationCenterProvider';
7
+
8
+ export function NotificationBell() {
9
+ const { unreadCount, openDrawer } = useNotificationCenter();
10
+
11
+ return (
12
+ <button
13
+ onClick={openDrawer}
14
+ className="relative p-2 hover:bg-gray-100 rounded-lg transition-colors"
15
+ aria-label="Notifications"
16
+ >
17
+ <Bell className="w-6 h-6 text-gray-700" />
18
+
19
+ <AnimatePresence>
20
+ {unreadCount > 0 && (
21
+ <motion.span
22
+ initial={{ scale: 0 }}
23
+ animate={{ scale: 1 }}
24
+ exit={{ scale: 0 }}
25
+ className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
26
+ >
27
+ {unreadCount > 99 ? '99+' : unreadCount}
28
+ </motion.span>
29
+ )}
30
+ </AnimatePresence>
31
+ </button>
32
+ );
33
+ }
@@ -0,0 +1,211 @@
1
+ 'use client';
2
+
3
+ import React, { useRef } from 'react';
4
+ import { motion, PanInfo } from 'framer-motion';
5
+ import {
6
+ X,
7
+ Package,
8
+ CreditCard,
9
+ Lock,
10
+ ShoppingCart,
11
+ TrendingDown,
12
+ Bell as BellIcon,
13
+ Trash2
14
+ } from 'lucide-react';
15
+ import { NotificationData, NotificationType } from '@/providers/NotificationCenterProvider';
16
+ import { useRouter } from 'next/navigation';
17
+ import { useBasePath } from '@/providers/BasePathProvider';
18
+
19
+ interface NotificationCardProps {
20
+ notification: NotificationData;
21
+ onMarkAsRead: (id: string) => void;
22
+ onDelete: (id: string) => void;
23
+ }
24
+
25
+ /* ---------------------------------- */
26
+ /* Helpers */
27
+ /* ---------------------------------- */
28
+
29
+ const getNotificationIcon = (type: NotificationType) => {
30
+ const className = 'w-5 h-5';
31
+
32
+ switch (type) {
33
+ case 'ORDER_CONFIRMATION':
34
+ case 'ORDER_SHIPPED':
35
+ case 'ORDER_DELIVERED':
36
+ return <Package className={className} />;
37
+ case 'PAYMENT_FAILED':
38
+ case 'REFUND_PROCESSED':
39
+ return <CreditCard className={className} />;
40
+ case 'PASSWORD_RESET':
41
+ case 'NEW_DEVICE_LOGIN':
42
+ case 'TWO_FA_CODE':
43
+ return <Lock className={className} />;
44
+ case 'ABANDONED_CART_REMINDER':
45
+ return <ShoppingCart className={className} />;
46
+ case 'PRICE_DROP_ALERT':
47
+ return <TrendingDown className={className} />;
48
+ case 'BACK_IN_STOCK':
49
+ default:
50
+ return <BellIcon className={className} />;
51
+ }
52
+ };
53
+
54
+ const getNotificationColor = (type: NotificationType) => {
55
+ switch (type) {
56
+ case 'ORDER_CONFIRMATION':
57
+ case 'ORDER_DELIVERED':
58
+ case 'REFUND_PROCESSED':
59
+ return 'bg-green-100 text-green-600';
60
+ case 'ORDER_SHIPPED':
61
+ return 'bg-blue-100 text-blue-600';
62
+ case 'PAYMENT_FAILED':
63
+ return 'bg-red-100 text-red-600';
64
+ case 'PASSWORD_RESET':
65
+ case 'NEW_DEVICE_LOGIN':
66
+ case 'TWO_FA_CODE':
67
+ return 'bg-purple-100 text-purple-600';
68
+ case 'ABANDONED_CART_REMINDER':
69
+ case 'PRICE_DROP_ALERT':
70
+ case 'BACK_IN_STOCK':
71
+ return 'bg-orange-100 text-orange-600';
72
+ default:
73
+ return 'bg-gray-100 text-gray-600';
74
+ }
75
+ };
76
+
77
+ const formatRelativeTime = (dateString: string): string => {
78
+ const date = new Date(dateString);
79
+ const diff = Date.now() - date.getTime();
80
+ const mins = Math.floor(diff / 60000);
81
+ const hours = Math.floor(diff / 3600000);
82
+ const days = Math.floor(diff / 86400000);
83
+
84
+ if (mins < 1) return 'Just now';
85
+ if (mins < 60) return `${mins} min ago`;
86
+ if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
87
+ if (days < 7) return `${days} day${days > 1 ? 's' : ''} ago`;
88
+
89
+ return date.toLocaleDateString();
90
+ };
91
+
92
+ /* ---------------------------------- */
93
+ /* Component */
94
+ /* ---------------------------------- */
95
+
96
+ export function NotificationCard({
97
+ notification,
98
+ onMarkAsRead,
99
+ onDelete
100
+ }: NotificationCardProps) {
101
+ const router = useRouter();
102
+ const { buildPath } = useBasePath();
103
+
104
+ const hasDragged = useRef(false);
105
+
106
+ const handleClick = () => {
107
+ if (hasDragged.current) return;
108
+
109
+ if (!notification.isRead) {
110
+ onMarkAsRead(notification._id);
111
+ }
112
+
113
+ if (notification.data?.orderId) {
114
+ router.push(buildPath(`/account/orders/${notification.data.orderId}`));
115
+ } else if (notification.data?.productId) {
116
+ router.push(buildPath(`/products/${notification.data.productId}`));
117
+ }
118
+ };
119
+
120
+ const handleDelete = (e: React.MouseEvent) => {
121
+ e.stopPropagation();
122
+ onDelete(notification._id);
123
+ };
124
+
125
+ const handleDragStart = () => {
126
+ hasDragged.current = false;
127
+ };
128
+
129
+ const handleDrag = (_: any, info: PanInfo) => {
130
+ if (Math.abs(info.offset.x) > 5) {
131
+ hasDragged.current = true;
132
+ }
133
+ };
134
+
135
+ const handleDragEnd = (_: any, info: PanInfo) => {
136
+ if (info.offset.x < -100) {
137
+ onDelete(notification._id);
138
+ }
139
+ };
140
+
141
+ return (
142
+ <div className="relative overflow-hidden rounded-lg">
143
+ {/* Delete background */}
144
+ <div className="absolute inset-0 flex items-center justify-end px-6">
145
+ <div className="flex flex-col items-center gap-1 text-white">
146
+ <Trash2 className="w-5 h-5" />
147
+ <span className="text-[10px] font-bold uppercase tracking-wider">
148
+ Remove
149
+ </span>
150
+ </div>
151
+ </div>
152
+
153
+ {/* Swipeable card */}
154
+ <motion.div
155
+ drag="x"
156
+ initial={{ opacity: 0, x: 0 }}
157
+ animate={{ opacity: 1, x: 0 }}
158
+ className={`relative z-10 p-4 border cursor-pointer select-none touch-pan-y transition-colors ${notification.isRead
159
+ ? 'bg-white border-gray-200'
160
+ : 'bg-blue-50 border-blue-200'
161
+ }`}
162
+ onClick={handleClick}
163
+ >
164
+ <div className="flex gap-3">
165
+ {/* Icon */}
166
+ <div
167
+ className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${getNotificationColor(
168
+ notification.type
169
+ )}`}
170
+ >
171
+ {getNotificationIcon(notification.type)}
172
+ </div>
173
+
174
+ {/* Content */}
175
+ <div className="flex-1 min-w-0">
176
+ <div className="flex items-start justify-between gap-2">
177
+ <h4 className="font-semibold text-gray-900 text-sm line-clamp-1">
178
+ {notification.title}
179
+ </h4>
180
+
181
+ <button
182
+ onClick={handleDelete}
183
+ className="flex-shrink-0 p-1 bg-gray-100 rounded-full hover:bg-red-50"
184
+ aria-label="Delete notification"
185
+ >
186
+ <X className="w-4 h-4 text-gray-400 hover:text-red-500" />
187
+ </button>
188
+ </div>
189
+
190
+ <p className="text-sm text-gray-600 mt-1 line-clamp-2">
191
+ {notification.body}
192
+ </p>
193
+
194
+ <div className="flex items-center gap-2 mt-2">
195
+ <span className="text-xs text-gray-500">
196
+ {formatRelativeTime(notification.createdAt)}
197
+ </span>
198
+
199
+ {!notification.isRead && (
200
+ <span
201
+ className="w-2 h-2 bg-blue-500 rounded-full"
202
+ aria-label="Unread"
203
+ />
204
+ )}
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </motion.div>
209
+ </div>
210
+ );
211
+ }