@instockng/api-client 1.0.3 → 1.0.4

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 (170) hide show
  1. package/dist/apps/backend/src/generated/zod/index.d.ts +1114 -0
  2. package/dist/apps/backend/src/generated/zod/index.js +670 -0
  3. package/dist/apps/backend/src/http-app.d.ts +40 -0
  4. package/dist/apps/backend/src/http-app.js +106 -0
  5. package/dist/apps/backend/src/lib/brand-response.d.ts +14 -0
  6. package/dist/apps/backend/src/lib/brand-response.js +8 -0
  7. package/dist/apps/backend/src/lib/cart-helpers.d.ts +280 -0
  8. package/dist/apps/backend/src/lib/cart-helpers.js +93 -0
  9. package/dist/apps/backend/src/lib/cart-recovery.d.ts +30 -0
  10. package/dist/apps/backend/src/lib/cart-recovery.js +147 -0
  11. package/dist/apps/backend/src/lib/cart-response.d.ts +121 -0
  12. package/dist/apps/backend/src/lib/cart-response.js +150 -0
  13. package/dist/apps/backend/src/lib/clerk.d.ts +18 -0
  14. package/dist/apps/backend/src/lib/clerk.js +167 -0
  15. package/dist/apps/backend/src/lib/delivery-zone-response.d.ts +62 -0
  16. package/dist/apps/backend/src/lib/delivery-zone-response.js +24 -0
  17. package/dist/apps/backend/src/lib/discount-code-response.d.ts +42 -0
  18. package/dist/apps/backend/src/lib/discount-code-response.js +19 -0
  19. package/dist/apps/backend/src/lib/discount.d.ts +20 -0
  20. package/dist/apps/backend/src/lib/discount.js +35 -0
  21. package/dist/apps/backend/src/lib/inventory.d.ts +26 -0
  22. package/dist/apps/backend/src/lib/inventory.js +160 -0
  23. package/dist/apps/backend/src/lib/meta-capi.d.ts +48 -0
  24. package/dist/apps/backend/src/lib/meta-capi.js +120 -0
  25. package/dist/apps/backend/src/lib/openapi.d.ts +36 -0
  26. package/dist/apps/backend/src/lib/openapi.js +69 -0
  27. package/dist/apps/backend/src/lib/order-recovery.d.ts +367 -0
  28. package/dist/apps/backend/src/lib/order-recovery.js +373 -0
  29. package/dist/apps/backend/src/lib/order-response.d.ts +136 -0
  30. package/dist/apps/backend/src/lib/order-response.js +61 -0
  31. package/dist/apps/backend/src/lib/pricing.d.ts +39 -0
  32. package/dist/apps/backend/src/lib/pricing.js +62 -0
  33. package/dist/apps/backend/src/lib/prisma.d.ts +9 -0
  34. package/dist/apps/backend/src/lib/prisma.js +30 -0
  35. package/dist/apps/backend/src/lib/product-response.d.ts +82 -0
  36. package/dist/apps/backend/src/lib/product-response.js +29 -0
  37. package/dist/apps/backend/src/lib/utils.d.ts +32 -0
  38. package/dist/apps/backend/src/lib/utils.js +63 -0
  39. package/dist/apps/backend/src/middleware/clerk-auth.d.ts +8 -0
  40. package/dist/apps/backend/src/middleware/clerk-auth.js +89 -0
  41. package/dist/apps/backend/src/middleware/cors.d.ts +8 -0
  42. package/dist/apps/backend/src/middleware/cors.js +11 -0
  43. package/dist/apps/backend/src/notifications/producers/meta-capi-producer.d.ts +55 -0
  44. package/dist/apps/backend/src/notifications/producers/meta-capi-producer.js +125 -0
  45. package/dist/apps/backend/src/notifications/producers/order-notification.d.ts +9 -0
  46. package/dist/apps/backend/src/notifications/producers/order-notification.js +18 -0
  47. package/dist/apps/backend/src/notifications/producers/prospect-recovery-notification.d.ts +10 -0
  48. package/dist/apps/backend/src/notifications/producers/prospect-recovery-notification.js +11 -0
  49. package/dist/apps/backend/src/routes/admin/abandoned-carts.d.ts +605 -0
  50. package/dist/apps/backend/src/routes/admin/abandoned-carts.js +194 -0
  51. package/dist/apps/backend/src/routes/admin/brands.d.ts +175 -0
  52. package/dist/apps/backend/src/routes/admin/brands.js +118 -0
  53. package/dist/apps/backend/src/routes/admin/customers.d.ts +306 -0
  54. package/dist/apps/backend/src/routes/admin/customers.js +39 -0
  55. package/dist/apps/backend/src/routes/admin/delivery-zones.d.ts +438 -0
  56. package/dist/apps/backend/src/routes/admin/delivery-zones.js +300 -0
  57. package/dist/apps/backend/src/routes/admin/discount-codes.d.ts +478 -0
  58. package/dist/apps/backend/src/routes/admin/discount-codes.js +418 -0
  59. package/dist/apps/backend/src/routes/admin/inventory.d.ts +273 -0
  60. package/dist/apps/backend/src/routes/admin/inventory.js +189 -0
  61. package/dist/apps/backend/src/routes/admin/orders.d.ts +1478 -0
  62. package/dist/apps/backend/src/routes/admin/orders.js +503 -0
  63. package/dist/apps/backend/src/routes/admin/products.d.ts +860 -0
  64. package/dist/apps/backend/src/routes/admin/products.js +107 -0
  65. package/dist/apps/backend/src/routes/admin/stats.d.ts +288 -0
  66. package/dist/apps/backend/src/routes/admin/stats.js +55 -0
  67. package/dist/apps/backend/src/routes/admin/variants.d.ts +239 -0
  68. package/dist/apps/backend/src/routes/admin/variants.js +173 -0
  69. package/dist/apps/backend/src/routes/admin/warehouses.d.ts +373 -0
  70. package/dist/apps/backend/src/routes/admin/warehouses.js +123 -0
  71. package/dist/apps/backend/src/routes/public/brands.d.ts +40 -0
  72. package/dist/apps/backend/src/routes/public/brands.js +38 -0
  73. package/dist/apps/backend/src/routes/public/carts.d.ts +2655 -0
  74. package/dist/apps/backend/src/routes/public/carts.js +631 -0
  75. package/dist/apps/backend/src/routes/public/delivery-zones.d.ts +35 -0
  76. package/dist/apps/backend/src/routes/public/delivery-zones.js +62 -0
  77. package/dist/apps/backend/src/routes/public/orders.d.ts +323 -0
  78. package/dist/apps/backend/src/routes/public/orders.js +160 -0
  79. package/dist/apps/backend/src/routes/public/products.d.ts +449 -0
  80. package/dist/apps/backend/src/routes/public/products.js +133 -0
  81. package/dist/apps/backend/src/types/index.d.ts +42 -0
  82. package/dist/apps/backend/src/types/index.js +2 -0
  83. package/dist/apps/backend/src/validators/brand.d.ts +17 -0
  84. package/dist/apps/backend/src/validators/brand.js +15 -0
  85. package/dist/apps/backend/src/validators/delivery-zone.d.ts +31 -0
  86. package/dist/apps/backend/src/validators/delivery-zone.js +51 -0
  87. package/dist/apps/backend/src/validators/discount-code.d.ts +74 -0
  88. package/dist/apps/backend/src/validators/discount-code.js +50 -0
  89. package/dist/apps/backend/src/validators/inventory.d.ts +20 -0
  90. package/dist/apps/backend/src/validators/inventory.js +15 -0
  91. package/dist/apps/backend/src/validators/order.d.ts +87 -0
  92. package/dist/apps/backend/src/validators/order.js +61 -0
  93. package/dist/apps/backend/src/validators/product.d.ts +18 -0
  94. package/dist/apps/backend/src/validators/product.js +19 -0
  95. package/dist/apps/backend/src/validators/variant.d.ts +19 -0
  96. package/dist/apps/backend/src/validators/variant.js +19 -0
  97. package/dist/apps/backend/src/validators/warehouse.d.ts +15 -0
  98. package/dist/apps/backend/src/validators/warehouse.js +15 -0
  99. package/dist/fetchers/carts.d.ts +746 -754
  100. package/dist/hooks/public/carts.d.ts +694 -702
  101. package/dist/packages/api-client/src/backend-types.d.ts +10 -0
  102. package/dist/packages/api-client/src/backend-types.js +10 -0
  103. package/dist/packages/api-client/src/client.d.ts +20 -0
  104. package/dist/packages/api-client/src/client.js +40 -0
  105. package/dist/packages/api-client/src/fetchers/brands.d.ts +25 -0
  106. package/dist/packages/api-client/src/fetchers/brands.js +26 -0
  107. package/dist/packages/api-client/src/fetchers/carts.d.ts +2335 -0
  108. package/dist/packages/api-client/src/fetchers/carts.js +169 -0
  109. package/dist/packages/api-client/src/fetchers/delivery-zones.d.ts +28 -0
  110. package/dist/packages/api-client/src/fetchers/delivery-zones.js +26 -0
  111. package/dist/packages/api-client/src/fetchers/index.d.ts +22 -0
  112. package/dist/packages/api-client/src/fetchers/index.js +22 -0
  113. package/dist/packages/api-client/src/fetchers/orders.d.ts +283 -0
  114. package/dist/packages/api-client/src/fetchers/orders.js +44 -0
  115. package/dist/packages/api-client/src/fetchers/products.d.ts +386 -0
  116. package/dist/packages/api-client/src/fetchers/products.js +42 -0
  117. package/dist/packages/api-client/src/hooks/admin/abandoned-carts.d.ts +535 -0
  118. package/dist/packages/api-client/src/hooks/admin/abandoned-carts.js +79 -0
  119. package/dist/packages/api-client/src/hooks/admin/brands.d.ts +79 -0
  120. package/dist/packages/api-client/src/hooks/admin/brands.js +103 -0
  121. package/dist/packages/api-client/src/hooks/admin/customers.d.ts +278 -0
  122. package/dist/packages/api-client/src/hooks/admin/customers.js +25 -0
  123. package/dist/packages/api-client/src/hooks/admin/delivery-zones.d.ts +270 -0
  124. package/dist/packages/api-client/src/hooks/admin/delivery-zones.js +168 -0
  125. package/dist/packages/api-client/src/hooks/admin/discount-codes.d.ts +299 -0
  126. package/dist/packages/api-client/src/hooks/admin/discount-codes.js +157 -0
  127. package/dist/packages/api-client/src/hooks/admin/index.d.ts +16 -0
  128. package/dist/packages/api-client/src/hooks/admin/index.js +16 -0
  129. package/dist/packages/api-client/src/hooks/admin/inventory.d.ts +224 -0
  130. package/dist/packages/api-client/src/hooks/admin/inventory.js +102 -0
  131. package/dist/packages/api-client/src/hooks/admin/orders.d.ts +1380 -0
  132. package/dist/packages/api-client/src/hooks/admin/orders.js +169 -0
  133. package/dist/packages/api-client/src/hooks/admin/products.d.ts +374 -0
  134. package/dist/packages/api-client/src/hooks/admin/products.js +84 -0
  135. package/dist/packages/api-client/src/hooks/admin/stats.d.ts +277 -0
  136. package/dist/packages/api-client/src/hooks/admin/stats.js +24 -0
  137. package/dist/packages/api-client/src/hooks/admin/variants.d.ts +115 -0
  138. package/dist/packages/api-client/src/hooks/admin/variants.js +121 -0
  139. package/dist/packages/api-client/src/hooks/admin/warehouses.d.ts +277 -0
  140. package/dist/packages/api-client/src/hooks/admin/warehouses.js +103 -0
  141. package/dist/packages/api-client/src/hooks/public/brands.d.ts +33 -0
  142. package/dist/packages/api-client/src/hooks/public/brands.js +30 -0
  143. package/dist/packages/api-client/src/hooks/public/carts.d.ts +2405 -0
  144. package/dist/packages/api-client/src/hooks/public/carts.js +213 -0
  145. package/dist/packages/api-client/src/hooks/public/delivery-zones.d.ts +34 -0
  146. package/dist/packages/api-client/src/hooks/public/delivery-zones.js +28 -0
  147. package/dist/packages/api-client/src/hooks/public/index.d.ts +10 -0
  148. package/dist/packages/api-client/src/hooks/public/index.js +10 -0
  149. package/dist/packages/api-client/src/hooks/public/orders.d.ts +302 -0
  150. package/dist/packages/api-client/src/hooks/public/orders.js +50 -0
  151. package/dist/packages/api-client/src/hooks/public/products.d.ts +398 -0
  152. package/dist/packages/api-client/src/hooks/public/products.js +47 -0
  153. package/dist/packages/api-client/src/hooks/use-query-unwrapped.d.ts +20 -0
  154. package/dist/packages/api-client/src/hooks/use-query-unwrapped.js +22 -0
  155. package/dist/packages/api-client/src/hooks/useApiConfig.d.ts +11 -0
  156. package/dist/packages/api-client/src/hooks/useApiConfig.js +14 -0
  157. package/dist/packages/api-client/src/index.d.ts +20 -0
  158. package/dist/packages/api-client/src/index.js +25 -0
  159. package/dist/packages/api-client/src/provider.d.ts +33 -0
  160. package/dist/packages/api-client/src/provider.js +52 -0
  161. package/dist/packages/api-client/src/rpc-client.d.ts +9035 -0
  162. package/dist/packages/api-client/src/rpc-client.js +78 -0
  163. package/dist/packages/api-client/src/rpc-types.d.ts +76 -0
  164. package/dist/packages/api-client/src/rpc-types.js +7 -0
  165. package/dist/packages/api-client/src/types.d.ts +33 -0
  166. package/dist/packages/api-client/src/types.js +16 -0
  167. package/dist/packages/api-client/src/utils/query-keys.d.ts +106 -0
  168. package/dist/packages/api-client/src/utils/query-keys.js +108 -0
  169. package/dist/rpc-client.d.ts +685 -693
  170. package/package.json +10 -9
