@cartbase/storefront 0.1.0

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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,91 @@
1
+ import type { ReviewsUiLabels } from "./labels"
2
+
3
+ /**
4
+ * Bulgarian reviews-UI labels — the EXACT Alenika production copy, lifted
5
+ * from src/components/product/reviews-list.tsx, src/app/(main)/review/
6
+ * [token]/page.tsx and review-form.tsx (where it shipped hardcoded), with
7
+ * the numbers/emails parameterized into `{placeholder}` slots.
8
+ */
9
+ export const bulgarianReviewsUiLabels: ReviewsUiLabels = {
10
+ sectionTitle: "Отзиви",
11
+ reviewSingular: "отзив",
12
+ reviewPlural: "отзива",
13
+ sortAria: "Сортирай",
14
+ sortDefault: "Препоръчани",
15
+ sortNewest: "Най-нови",
16
+ sortRatingDesc: "Най-високи оценки",
17
+ sortRatingAsc: "Най-ниски оценки",
18
+ verified: "Потвърден",
19
+ verifiedTitle: "Истински купувач, потвърден чрез линк от поръчка",
20
+ loadMore: "Покажи още",
21
+ loading: "Зарежда...",
22
+ dateToday: "днес",
23
+ dateYesterday: "вчера",
24
+ dateDaysAgo: "преди {n} дни",
25
+ showPhoto: "Покажи снимка",
26
+ playVideo: "Пусни видео",
27
+ close: "Затвори",
28
+ expiredTitle: "Този линк за отзив е изтекъл",
29
+ expiredBody:
30
+ "Линкът беше валиден 60 дни от датата на покупката. Ако искаш да оставиш отзив, пиши ни на {email}.",
31
+ invalidTitle: "Този линк за отзив не е валиден",
32
+ invalidBody:
33
+ "Възможно е да си натиснал/а на стар линк. Ако имаш въпроси, пиши ни на {email}.",
34
+ alreadyTitle: "Благодарим за отзива!",
35
+ alreadyBodyWithProduct: "Вече сме получили твоя отзив за {name}.",
36
+ alreadyBody: "Този отзив вече е изпратен.",
37
+ greeting: "Здравей",
38
+ headingRate: "Сподели мнението си",
39
+ headingPhoto: "Остана само една крачка",
40
+ introRate:
41
+ "Кратък отзив с твоето мнение помага на хора, които сега обмислят същия избор.",
42
+ introPhoto:
43
+ "Оценката ти е запазена. Добави снимка на продукта и получаваш {pct}% отстъпка за следващата поръчка.",
44
+ productLabel: "Продукт",
45
+ ratingLabel: "Оценка",
46
+ ratingOutOf: "{n} от 5",
47
+ ratingAria: "{n} от 5 звезди",
48
+ bodyLabel: "Какво ти хареса или не?",
49
+ bodyPlaceholder:
50
+ "Опиши с твои думи. Колкото повече детайл, толкова по-полезно за следващите.",
51
+ consentNote:
52
+ "Изпращайки отзива, се съгласяваш да го публикуваме на страницата на продукта.",
53
+ continue: "Продължи",
54
+ submitting: "Изпращане...",
55
+ bonusBadge: "Бонус",
56
+ photoTitle: "Покажи ни го, вземи подарък {pct}%",
57
+ photoSubtitle:
58
+ "Добави снимка или кратко видео на продукта и ти подаряваме {pct}% отстъпка за следваща поръчка.",
59
+ dropHint: "Пусни файлове тук или кликни, за да избереш",
60
+ dropLimits: "До {n} снимки (JPG/PNG/WEBP, 8 MB), 1 видео (MP4/MOV, 50 MB, 60 сек)",
61
+ skip: "Пропусни",
62
+ skipAria: "Пропусни",
63
+ submitPhoto: "Изпрати и вземи {pct}%",
64
+ waitingUpload: "Изчакай качването...",
65
+ remove: "Премахни",
66
+ thanksTitle: "Благодарим!",
67
+ thanksBody: "Отзивът ти е получен и вече е публикуван на страницата на продукта.",
68
+ rewardTitle: "Твоята отстъпка от {pct}%",
69
+ copy: "Копирай",
70
+ copied: "Копирано",
71
+ copyAria: "Копирай кода",
72
+ rewardNote:
73
+ "Изпратихме кода и на имейла ти. Използвай го при следващата си поръчка - валиден е за еднократна употреба.",
74
+ codeUnavailable:
75
+ "Снимката е запазена. Възникна проблем с генерирането на кода за отстъпка - пиши ни на {email} и веднага ще ти го изпратим.",
76
+ viewProduct: "Виж продукта",
77
+ toStore: "Към магазина",
78
+ errUnsupported: "Файлът {name} не е поддържан формат.",
79
+ errMaxImages: "Максимум {n} снимки.",
80
+ errImageTooLarge: "Снимката {name} е по-голяма от {mb} MB.",
81
+ errMaxVideos: "Максимум {n} видео.",
82
+ errVideoTooLarge: "Видеото е по-голямо от {mb} MB.",
83
+ errVideoTooLong: "Видеото е по-дълго от {s} секунди.",
84
+ errVideoMeta: "Не успяхме да прочетем продължителността на видеото.",
85
+ errUploadFailed: "Качването на {name} не успя. Опитай отново.",
86
+ errRateLimited: "Изпратени са твърде много отзиви от този адрес. Опитай по-късно.",
87
+ errAlreadySubmitted: "Този отзив вече е изпратен.",
88
+ errExpired: "Линкът за отзив е изтекъл.",
89
+ errGeneric: "Нещо се обърка. Опитай отново.",
90
+ errPhotoSave: "Снимката не можа да се запази. Опитай отново.",
91
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Reviews UI label pack. The Alenika production storefront shipped this
3
+ * copy hardcoded in Bulgarian (src/components/product/reviews-list.tsx +
4
+ * src/app/(main)/review/[token]/review-form.tsx); the barter port lifts
5
+ * every string into the family's labels/labels-bg discipline. English
6
+ * defaults here; the exact production Bulgarian copy in `labels-bg.ts`.
7
+ *
8
+ * Templates use `{placeholder}` slots resolved via `formatLabel()`
9
+ * (helpers.ts): `{n}`, `{name}`, `{mb}`, `{s}`, `{pct}`, `{email}`.
10
+ */
11
+ export type ReviewsUiLabels = {
12
+ // — list / widget —
13
+ sectionTitle: string
14
+ /** "1 review" singular noun. */
15
+ reviewSingular: string
16
+ /** "N reviews" plural noun. */
17
+ reviewPlural: string
18
+ sortAria: string
19
+ sortDefault: string
20
+ sortNewest: string
21
+ sortRatingDesc: string
22
+ sortRatingAsc: string
23
+ verified: string
24
+ verifiedTitle: string
25
+ loadMore: string
26
+ loading: string
27
+ dateToday: string
28
+ dateYesterday: string
29
+ /** `{n}` = day count. */
30
+ dateDaysAgo: string
31
+ showPhoto: string
32
+ playVideo: string
33
+ close: string
34
+ // — wizard: entry states (ported from the alenika /review/[token] page) —
35
+ expiredTitle: string
36
+ /** `{email}` = store support email. */
37
+ expiredBody: string
38
+ invalidTitle: string
39
+ /** `{email}` = store support email. */
40
+ invalidBody: string
41
+ /** Terminal "thanks" panel: consumed token with NO review row. */
42
+ alreadyTitle: string
43
+ /** `{name}` = product title. */
44
+ alreadyBodyWithProduct: string
45
+ alreadyBody: string
46
+ /** Greeting prefix — the component appends ", {firstName}". */
47
+ greeting: string
48
+ headingRate: string
49
+ headingPhoto: string
50
+ introRate: string
51
+ /** `{pct}` = reward percentage. */
52
+ introPhoto: string
53
+ productLabel: string
54
+ // — wizard: rate step —
55
+ ratingLabel: string
56
+ /** `{n}` = chosen rating. */
57
+ ratingOutOf: string
58
+ /** `{n}` = star value (aria). */
59
+ ratingAria: string
60
+ bodyLabel: string
61
+ bodyPlaceholder: string
62
+ consentNote: string
63
+ continue: string
64
+ submitting: string
65
+ // — wizard: photo step —
66
+ bonusBadge: string
67
+ /** `{pct}` = reward percentage. */
68
+ photoTitle: string
69
+ /** `{pct}` = reward percentage. */
70
+ photoSubtitle: string
71
+ dropHint: string
72
+ /** `{n}` = max images. */
73
+ dropLimits: string
74
+ skip: string
75
+ skipAria: string
76
+ /** `{pct}` = reward percentage. */
77
+ submitPhoto: string
78
+ waitingUpload: string
79
+ remove: string
80
+ // — wizard: done step —
81
+ thanksTitle: string
82
+ thanksBody: string
83
+ /** `{pct}` = reward percentage. */
84
+ rewardTitle: string
85
+ copy: string
86
+ copied: string
87
+ copyAria: string
88
+ rewardNote: string
89
+ /** `{email}` = store support email. */
90
+ codeUnavailable: string
91
+ viewProduct: string
92
+ toStore: string
93
+ // — upload errors —
94
+ /** `{name}` = file name. */
95
+ errUnsupported: string
96
+ /** `{n}` = max images. */
97
+ errMaxImages: string
98
+ /** `{name}` file, `{mb}` cap. */
99
+ errImageTooLarge: string
100
+ /** `{n}` = max videos. */
101
+ errMaxVideos: string
102
+ /** `{mb}` = cap. */
103
+ errVideoTooLarge: string
104
+ /** `{s}` = max seconds. */
105
+ errVideoTooLong: string
106
+ errVideoMeta: string
107
+ /** `{name}` = file name. */
108
+ errUploadFailed: string
109
+ // — submit errors (status → copy map, wizard-state.ts) —
110
+ errRateLimited: string
111
+ errAlreadySubmitted: string
112
+ errExpired: string
113
+ errGeneric: string
114
+ errPhotoSave: string
115
+ }
116
+
117
+ export const defaultReviewsUiLabels: ReviewsUiLabels = {
118
+ sectionTitle: "Reviews",
119
+ reviewSingular: "review",
120
+ reviewPlural: "reviews",
121
+ sortAria: "Sort",
122
+ sortDefault: "Recommended",
123
+ sortNewest: "Newest",
124
+ sortRatingDesc: "Highest rated",
125
+ sortRatingAsc: "Lowest rated",
126
+ verified: "Verified",
127
+ verifiedTitle: "Real buyer, verified via an order link",
128
+ loadMore: "Show more",
129
+ loading: "Loading...",
130
+ dateToday: "today",
131
+ dateYesterday: "yesterday",
132
+ dateDaysAgo: "{n} days ago",
133
+ showPhoto: "View photo",
134
+ playVideo: "Play video",
135
+ close: "Close",
136
+ expiredTitle: "This review link has expired",
137
+ expiredBody:
138
+ "The link was valid for 60 days after your purchase. If you'd like to leave a review, write to us at {email}.",
139
+ invalidTitle: "This review link is not valid",
140
+ invalidBody:
141
+ "You may have followed an old link. If you have any questions, write to us at {email}.",
142
+ alreadyTitle: "Thanks for your review!",
143
+ alreadyBodyWithProduct: "We already received your review for {name}.",
144
+ alreadyBody: "This review has already been sent.",
145
+ greeting: "Hello",
146
+ headingRate: "Share your thoughts",
147
+ headingPhoto: "One step left",
148
+ introRate:
149
+ "A short review in your own words helps people who are considering the same choice.",
150
+ introPhoto:
151
+ "Your rating is saved. Add a photo of the product and get a {pct}% discount on your next order.",
152
+ productLabel: "Product",
153
+ ratingLabel: "Rating",
154
+ ratingOutOf: "{n} of 5",
155
+ ratingAria: "{n} of 5 stars",
156
+ bodyLabel: "What did you like or dislike?",
157
+ bodyPlaceholder:
158
+ "Describe it in your own words. The more detail, the more helpful for the next customer.",
159
+ consentNote:
160
+ "By sending the review you agree that we may publish it on the product page.",
161
+ continue: "Continue",
162
+ submitting: "Sending...",
163
+ bonusBadge: "Bonus",
164
+ photoTitle: "Show it off, get {pct}% back",
165
+ photoSubtitle:
166
+ "Add a photo or a short video of the product and we will gift you a {pct}% discount for your next order.",
167
+ dropHint: "Drop files here or click to choose",
168
+ dropLimits: "Up to {n} photos (JPG/PNG/WEBP, 8 MB), 1 video (MP4/MOV, 50 MB, 60 sec)",
169
+ skip: "Skip",
170
+ skipAria: "Skip",
171
+ submitPhoto: "Send and get {pct}%",
172
+ waitingUpload: "Waiting for the upload...",
173
+ remove: "Remove",
174
+ thanksTitle: "Thank you!",
175
+ thanksBody: "Your review was received and is now published on the product page.",
176
+ rewardTitle: "Your {pct}% discount",
177
+ copy: "Copy",
178
+ copied: "Copied",
179
+ copyAria: "Copy the code",
180
+ rewardNote:
181
+ "We also emailed you the code. Use it on your next order — it is valid for a single use.",
182
+ codeUnavailable:
183
+ "The photo was saved. Something went wrong generating your discount code — write to us at {email} and we will send it right away.",
184
+ viewProduct: "View product",
185
+ toStore: "Back to the store",
186
+ errUnsupported: "The file {name} is not a supported format.",
187
+ errMaxImages: "Maximum {n} photos.",
188
+ errImageTooLarge: "The photo {name} is larger than {mb} MB.",
189
+ errMaxVideos: "Maximum {n} video.",
190
+ errVideoTooLarge: "The video is larger than {mb} MB.",
191
+ errVideoTooLong: "The video is longer than {s} seconds.",
192
+ errVideoMeta: "We could not read the video duration.",
193
+ errUploadFailed: "Uploading {name} failed. Try again.",
194
+ errRateLimited: "Too many reviews were sent from this address. Try again later.",
195
+ errAlreadySubmitted: "This review has already been sent.",
196
+ errExpired: "The review link has expired.",
197
+ errGeneric: "Something went wrong. Try again.",
198
+ errPhotoSave: "The photo could not be saved. Try again.",
199
+ }
@@ -0,0 +1,345 @@
1
+ "use client"
2
+
3
+ import { useCallback, useRef, useState } from "react"
4
+ import { Loader2, Upload, X } from "lucide-react"
5
+ import type { StorefrontClient } from "../api/http"
6
+ import { createUploadUrl, type ReviewMedia } from "../api/reviews"
7
+ import { cn } from "../lib/utils"
8
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
9
+ import {
10
+ MAX_IMAGES,
11
+ MAX_VIDEOS,
12
+ MAX_VIDEO_SECONDS,
13
+ UPLOAD_ACCEPT,
14
+ checkFileForUpload,
15
+ formatLabel,
16
+ } from "./helpers"
17
+
18
+ /**
19
+ * Review media uploader — drag-drop zone + preview grid + the signed-PUT
20
+ * pipeline. Ported from the Alenika review-form's media handling
21
+ * (src/app/(main)/review/[token]/review-form.tsx) with the barter seam:
22
+ * the signed URL comes from `api/reviews.createUploadUrl(client, {token,
23
+ * name, type, size})` (docs/storefront/reviews.md) instead of the Medusa
24
+ * `/store/files/upload-url` helper; the raw-file PUT to R2 is unchanged.
25
+ *
26
+ * Pipeline per file:
27
+ * 1. user drops / picks
28
+ * 2. client-side validation (mime, size — `checkFileForUpload`, with
29
+ * the local slot counters so a multi-select batch can't overshoot
30
+ * the caps; production fix) + async video-duration check (60s)
31
+ * 3. POST upload-url → signed R2 PUT URL
32
+ * 4. PUT the raw file body to the signed URL
33
+ * 5. swap the blob preview for the returned `publicUrl` in `media[]`
34
+ * (and revoke the blob so a 50 MB video doesn't stay resident —
35
+ * production fix)
36
+ *
37
+ * Constraints: ≤ 6 images (jpg/png/webp, 8 MB) + 1 video (mp4/mov,
38
+ * 50 MB, 60 sec) — mirrors the server rules.
39
+ */
40
+
41
+ export type LocalMedia = ReviewMedia & {
42
+ /** Local-only id for the preview grid key — never sent to the API. */
43
+ _localId: string
44
+ _uploading?: boolean
45
+ _failed?: boolean
46
+ }
47
+
48
+ /** The attach payload: uploaded-and-ready entries, persisted fields only. */
49
+ export function readyMedia(media: readonly LocalMedia[]): ReviewMedia[] {
50
+ return media
51
+ .filter((m) => !m._uploading && !m._failed)
52
+ .map<ReviewMedia>((m) => ({
53
+ type: m.type,
54
+ url: m.url,
55
+ thumb: m.thumb,
56
+ w: m.w,
57
+ h: m.h,
58
+ bytes: m.bytes,
59
+ }))
60
+ }
61
+
62
+ /**
63
+ * Direct PUT to the signed R2 upload URL. Browsers handle the CORS
64
+ * preflight automatically; the bucket must allow the storefront origin.
65
+ * Same mechanism as the admin file picker.
66
+ */
67
+ export async function putToSignedUrl(args: {
68
+ uploadUrl: string
69
+ file: File
70
+ }): Promise<boolean> {
71
+ try {
72
+ const res = await fetch(args.uploadUrl, {
73
+ method: "PUT",
74
+ headers: { "Content-Type": args.file.type },
75
+ body: args.file,
76
+ })
77
+ return res.ok
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ /** Promise wrapper around <video> metadata-load to get duration. */
84
+ function readVideoDuration(file: File): Promise<number> {
85
+ return new Promise((resolve, reject) => {
86
+ const url = URL.createObjectURL(file)
87
+ const video = document.createElement("video")
88
+ video.preload = "metadata"
89
+ video.onloadedmetadata = () => {
90
+ URL.revokeObjectURL(url)
91
+ resolve(video.duration)
92
+ }
93
+ video.onerror = () => {
94
+ URL.revokeObjectURL(url)
95
+ reject(new Error("could not read video metadata"))
96
+ }
97
+ video.src = url
98
+ })
99
+ }
100
+
101
+ /** Promise wrapper around <img> load to get natural dimensions. */
102
+ function readImageDimensions(file: File): Promise<{ w: number; h: number }> {
103
+ return new Promise((resolve, reject) => {
104
+ const url = URL.createObjectURL(file)
105
+ const img = new window.Image()
106
+ img.onload = () => {
107
+ URL.revokeObjectURL(url)
108
+ resolve({ w: img.naturalWidth, h: img.naturalHeight })
109
+ }
110
+ img.onerror = () => {
111
+ URL.revokeObjectURL(url)
112
+ reject(new Error("could not read image"))
113
+ }
114
+ img.src = url
115
+ })
116
+ }
117
+
118
+ export interface ReviewPhotoUploadProps {
119
+ client: StorefrontClient
120
+ /** The review token — it is the upload auth (consumed tokens accepted). */
121
+ token: string
122
+ media: LocalMedia[]
123
+ onChange: (update: (prev: LocalMedia[]) => LocalMedia[]) => void
124
+ /** Validation/upload error copy (already formatted); null clears. */
125
+ onError: (message: string | null) => void
126
+ labels?: Partial<ReviewsUiLabels>
127
+ }
128
+
129
+ export function ReviewPhotoUpload({
130
+ client,
131
+ token,
132
+ media,
133
+ onChange,
134
+ onError,
135
+ labels,
136
+ }: ReviewPhotoUploadProps) {
137
+ const l = { ...defaultReviewsUiLabels, ...labels }
138
+ const [dragging, setDragging] = useState(false)
139
+ const fileInputRef = useRef<HTMLInputElement>(null)
140
+
141
+ const imagesCount = media.filter((m) => m.type === "image").length
142
+ const videosCount = media.filter((m) => m.type === "video").length
143
+
144
+ const handleFiles = useCallback(
145
+ async (files: FileList | File[]) => {
146
+ onError(null)
147
+ // Running slot counts for THIS batch — see checkFileForUpload.
148
+ const slots = {
149
+ imgSlots: MAX_IMAGES - imagesCount,
150
+ vidSlots: MAX_VIDEOS - videosCount,
151
+ }
152
+ for (const file of Array.from(files)) {
153
+ const check = checkFileForUpload(
154
+ { name: file.name, type: file.type, size: file.size },
155
+ slots
156
+ )
157
+ if (!check.ok) {
158
+ onError(formatLabel(l[check.errorKey], check.vars))
159
+ continue
160
+ }
161
+ const isImage = check.kind === "image"
162
+ if (isImage) {
163
+ slots.imgSlots--
164
+ } else {
165
+ try {
166
+ const duration = await readVideoDuration(file)
167
+ if (duration > MAX_VIDEO_SECONDS) {
168
+ onError(formatLabel(l.errVideoTooLong, { s: MAX_VIDEO_SECONDS }))
169
+ continue
170
+ }
171
+ } catch {
172
+ onError(l.errVideoMeta)
173
+ continue
174
+ }
175
+ slots.vidSlots--
176
+ }
177
+
178
+ const localId =
179
+ typeof crypto !== "undefined" && "randomUUID" in crypto
180
+ ? crypto.randomUUID()
181
+ : `${Date.now()}-${Math.random()}`
182
+ const placeholderUrl = URL.createObjectURL(file)
183
+
184
+ onChange((prev) => [
185
+ ...prev,
186
+ {
187
+ _localId: localId,
188
+ type: isImage ? "image" : "video",
189
+ url: placeholderUrl,
190
+ thumb: placeholderUrl,
191
+ w: 0,
192
+ h: 0,
193
+ bytes: file.size,
194
+ _uploading: true,
195
+ },
196
+ ])
197
+
198
+ // Kick off upload + signed-URL exchange.
199
+ ;(async () => {
200
+ try {
201
+ const signed = await createUploadUrl(client, {
202
+ token,
203
+ name: file.name,
204
+ type: file.type,
205
+ size: file.size,
206
+ })
207
+
208
+ const ok = await putToSignedUrl({
209
+ uploadUrl: signed.uploadUrl,
210
+ file,
211
+ })
212
+ if (!ok) throw new Error("upload failed")
213
+
214
+ const dims = isImage
215
+ ? await readImageDimensions(file).catch(() => ({ w: 0, h: 0 }))
216
+ : { w: 0, h: 0 }
217
+
218
+ // Free the blob preview now that the real URL is in — otherwise
219
+ // a 50 MB video blob stays resident until page unload (mobile).
220
+ URL.revokeObjectURL(placeholderUrl)
221
+
222
+ onChange((prev) =>
223
+ prev.map((m) =>
224
+ m._localId === localId
225
+ ? {
226
+ ...m,
227
+ url: signed.publicUrl,
228
+ thumb: signed.publicUrl,
229
+ w: dims.w,
230
+ h: dims.h,
231
+ _uploading: false,
232
+ }
233
+ : m
234
+ )
235
+ )
236
+ } catch {
237
+ onChange((prev) =>
238
+ prev.map((m) =>
239
+ m._localId === localId
240
+ ? { ...m, _uploading: false, _failed: true }
241
+ : m
242
+ )
243
+ )
244
+ onError(formatLabel(l.errUploadFailed, { name: file.name }))
245
+ }
246
+ })()
247
+ }
248
+ },
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ [imagesCount, videosCount, token, client]
251
+ )
252
+
253
+ const removeMedia = (localId: string) => {
254
+ onChange((prev) => {
255
+ const target = prev.find((m) => m._localId === localId)
256
+ if (target?.url?.startsWith("blob:")) URL.revokeObjectURL(target.url)
257
+ return prev.filter((m) => m._localId !== localId)
258
+ })
259
+ }
260
+
261
+ const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
262
+ e.preventDefault()
263
+ setDragging(false)
264
+ if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files)
265
+ }
266
+ const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
267
+ e.preventDefault()
268
+ setDragging(true)
269
+ }
270
+ const onDragLeave = () => setDragging(false)
271
+
272
+ return (
273
+ <>
274
+ <div
275
+ onDrop={onDrop}
276
+ onDragOver={onDragOver}
277
+ onDragLeave={onDragLeave}
278
+ onClick={() => fileInputRef.current?.click()}
279
+ className={cn(
280
+ "mt-5 rounded-[2px] border-2 border-dashed px-4 py-8 text-center cursor-pointer transition-colors",
281
+ dragging
282
+ ? "border-primary bg-primary/5"
283
+ : "border-border hover:bg-muted/30"
284
+ )}
285
+ >
286
+ <Upload className="w-6 h-6 mx-auto text-muted-foreground mb-2" />
287
+ <p className="text-sm text-foreground">{l.dropHint}</p>
288
+ <p className="text-xs text-muted-foreground mt-1">
289
+ {formatLabel(l.dropLimits, { n: MAX_IMAGES })}
290
+ </p>
291
+ <input
292
+ ref={fileInputRef}
293
+ type="file"
294
+ accept={UPLOAD_ACCEPT}
295
+ multiple
296
+ onChange={(e) => {
297
+ if (e.target.files) handleFiles(e.target.files)
298
+ e.target.value = ""
299
+ }}
300
+ className="hidden"
301
+ />
302
+ </div>
303
+
304
+ {media.length > 0 && (
305
+ <div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-3">
306
+ {media.map((m) => (
307
+ <div
308
+ key={m._localId}
309
+ className={cn(
310
+ "relative aspect-square rounded-[2px] overflow-hidden bg-muted",
311
+ m._failed && "ring-1 ring-destructive"
312
+ )}
313
+ >
314
+ {m.type === "image" ? (
315
+ <img
316
+ src={m.thumb || m.url}
317
+ alt=""
318
+ className="absolute inset-0 w-full h-full object-cover"
319
+ />
320
+ ) : (
321
+ <video
322
+ src={m.url}
323
+ className="absolute inset-0 w-full h-full object-cover bg-black"
324
+ />
325
+ )}
326
+ {m._uploading && (
327
+ <div className="absolute inset-0 flex items-center justify-center bg-background/60">
328
+ <Loader2 className="w-5 h-5 animate-spin text-foreground" />
329
+ </div>
330
+ )}
331
+ <button
332
+ type="button"
333
+ onClick={() => removeMedia(m._localId)}
334
+ aria-label={l.remove}
335
+ className="absolute top-1 right-1 w-6 h-6 rounded-full bg-background/80 hover:bg-background text-foreground flex items-center justify-center"
336
+ >
337
+ <X className="w-3.5 h-3.5" />
338
+ </button>
339
+ </div>
340
+ ))}
341
+ </div>
342
+ )}
343
+ </>
344
+ )
345
+ }