@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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
|
+
}
|