@@ -0,0 +1,631 @@
1
+ import { Hono } from 'hono';
2
+ import { zValidator } from '@hono/zod-validator';
3
+ import { z } from 'zod';
4
+ import { getPrismaClient } from '../../lib/prisma';
5
+ import { calculateCartCost } from '../../lib/pricing';
6
+ import { buildCartResponseWithPricing } from '../../lib/cart-response';
7
+ import { toPricingItems } from '../../lib/cart-response';
8
+ import { validateCart, buildCartResponse, etagFrom, paymentFromZone, CART_INCLUDE_FULL, CART_ITEM_WITH_CART_INCLUDE, ORDER_INCLUDE_FULL, } from '../../lib/cart-helpers';
9
+ import { round, toNumber } from '../../lib/utils';
10
+ import { formatOrderResponse } from '../../lib/order-response';
11
+ const app = new Hono()
12
+ // CREATE CART
13
+ .post('/', zValidator('json', z.object({
14
+ brandSlug: z.string(),
15
+ })), async (c) => {
16
+ try {
17
+ const input = c.req.valid('json');
18
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
19
+ // Look up brand by slug
20
+ const brand = await prisma.brand.findFirst({
21
+ where: { slug: input.brandSlug, deletedAt: null },
22
+ });
23
+ if (!brand) {
24
+ return c.json({ error: { code: 'BRAND_NOT_FOUND', message: 'Brand not found' } }, 404);
25
+ }
26
+ // Create cart that expires in 7 days
27
+ const cart = await prisma.cart.create({
28
+ data: {
29
+ brandId: brand.id,
30
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
31
+ availablePaymentMethods: ['cod', 'online'],
32
+ },
33
+ include: CART_INCLUDE_FULL,
34
+ });
35
+ const responseCart = await buildCartResponseWithPricing(prisma, cart);
36
+ return c.json(responseCart, 201);
37
+ }
38
+ catch (error) {
39
+ console.error('Error creating cart:', error);
40
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
41
+ }
42
+ })
43
+ // GET CART
44
+ .get('/:id', zValidator('param', z.object({ id: z.string().uuid() })), async (c) => {
45
+ try {
46
+ const { id: cartId } = c.req.valid('param');
47
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
48
+ // Validate cart exists and is not expired
49
+ const { cart, error } = await validateCart(prisma, cartId);
50
+ if (error) {
51
+ return c.json({ error: { code: error.code, message: error.message } }, error.status);
52
+ }
53
+ const responseCart = await buildCartResponse(prisma, cart);
54
+ c.header('ETag', etagFrom(cart.updatedAt));
55
+ return c.json(responseCart, 200);
56
+ }
57
+ catch (error) {
58
+ console.error('Error fetching cart:', error);
59
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
60
+ }
61
+ })
62
+ // UPDATE CART
63
+ .patch('/:id', zValidator('param', z.object({ id: z.string().uuid() })), zValidator('json', z.object({
64
+ customerPhone: z.string().optional().nullable(),
65
+ customerEmail: z.string().email().optional().nullable(),
66
+ customerFirstName: z.string().optional().nullable(),
67
+ customerLastName: z.string().optional().nullable(),
68
+ deliveryZoneId: z.string().uuid().optional().nullable(),
69
+ ifUnmodifiedSince: z.string().datetime().optional(),
70
+ })), async (c) => {
71
+ try {
72
+ const { id: cartId } = c.req.valid('param');
73
+ const input = c.req.valid('json');
74
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
75
+ // Validate cart exists and is not expired
76
+ const { cart, error } = await validateCart(prisma, cartId);
77
+ if (error) {
78
+ return c.json({ error: { code: error.code, message: error.message } }, error.status);
79
+ }
80
+ // Handle concurrency check
81
+ const ifMatch = c.req.header('if-match');
82
+ const bodyIfUnmodified = input.ifUnmodifiedSince ? new Date(input.ifUnmodifiedSince) : undefined;
83
+ if (ifMatch && ifMatch !== etagFrom(cart.updatedAt)) {
84
+ return c.json({ error: { code: 'PRECONDITION_FAILED', message: 'Cart has changed' } }, 412);
85
+ }
86
+ if (bodyIfUnmodified && cart.updatedAt.getTime() !== bodyIfUnmodified.getTime()) {
87
+ return c.json({ error: { code: 'PRECONDITION_FAILED', message: 'Cart has changed' } }, 412);
88
+ }
89
+ // Build update data
90
+ const updateData = {
91
+ customerPhone: input.customerPhone,
92
+ customerEmail: input.customerEmail,
93
+ customerFirstName: input.customerFirstName,
94
+ customerLastName: input.customerLastName,
95
+ };
96
+ // Handle delivery zone change
97
+ if (input.deliveryZoneId !== undefined) {
98
+ if (!input.deliveryZoneId) {
99
+ // Unsetting zone
100
+ updateData.deliveryZoneId = null;
101
+ updateData.deliveryZoneLockedAt = null;
102
+ updateData.availablePaymentMethods = ['cod', 'online'];
103
+ }
104
+ else {
105
+ // Setting zone
106
+ const zone = await prisma.deliveryZone.findFirst({
107
+ where: { id: input.deliveryZoneId, isActive: true, deletedAt: null },
108
+ });
109
+ if (!zone) {
110
+ return c.json({ error: { code: 'INVALID_DELIVERY_ZONE', message: 'Zone not available' } }, 400);
111
+ }
112
+ updateData.deliveryZoneId = zone.id;
113
+ updateData.deliveryZoneLockedAt = new Date();
114
+ updateData.availablePaymentMethods = paymentFromZone(zone);
115
+ }
116
+ }
117
+ const updatedCart = await prisma.cart.update({
118
+ where: { id: cartId },
119
+ data: updateData,
120
+ include: CART_INCLUDE_FULL,
121
+ });
122
+ const responseCart = await buildCartResponse(prisma, updatedCart);
123
+ c.header('ETag', etagFrom(updatedCart.updatedAt));
124
+ return c.json(responseCart, 200);
125
+ }
126
+ catch (error) {
127
+ console.error('Error updating cart:', error);
128
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
129
+ }
130
+ })
131
+ // APPLY DISCOUNT CODE
132
+ .post('/:id/apply-discount', zValidator('param', z.object({ id: z.string().uuid() })), zValidator('json', z.object({ code: z.string() })), async (c) => {
133
+ try {
134
+ const { id: cartId } = c.req.valid('param');
135
+ const { code } = c.req.valid('json');
136
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
137
+ // Validate cart exists and is not expired
138
+ const { cart, error } = await validateCart(prisma, cartId);
139
+ if (error) {
140
+ return c.json({ error: { code: error.code, message: error.message } }, error.status);
141
+ }
142
+ const now = new Date();
143
+ const discount = await prisma.discountCode.findFirst({
144
+ where: {
145
+ code,
146
+ deletedAt: null,
147
+ isActive: true,
148
+ validFrom: { lte: now },
149
+ AND: [
150
+ { OR: [{ validUntil: null }, { validUntil: { gt: now } }] },
151
+ { OR: [{ brandId: null }, { brandId: cart.brandId }] },
152
+ ],
153
+ },
154
+ });
155
+ if (!discount) {
156
+ return c.json({ error: { code: 'INVALID_DISCOUNT_CODE', message: 'Invalid or expired discount code' } }, 400);
157
+ }
158
+ // Check minimum purchase
159
+ const pricing = calculateCartCost(toPricingItems(cart));
160
+ if (discount.minPurchase) {
161
+ const minPurchase = toNumber(discount.minPurchase);
162
+ if (pricing.subtotal < minPurchase) {
163
+ return c.json({ error: { code: 'MIN_PURCHASE_NOT_MET', message: `Minimum purchase of ₦${minPurchase.toLocaleString('en-NG', { minimumFractionDigits: 2 })} required` } }, 400);
164
+ }
165
+ }
166
+ // Attach discount to cart
167
+ const updated = await prisma.cart.update({
168
+ where: { id: cartId },
169
+ data: { appliedDiscountCodeId: discount.id },
170
+ include: CART_INCLUDE_FULL,
171
+ });
172
+ const responseCart = await buildCartResponse(prisma, updated);
173
+ return c.json(responseCart, 200);
174
+ }
175
+ catch (error) {
176
+ console.error('Error applying discount to cart:', error);
177
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
178
+ }
179
+ })
180
+ // REMOVE DISCOUNT CODE
181
+ .post('/:id/remove-discount', zValidator('param', z.object({ id: z.string().uuid() })), async (c) => {
182
+ try {
183
+ const { id: cartId } = c.req.valid('param');
184
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
185
+ // Validate cart exists and is not expired
186
+ const { error } = await validateCart(prisma, cartId);
187
+ if (error) {
188
+ return c.json({ error: { code: error.code, message: error.message } }, error.status);
189
+ }
190
+ const updated = await prisma.cart.update({
191
+ where: { id: cartId },
192
+ data: { appliedDiscountCodeId: null },
193
+ include: CART_INCLUDE_FULL,
194
+ });
195
+ const responseCart = await buildCartResponse(prisma, updated);
196
+ return c.json(responseCart, 200);
197
+ }
198
+ catch (error) {
199
+ console.error('Error removing discount from cart:', error);
200
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
201
+ }
202
+ })
203
+ // ADD CART ITEM
204
+ .post('/:id/items', zValidator('param', z.object({ id: z.string().uuid() })), zValidator('json', z.object({
205
+ sku: z.string(),
206
+ quantity: z.number().int().positive(),
207
+ fbc: z.string().optional(), // Facebook Click ID for attribution
208
+ fbp: z.string().optional(), // Facebook Browser ID for attribution
209
+ })), async (c) => {
210
+ try {
211
+ const { id: cartId } = c.req.valid('param');
212
+ const input = c.req.valid('json');
213
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
214
+ // Validate cart exists and is not expired
215
+ const { cart, error } = await validateCart(prisma, cartId);
216
+ if (error) {
217
+ return c.json({ error: { code: error.code, message: error.message } }, error.status);
218
+ }
219
+ // Find variant by SKU for this brand
220
+ const variant = await prisma.productVariant.findFirst({
221
+ where: {
222
+ sku: input.sku,
223
+ product: { brandId: cart.brandId },
224
+ isActive: true,
225
+ deletedAt: null,
226
+ },
227
+ include: {
228
+ product: true,
229
+ },
230
+ });
231
+ if (!variant) {
232
+ return c.json({ error: { code: 'VARIANT_NOT_FOUND', message: 'Product variant not found' } }, 404);
233
+ }
234
+ // Upsert cart item (add new or update quantity)
235
+ const existingItem = await prisma.cartItem.findUnique({
236
+ where: {
237
+ cartId_variantId: {
238
+ cartId,
239
+ variantId: variant.id,
240
+ },
241
+ },
242
+ });
243
+ let cartItem;
244
+ if (existingItem) {
245
+ // Update quantity
246
+ cartItem = await prisma.cartItem.update({
247
+ where: { id: existingItem.id },
248
+ data: { quantity: existingItem.quantity + input.quantity },
249
+ include: {
250
+ variant: {
251
+ include: {
252
+ product: true,
253
+ },
254
+ },
255
+ },
256
+ });
257
+ }
258
+ else {
259
+ // Create new item
260
+ cartItem = await prisma.cartItem.create({
261
+ data: {
262
+ cartId,
263
+ variantId: variant.id,
264
+ quantity: input.quantity,
265
+ },
266
+ include: {
267
+ variant: {
268
+ include: {
269
+ product: true,
270
+ },
271
+ },
272
+ },
273
+ });
274
+ }
275
+ // Fetch updated cart for pricing
276
+ const updatedCart = await prisma.cart.findUnique({
277
+ where: { id: cartId },
278
+ include: CART_INCLUDE_FULL,
279
+ });
280
+ // Queue Meta CAPI AddToCart event
281
+ try {
282
+ const { enqueueAddToCartEvent } = await import('../../notifications/producers/meta-capi-producer');
283
+ await enqueueAddToCartEvent(c.env, {
284
+ cartId,
285
+ itemId: cartItem.id,
286
+ pixelId: updatedCart.brand.metaPixelId,
287
+ brandSiteUrl: updatedCart.brand.siteUrl,
288
+ productName: variant.product.name,
289
+ productSlug: variant.product.slug,
290
+ sku: variant.sku,
291
+ price: Number(variant.price),
292
+ quantity: input.quantity,
293
+ customerEmail: updatedCart.customerEmail,
294
+ customerPhone: updatedCart.customerPhone,
295
+ clientIpAddress: c.req.header('cf-connecting-ip'),
296
+ clientUserAgent: c.req.header('user-agent'),
297
+ fbc: input.fbc,
298
+ fbp: input.fbp,
299
+ });
300
+ }
301
+ catch (error) {
302
+ console.error('Failed to queue Meta CAPI AddToCart event:', error);
303
+ // Don't fail the request if CAPI queuing fails
304
+ }
305
+ const responseCart = await buildCartResponse(prisma, updatedCart);
306
+ return c.json(responseCart, 200);
307
+ }
308
+ catch (error) {
309
+ console.error('Error adding cart item:', error);
310
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
311
+ }
312
+ })
313
+ // UPDATE CART ITEM
314
+ .patch('/:id/items/:itemId', zValidator('param', z.object({
315
+ id: z.string().uuid(),
316
+ itemId: z.string().uuid(),
317
+ })), zValidator('json', z.object({
318
+ quantity: z.number().int().positive(),
319
+ })), async (c) => {
320
+ try {
321
+ const { id: cartId, itemId } = c.req.valid('param');
322
+ const input = c.req.valid('json');
323
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
324
+ const cartItem = await prisma.cartItem.findUnique({
325
+ where: { id: itemId },
326
+ include: CART_ITEM_WITH_CART_INCLUDE,
327
+ });
328
+ if (!cartItem || cartItem.cartId !== cartId) {
329
+ return c.json({ error: { code: 'CART_ITEM_NOT_FOUND', message: 'Cart item not found' } }, 404);
330
+ }
331
+ if (cartItem.cart.expiresAt < new Date()) {
332
+ return c.json({ error: { code: 'CART_EXPIRED', message: 'Cart has expired' } }, 410);
333
+ }
334
+ // Update quantity
335
+ await prisma.cartItem.update({
336
+ where: { id: itemId },
337
+ data: { quantity: input.quantity },
338
+ });
339
+ // Fetch updated cart for pricing
340
+ const updatedCart = await prisma.cart.findUnique({
341
+ where: { id: cartId },
342
+ include: CART_INCLUDE_FULL,
343
+ });
344
+ const responseCart = await buildCartResponse(prisma, updatedCart);
345
+ return c.json(responseCart, 200);
346
+ }
347
+ catch (error) {
348
+ console.error('Error updating cart item:', error);
349
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
350
+ }
351
+ })
352
+ // DELETE CART ITEM
353
+ .delete('/:id/items/:itemId', zValidator('param', z.object({
354
+ id: z.string().uuid(),
355
+ itemId: z.string().uuid(),
356
+ })), async (c) => {
357
+ try {
358
+ const { id: cartId, itemId } = c.req.valid('param');
359
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
360
+ const cartItem = await prisma.cartItem.findUnique({
361
+ where: { id: itemId },
362
+ include: CART_ITEM_WITH_CART_INCLUDE,
363
+ });
364
+ if (!cartItem || cartItem.cartId !== cartId) {
365
+ return c.json({ error: { code: 'CART_ITEM_NOT_FOUND', message: 'Cart item not found' } }, 404);
366
+ }
367
+ if (cartItem.cart.expiresAt < new Date()) {
368
+ return c.json({ error: { code: 'CART_EXPIRED', message: 'Cart has expired' } }, 410);
369
+ }
370
+ // Delete item
371
+ await prisma.cartItem.delete({
372
+ where: { id: itemId },
373
+ });
374
+ // Fetch updated cart for pricing
375
+ const updatedCart = await prisma.cart.findUnique({
376
+ where: { id: cartId },
377
+ include: CART_INCLUDE_FULL,
378
+ });
379
+ if (!updatedCart) {
380
+ return c.json({ error: { code: 'CART_NOT_FOUND', message: 'Cart not found' } }, 404);
381
+ }
382
+ const responseCart = await buildCartResponse(prisma, updatedCart);
383
+ return c.json(responseCart, 200);
384
+ }
385
+ catch (error) {
386
+ console.error('Error removing cart item:', error);
387
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
388
+ }
389
+ })
390
+ // CHECKOUT CART
391
+ .post('/:id/checkout', zValidator('param', z.object({ id: z.uuid() })), zValidator('json', z.object({
392
+ firstName: z.string().min(1),
393
+ lastName: z.string().min(1),
394
+ email: z.email().optional(),
395
+ phone: z.string().optional(),
396
+ address: z.string().min(1),
397
+ city: z.string().min(1),
398
+ deliveryZoneId: z.uuid(),
399
+ paymentMethod: z.enum(['cod', 'online']),
400
+ paystackReference: z.string().optional(),
401
+ ifUnmodifiedSince: z.string().datetime().optional(),
402
+ fbc: z.string().optional(), // Facebook Click ID for attribution
403
+ fbp: z.string().optional(), // Facebook Browser ID for attribution
404
+ })), async (c) => {
405
+ try {
406
+ const { id: cartId } = c.req.valid('param');
407
+ const input = c.req.valid('json');
408
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
409
+ const cart = await prisma.cart.findUnique({
410
+ where: { id: cartId },
411
+ include: CART_INCLUDE_FULL,
412
+ });
413
+ if (!cart) {
414
+ return c.json({ error: { code: 'CART_NOT_FOUND', message: 'Cart not found' } }, 404);
415
+ }
416
+ if (cart.expiresAt < new Date()) {
417
+ return c.json({ error: { code: 'CART_EXPIRED', message: 'Cart has expired' } }, 410);
418
+ }
419
+ if (cart.items.length === 0) {
420
+ return c.json({ error: { code: 'CART_EMPTY', message: 'Cart is empty' } }, 400);
421
+ }
422
+ // Queue Meta CAPI InitiateCheckout event
423
+ try {
424
+ const { enqueueInitiateCheckoutEvent } = await import('../../notifications/producers/meta-capi-producer');
425
+ const pricing = calculateCartCost(toPricingItems(cart));
426
+ const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
427
+ await enqueueInitiateCheckoutEvent(c.env, {
428
+ cartId,
429
+ pixelId: cart.brand.metaPixelId,
430
+ brandSiteUrl: cart.brand.siteUrl,
431
+ cartTotal: pricing.total,
432
+ itemCount,
433
+ customerEmail: input.email || cart.customerEmail,
434
+ customerPhone: input.phone || cart.customerPhone,
435
+ clientIpAddress: c.req.header('cf-connecting-ip'),
436
+ clientUserAgent: c.req.header('user-agent'),
437
+ fbc: input.fbc,
438
+ fbp: input.fbp,
439
+ });
440
+ }
441
+ catch (error) {
442
+ console.error('Failed to queue Meta CAPI InitiateCheckout event:', error);
443
+ // Don't fail the checkout if CAPI queuing fails
444
+ }
445
+ // Concurrency check
446
+ const ifMatch = c.req.header('if-match');
447
+ if (ifMatch && ifMatch !== etagFrom(cart.updatedAt)) {
448
+ return c.json({ error: { code: 'PRECONDITION_FAILED', message: 'Cart has changed' } }, 412);
449
+ }
450
+ if (input.ifUnmodifiedSince && new Date(input.ifUnmodifiedSince).getTime() !== cart.updatedAt.getTime()) {
451
+ return c.json({ error: { code: 'PRECONDITION_FAILED', message: 'Cart has changed' } }, 412);
452
+ }
453
+ // Validate and load delivery zone
454
+ if (!input.deliveryZoneId) {
455
+ return c.json({ error: { code: 'MISSING_DELIVERY_ZONE', message: 'Select a delivery zone' } }, 400);
456
+ }
457
+ const zone = await prisma.deliveryZone.findFirst({
458
+ where: { id: input.deliveryZoneId, isActive: true, deletedAt: null },
459
+ include: { state: true },
460
+ });
461
+ if (!zone) {
462
+ return c.json({ error: { code: 'INVALID_DELIVERY_ZONE', message: 'Zone not available' } }, 400);
463
+ }
464
+ // Validate payment method against cart's available methods
465
+ if (!cart.availablePaymentMethods.includes(input.paymentMethod)) {
466
+ return c.json({ error: { code: 'PAYMENT_METHOD_NOT_AVAILABLE', message: 'Payment not available for selection' } }, 400);
467
+ }
468
+ // Calculate pricing with quantity discounts
469
+ const pricing = calculateCartCost(toPricingItems(cart));
470
+ // Compute delivery charge with free shipping threshold
471
+ const threshold = zone.freeShippingThreshold ? Number(zone.freeShippingThreshold) : null;
472
+ const base = Number(zone.deliveryCost);
473
+ const deliveryCharge = threshold !== null && pricing.subtotal >= threshold ? 0 : base;
474
+ const estimatedDays = zone.estimatedDays ?? null;
475
+ // Use the discount code already applied to the cart (if any)
476
+ let discountCodeRecord = null;
477
+ let discountAmount = 0;
478
+ let isRecoveredCart = false;
479
+ if (cart.appliedDiscountCodeId) {
480
+ discountCodeRecord = await prisma.discountCode.findUnique({
481
+ where: { id: cart.appliedDiscountCodeId },
482
+ });
483
+ // Validate discount is still active and valid
484
+ if (discountCodeRecord && discountCodeRecord.isActive) {
485
+ const now = new Date();
486
+ const isValid = (!discountCodeRecord.validFrom || discountCodeRecord.validFrom <= now) &&
487
+ (!discountCodeRecord.validUntil || discountCodeRecord.validUntil > now) &&
488
+ (!discountCodeRecord.brandId || discountCodeRecord.brandId === cart.brandId);
489
+ if (isValid) {
490
+ // Calculate discount amount
491
+ if (discountCodeRecord.type === 'percentage') {
492
+ discountAmount = round(pricing.subtotal * (toNumber(discountCodeRecord.value) / 100));
493
+ // Apply max discount cap if exists
494
+ if (discountCodeRecord.maxDiscount) {
495
+ const maxDiscountNum = toNumber(discountCodeRecord.maxDiscount);
496
+ if (discountAmount > maxDiscountNum) {
497
+ discountAmount = maxDiscountNum;
498
+ }
499
+ }
500
+ }
501
+ else if (discountCodeRecord.type === 'fixed') {
502
+ discountAmount = Math.min(toNumber(discountCodeRecord.value), pricing.subtotal);
503
+ }
504
+ // Check if this is a recovery code
505
+ if (discountCodeRecord.category === 'recovery') {
506
+ isRecoveredCart = true;
507
+ }
508
+ }
509
+ else {
510
+ // Discount is no longer valid, ignore it
511
+ discountCodeRecord = null;
512
+ }
513
+ }
514
+ else {
515
+ discountCodeRecord = null;
516
+ }
517
+ }
518
+ // Consider cart recovered if it's at least 1 hour old (when first recovery attempt is sent)
519
+ const oneHourMs = 60 * 60 * 1000;
520
+ if (!isRecoveredCart && Date.now() - new Date(cart.createdAt).getTime() >= oneHourMs) {
521
+ isRecoveredCart = true;
522
+ }
523
+ // Calculate final total
524
+ const finalTotal = round(Math.max(0, pricing.subtotal + deliveryCharge - discountAmount));
525
+ // Build order items
526
+ const orderItems = cart.items.map((item) => {
527
+ const variant = item.variant;
528
+ const product = variant.product;
529
+ let finalPrice = toNumber(variant.price);
530
+ // Apply quantity discount if available
531
+ if (product.quantityDiscounts && typeof product.quantityDiscounts === 'object') {
532
+ const discounts = product.quantityDiscounts;
533
+ const applicableQuantities = Object.keys(discounts)
534
+ .map(Number)
535
+ .filter((qty) => item.quantity >= qty)
536
+ .sort((a, b) => b - a);
537
+ if (applicableQuantities.length > 0) {
538
+ const discountPercent = discounts[applicableQuantities[0].toString()];
539
+ finalPrice = round(toNumber(variant.price) * (1 - discountPercent / 100));
540
+ }
541
+ }
542
+ return {
543
+ variantId: variant.id,
544
+ quantity: item.quantity,
545
+ priceAtPurchase: finalPrice,
546
+ };
547
+ });
548
+ // Create order in transaction with conversion tracking
549
+ const result = await prisma.$transaction(async (tx) => {
550
+ // Create order
551
+ const order = await tx.order.create({
552
+ data: {
553
+ brandId: cart.brandId,
554
+ firstName: input.firstName,
555
+ lastName: input.lastName,
556
+ phone: (input.phone || cart.customerPhone), // Use checkout phone or fallback to cart phone
557
+ email: input.email || cart.customerEmail, // Use checkout email or fallback to cart email
558
+ address: input.address,
559
+ city: input.city,
560
+ deliveryZoneId: zone.id,
561
+ deliveryCharge,
562
+ estimatedDays,
563
+ totalPrice: finalTotal,
564
+ discountCodeId: discountCodeRecord?.id,
565
+ discountAmount: discountAmount > 0 ? discountAmount : null,
566
+ paymentMethod: input.paymentMethod,
567
+ paystackReference: input.paystackReference,
568
+ status: 'pending',
569
+ items: {
570
+ create: orderItems,
571
+ },
572
+ },
573
+ include: ORDER_INCLUDE_FULL,
574
+ });
575
+ // If discount code was used, create usage log and increment count
576
+ if (discountCodeRecord && discountAmount > 0) {
577
+ await tx.discountCodeUsage.create({
578
+ data: {
579
+ discountCodeId: discountCodeRecord.id,
580
+ orderId: order.id,
581
+ customerPhone: input.phone || cart.customerPhone || '',
582
+ discountAmount,
583
+ originalAmount: pricing.subtotal,
584
+ },
585
+ });
586
+ await tx.discountCode.update({
587
+ where: { id: discountCodeRecord.id },
588
+ data: { usageCount: { increment: 1 } },
589
+ });
590
+ }
591
+ // Track conversion: Update cart with order ID and recovery status
592
+ await tx.cart.update({
593
+ where: { id: cartId },
594
+ data: {
595
+ convertedToOrderId: order.id,
596
+ wasRecovered: isRecoveredCart,
597
+ },
598
+ });
599
+ return order;
600
+ });
601
+ // Queue order confirmation notification
602
+ try {
603
+ const { enqueueOrderNotification } = await import('../../notifications/producers/order-notification');
604
+ await enqueueOrderNotification(c.env, 'order_confirmation', result.id);
605
+ }
606
+ catch (error) {
607
+ console.error('Failed to queue order confirmation notification:', error);
608
+ // Don't fail the checkout if notification fails
609
+ }
610
+ // Queue Meta CAPI Purchase event
611
+ try {
612
+ const { enqueuePurchaseEvent } = await import('../../notifications/producers/meta-capi-producer');
613
+ await enqueuePurchaseEvent(c.env, result, {
614
+ clientIpAddress: c.req.header('cf-connecting-ip'),
615
+ clientUserAgent: c.req.header('user-agent'),
616
+ fbc: input.fbc,
617
+ fbp: input.fbp,
618
+ });
619
+ }
620
+ catch (error) {
621
+ console.error('Failed to queue Meta CAPI Purchase event:', error);
622
+ // Don't fail the checkout if CAPI queuing fails
623
+ }
624
+ return c.json(formatOrderResponse(result), 201);
625
+ }
626
+ catch (error) {
627
+ console.error('Error checking out cart:', error);
628
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
629
+ }
630
+ });
631
+ export default app;
@@ -0,0 +1,35 @@
1
+ import { AppContext } from '../../types';
2
+ declare const app: import("hono/hono-base").HonoBase<AppContext, {
3
+ "/": {
4
+ $get: {
5
+ input: {};
6
+ output: {
7
+ id: string;
8
+ name: string;
9
+ zones: {
10
+ id: string;
11
+ name: string;
12
+ deliveryCost: number;
13
+ freeShippingThreshold: number;
14
+ allowCOD: boolean;
15
+ allowOnline: boolean;
16
+ waybillOnly: boolean;
17
+ estimatedDays: number;
18
+ }[];
19
+ }[];
20
+ outputFormat: "json";
21
+ status: 200;
22
+ } | {
23
+ input: {};
24
+ output: {
25
+ error: {
26
+ code: string;
27
+ message: any;
28
+ };
29
+ };
30
+ outputFormat: "json";
31
+ status: 500;
32
+ };
33
+ };
34
+ }, "/">;
35
+ export default app;