@osimart/create-storefront 0.0.0-stage → 1.0.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 (206) hide show
  1. package/README.md +80 -2
  2. package/THEME_DEV_API.md +80 -0
  3. package/app/app.vue +154 -0
  4. package/app/assets/css/osi-controls.css +824 -0
  5. package/app/components/SectionRenderer.vue +46 -0
  6. package/app/components/builder/BuilderOverlay.vue +582 -0
  7. package/app/components/checkout/ExtraFees.vue +329 -0
  8. package/app/components/checkout/PaymentHandoff.vue +388 -0
  9. package/app/components/common/Banner.vue +35 -0
  10. package/app/components/common/ConfirmDeleteModal.vue +117 -0
  11. package/app/components/common/DropdownSelect.vue +432 -0
  12. package/app/components/common/Loader.vue +32 -0
  13. package/app/components/common/OsiField.vue +218 -0
  14. package/app/components/common/PhoneNumInput.vue +434 -0
  15. package/app/composables/UseBodyScrollLock.js +122 -0
  16. package/app/composables/UseThemeGroup.js +575 -0
  17. package/app/composables/useAddToCart.js +87 -0
  18. package/app/composables/useAnalytics.js +15 -0
  19. package/app/composables/useAnnouncements.js +29 -0
  20. package/app/composables/useApiErrorToast.js +104 -0
  21. package/app/composables/useAuth.js +99 -0
  22. package/app/composables/useBanners.js +25 -0
  23. package/app/composables/useBuilderPreview.js +320 -0
  24. package/app/composables/useButtonStyle.js +107 -0
  25. package/app/composables/useCarousel.js +160 -0
  26. package/app/composables/useCart.js +109 -0
  27. package/app/composables/useCheckout.js +76 -0
  28. package/app/composables/useClassificationItems.js +269 -0
  29. package/app/composables/useClassificationPage.js +545 -0
  30. package/app/composables/useClassificationUrl.js +63 -0
  31. package/app/composables/useColorScheme.js +44 -0
  32. package/app/composables/useContactForm.js +53 -0
  33. package/app/composables/useContentTree.js +770 -0
  34. package/app/composables/useDragScroll.js +73 -0
  35. package/app/composables/useFooterSocials.js +107 -0
  36. package/app/composables/useInputStyle.js +45 -0
  37. package/app/composables/useLayoutShell.js +114 -0
  38. package/app/composables/useLazyVideo.js +36 -0
  39. package/app/composables/useMediaTypes.js +66 -0
  40. package/app/composables/useMediaUrl.js +71 -0
  41. package/app/composables/useMenu.js +100 -0
  42. package/app/composables/useNavStyle.js +219 -0
  43. package/app/composables/usePlaceholders.js +294 -0
  44. package/app/composables/usePolicies.js +26 -0
  45. package/app/composables/useProductCardMedia.js +135 -0
  46. package/app/composables/useProductCardStyle.js +132 -0
  47. package/app/composables/useProductDetail.js +982 -0
  48. package/app/composables/useProductGallery.js +166 -0
  49. package/app/composables/useProductItems.js +270 -0
  50. package/app/composables/useProductUrl.js +60 -0
  51. package/app/composables/useProductVariants.js +210 -0
  52. package/app/composables/useQuickShop.js +115 -0
  53. package/app/composables/useRelatedProducts.js +186 -0
  54. package/app/composables/useRenderContext.js +45 -0
  55. package/app/composables/useScopedCss.js +106 -0
  56. package/app/composables/useSearch.js +35 -0
  57. package/app/composables/useSectionData.js +462 -0
  58. package/app/composables/useSectionShell.js +113 -0
  59. package/app/composables/useSidebar.js +69 -0
  60. package/app/composables/useStoreData.js +57 -0
  61. package/app/composables/useStoreInit.js +52 -0
  62. package/app/composables/useStorefront.js +239 -0
  63. package/app/composables/useT.js +21 -0
  64. package/app/composables/useTextStyles.js +84 -0
  65. package/app/composables/useThemeControls.js +34 -0
  66. package/app/composables/useThemeSeo.js +91 -0
  67. package/app/composables/useThemeTokens.js +171 -0
  68. package/app/composables/useWishlist.js +66 -0
  69. package/app/logic/components/AnnouncementBar.js +118 -0
  70. package/app/logic/components/CardGrid.js +125 -0
  71. package/app/logic/components/CardList.js +98 -0
  72. package/app/logic/components/CartDrawer.js +273 -0
  73. package/app/logic/components/CartItem.js +31 -0
  74. package/app/logic/components/ClassificationBento.js +6 -0
  75. package/app/logic/components/ClassificationCard.js +6 -0
  76. package/app/logic/components/ClassificationCarousel.js +115 -0
  77. package/app/logic/components/ClassificationEditorial.js +41 -0
  78. package/app/logic/components/ClassificationFeatured.js +57 -0
  79. package/app/logic/components/ClassificationGrid.js +6 -0
  80. package/app/logic/components/ClassificationNav.js +61 -0
  81. package/app/logic/components/ClassificationPart.js +72 -0
  82. package/app/logic/components/FooterBlock.js +91 -0
  83. package/app/logic/components/FooterSocials.js +60 -0
  84. package/app/logic/components/HeaderDropdownList.js +29 -0
  85. package/app/logic/components/HeaderIcon.js +68 -0
  86. package/app/logic/components/HeaderLogo.js +39 -0
  87. package/app/logic/components/HeaderNav.js +268 -0
  88. package/app/logic/components/MobileMenu.js +280 -0
  89. package/app/logic/components/ProductBuyButtons.js +66 -0
  90. package/app/logic/components/ProductCard.js +160 -0
  91. package/app/logic/components/ProductFilters.js +37 -0
  92. package/app/logic/components/ProductGallery.js +19 -0
  93. package/app/logic/components/ProductInfoBlock.js +411 -0
  94. package/app/logic/components/ProductLightbox.js +12 -0
  95. package/app/logic/components/ProductQuantity.js +31 -0
  96. package/app/logic/components/ProductVariantPicker.js +125 -0
  97. package/app/logic/components/QuickBuy.js +194 -0
  98. package/app/logic/components/SearchOverlay.js +41 -0
  99. package/app/logic/components/SidebarMenuItem.js +39 -0
  100. package/app/logic/components/SiteFooter.js +476 -0
  101. package/app/logic/components/SiteHeader.js +266 -0
  102. package/app/logic/components/WhatsAppButton.js +75 -0
  103. package/app/logic/layouts/default.js +66 -0
  104. package/app/logic/sections/AnnouncementBarSection.js +6 -0
  105. package/app/logic/sections/BlockCarousel.js +103 -0
  106. package/app/logic/sections/ClassificationListSection.js +40 -0
  107. package/app/logic/sections/ContactFormSection.js +294 -0
  108. package/app/logic/sections/ContactInfoSection.js +419 -0
  109. package/app/logic/sections/ContentNode.js +189 -0
  110. package/app/logic/sections/CountdownSection.js +254 -0
  111. package/app/logic/sections/CustomHtmlSection.js +51 -0
  112. package/app/logic/sections/DividerSection.js +31 -0
  113. package/app/logic/sections/FaqSection.js +53 -0
  114. package/app/logic/sections/FeatureListSection.js +6 -0
  115. package/app/logic/sections/FeaturedProductSection.js +40 -0
  116. package/app/logic/sections/FooterSection.js +16 -0
  117. package/app/logic/sections/FooterUtilitiesSection.js +24 -0
  118. package/app/logic/sections/GallerySection.js +232 -0
  119. package/app/logic/sections/HeaderSection.js +24 -0
  120. package/app/logic/sections/HeroSection.js +24 -0
  121. package/app/logic/sections/LogoListSection.js +6 -0
  122. package/app/logic/sections/MainClassificationSection.js +473 -0
  123. package/app/logic/sections/MainProductSection.js +211 -0
  124. package/app/logic/sections/MainSearchSection.js +6 -0
  125. package/app/logic/sections/MapSection.js +92 -0
  126. package/app/logic/sections/MediaContentSection.js +38 -0
  127. package/app/logic/sections/MediaWithTextSection.js +238 -0
  128. package/app/logic/sections/NewsletterSection.js +126 -0
  129. package/app/logic/sections/ProductHighlightSection.js +201 -0
  130. package/app/logic/sections/ProductListSection.js +45 -0
  131. package/app/logic/sections/ProductRecommendationsSection.js +113 -0
  132. package/app/logic/sections/RichTextSection.js +6 -0
  133. package/app/logic/sections/ScrollingTextSection.js +69 -0
  134. package/app/logic/sections/SearchHeaderSection.js +22 -0
  135. package/app/logic/sections/SlideshowSection.js +118 -0
  136. package/app/logic/sections/SpacerSection.js +37 -0
  137. package/app/logic/sections/SplitShowcaseSection.js +6 -0
  138. package/app/logic/sections/TestimonialListSection.js +6 -0
  139. package/app/logic/sections/VideoSection.js +235 -0
  140. package/app/pages/[category]/[product].vue +196 -0
  141. package/app/pages/__builder-preview.vue +38 -0
  142. package/app/pages/categories/[slug].vue +208 -0
  143. package/app/pages/checkout.vue +1972 -0
  144. package/app/pages/forgot-password.vue +115 -0
  145. package/app/pages/index.vue +64 -0
  146. package/app/pages/login.vue +585 -0
  147. package/app/pages/order-detail.vue +442 -0
  148. package/app/pages/pages/[handle].vue +58 -0
  149. package/app/pages/privacy-policy.vue +46 -0
  150. package/app/pages/resetpassword.vue +138 -0
  151. package/app/pages/resetverificationcode.vue +163 -0
  152. package/app/pages/result.vue +203 -0
  153. package/app/pages/return-policy.vue +50 -0
  154. package/app/pages/search.vue +100 -0
  155. package/app/pages/shipping-policy.vue +46 -0
  156. package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
  157. package/app/pages/user-profile/components/Whishlist.vue +137 -0
  158. package/app/pages/user-profile/components/left-bar.vue +233 -0
  159. package/app/pages/user-profile/components/orders.vue +162 -0
  160. package/app/pages/user-profile/components/profile-details.vue +566 -0
  161. package/app/pages/user-profile/components/settings.vue +194 -0
  162. package/app/pages/user-profile/index.vue +252 -0
  163. package/app/pages/verificationcode.vue +188 -0
  164. package/app/plugins/analytics.client.js +99 -0
  165. package/app/plugins/geo.ts +24 -0
  166. package/app/plugins/reveal.client.js +53 -0
  167. package/app/plugins/toast.js +10 -0
  168. package/app/plugins/vue-query.ts +5 -0
  169. package/app/services/ApiServices.js +74 -0
  170. package/app/services/AuthBus.js +162 -0
  171. package/app/services/Authentication/AuthenticationService.js +133 -0
  172. package/app/services/Authentication/TokenService.js +62 -0
  173. package/app/services/CartServices.js +91 -0
  174. package/app/services/CartToken.js +26 -0
  175. package/app/services/CategoryServices.js +54 -0
  176. package/app/services/CheckoutServices.js +113 -0
  177. package/app/services/HomeServices.js +133 -0
  178. package/app/services/NewsletterServices.js +29 -0
  179. package/app/services/PaymentServices.js +71 -0
  180. package/app/services/PoliciesServices.js +16 -0
  181. package/app/services/ProductServices.js +79 -0
  182. package/app/services/SearchServices.js +18 -0
  183. package/app/services/UserServices.js +105 -0
  184. package/app/services/UtilsServices.js +46 -0
  185. package/app/services/WishlistServices.js +51 -0
  186. package/app/services/storefront.service.js +57 -0
  187. package/app/stores/userStore.js +13 -0
  188. package/app/theme/categoryTree.js +68 -0
  189. package/app/theme/controls.js +88 -0
  190. package/app/theme/registry.js +56 -0
  191. package/app/utils/analytics.js +864 -0
  192. package/app/utils/device.js +50 -0
  193. package/app/utils/eventBus.js +24 -0
  194. package/app/utils/icon-presets.js +24 -0
  195. package/app/utils/productOffers.js +118 -0
  196. package/app/utils/requestCache.js +36 -0
  197. package/app/utils/scrollToError.js +59 -0
  198. package/app/utils/toPlainError.js +30 -0
  199. package/bin/osimart-theme.mjs +259 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +43 -3
  203. package/public/css/input.css +29 -0
  204. package/public/css/output.css +2716 -0
  205. package/public/favicon-32x32.png +0 -0
  206. package/public/robots.txt +2 -0
@@ -0,0 +1,434 @@
1
+ <template>
2
+ <!-- `theme` variant: two .osi-input controls joined into one box, styled
3
+ entirely by the theme's Inputs group (assets/css/osi-controls.css).
4
+ Used inside <OsiField> on login, register, checkout and the account
5
+ pages. The other variants below are unchanged. -->
6
+ <div
7
+ v-if="effectiveVariant === 'theme'"
8
+ class="osi-phone"
9
+ :class="{ 'is-open': isFocused }"
10
+ >
11
+ <DropdownSelect
12
+ v-model="selectedCode"
13
+ :options="countryOptions"
14
+ :searchable="true"
15
+ :local-search="true"
16
+ smallText
17
+ @search="onSearch"
18
+ @update:modelValue="onCountryChange"
19
+ @open="isFocused = true"
20
+ @close="isFocused = false"
21
+ :fullWidth="false"
22
+ class="osi-phone-dropdown"
23
+ >
24
+ <template #trigger="{ toggle }">
25
+ <button
26
+ type="button"
27
+ class="osi-phone-code"
28
+ :class="[inputClass, { 'is-invalid': !!error }]"
29
+ :aria-label="`Country code ${selectedCountry?.dial || ''}`"
30
+ :disabled="disabled"
31
+ @click="toggle"
32
+ >
33
+ <img
34
+ :key="selectedCountry?.code"
35
+ :src="getFlagUrl(selectedCountry?.code)"
36
+ :alt="selectedCountry?.name"
37
+ />
38
+ <span v-if="!ispopup">{{ selectedCountry?.dial }}</span>
39
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
40
+ <path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
41
+ </svg>
42
+ </button>
43
+ </template>
44
+ </DropdownSelect>
45
+
46
+ <input
47
+ :id="inputId || undefined"
48
+ v-model="phoneNumber"
49
+ type="tel"
50
+ inputmode="numeric"
51
+ autocomplete="tel-national"
52
+ class="osi-phone-number"
53
+ :class="inputClass"
54
+ :placeholder="selectedCountry?.format"
55
+ :disabled="disabled"
56
+ :aria-invalid="!!error"
57
+ @input="onPhoneInput"
58
+ @focus="isFocused = true"
59
+ @blur="isFocused = false"
60
+ />
61
+ </div>
62
+
63
+ <div
64
+ v-else
65
+ class="w-full"
66
+ :class="[
67
+ effectiveVariant === 'underline' ? 'font-thoren relative' : 'font-poppins'
68
+ ]"
69
+ >
70
+ <label
71
+ v-if="effectiveVariant === 'underline'"
72
+ class="underline-label"
73
+ >{{ label }}</label>
74
+
75
+ <div
76
+ class="flex flex-row items-stretch w-full transition-all duration-300"
77
+ :class="[
78
+ effectiveVariant === 'register' ? 'rounded-none bg-transparent' : '',
79
+ effectiveVariant === 'register' && isFocused ? 'border-black' : '',
80
+ effectiveVariant === 'underline' ? 'border-b pt-[3px] border-black min-h-[45.98px]' : '',
81
+ effectiveVariant === 'bare' ? 'bg-transparent' : '',
82
+ ]"
83
+ >
84
+ <div
85
+ class="flex flex-col"
86
+ :class="effectiveVariant === 'bare' ? 'border-r border-gray-200 pr-3 justify-center py-2' : 'contents'"
87
+ >
88
+ <label
89
+ v-if="effectiveVariant === 'bare'"
90
+ class="block text-[11px] text-gray-400"
91
+ >{{ label }}</label>
92
+
93
+ <DropdownSelect
94
+ v-model="selectedCode"
95
+ :options="countryOptions"
96
+ :searchable="true"
97
+ :local-search="true"
98
+ smallText
99
+ @search="onSearch"
100
+ @update:modelValue="onCountryChange"
101
+ @open="isFocused = true"
102
+ @close="isFocused = false"
103
+ :fullWidth="false"
104
+ class="shrink min-w-[90px]"
105
+ :class="ispopup ? 'lg:min-w-[70px]':'lg:min-w-[140px]'"
106
+ >
107
+ <template #trigger="{ toggle }">
108
+ <button
109
+ type="button"
110
+ @click="toggle"
111
+ class="dropdown-trigger-btn rounded-l-none w-full text-left text-gray-900 focus:outline-none flex items-center justify-between transition-all duration-300"
112
+ :class="[
113
+ effectiveVariant === 'register'
114
+ ? 'bg-transparent rounded-none pl-3 pr-2 border-y border-l border-r py-[9px] text-[1rem]'
115
+ : effectiveVariant === 'underline'
116
+ ? 'bg-transparent pl-0 pr-2 py-2'
117
+ : effectiveVariant === 'bare'
118
+ ? 'bg-transparent rounded-none border-0 p-0 text-sm'
119
+ : 'bg-white min-h-[34px] rounded-l-md px-2 border py-1',
120
+ effectiveVariant === 'default'
121
+ ? (error ? 'border-red-500' : 'border-gray-300')
122
+ : '',
123
+ effectiveVariant === 'register'
124
+ ? (error ? 'border-red-500' : 'border-gray-300')
125
+ : '',
126
+ ]"
127
+ >
128
+ <div class="flex items-center gap-1.5">
129
+ <span class="text-base pointer-events-none select-none">
130
+ <img
131
+ :key="selectedCountry?.code"
132
+ :src="getFlagUrl(selectedCountry?.code)"
133
+ :alt="selectedCountry?.name"
134
+ class="w-5 h-5 object-contain"
135
+ />
136
+ </span>
137
+ <span
138
+ class="font-medium pointer-events-none whitespace-nowrap"
139
+ :class="[
140
+ ispopup ? 'hidden':'block',
141
+ effectiveVariant === 'bare' ? 'text-sm' : 'text-xs',
142
+ ]"
143
+ >{{ selectedCountry?.dial }}</span>
144
+ </div>
145
+ <svg class="w-3.5 h-3.5 text-gray-900 shrink-0 ml-0.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
146
+ <path d="M5.70711 16.1359C5.31659 16.5264 5.31659 17.1596 5.70711 17.5501L10.5993 22.4375C11.3805 23.2179 12.6463 23.2176 13.4271 22.4369L18.3174 17.5465C18.708 17.156 18.708 16.5228 18.3174 16.1323C17.9269 15.7418 17.2937 15.7418 16.9032 16.1323L12.7176 20.3179C12.3271 20.7085 11.6939 20.7085 11.3034 20.3179L7.12132 16.1359C6.7308 15.7454 6.09763 15.7454 5.70711 16.1359Z" fill="currentColor"></path>
147
+ <path d="M18.3174 7.88675C18.708 7.49623 18.708 6.86307 18.3174 6.47254L13.4252 1.58509C12.644 0.804698 11.3783 0.805008 10.5975 1.58579L5.70711 6.47615C5.31658 6.86667 5.31658 7.49984 5.70711 7.89036C6.09763 8.28089 6.7308 8.28089 7.12132 7.89036L11.307 3.70472C11.6975 3.31419 12.3307 3.31419 12.7212 3.70472L16.9032 7.88675C17.2937 8.27728 17.9269 8.27728 18.3174 7.88675Z" fill="currentColor"></path>
148
+ </svg>
149
+ </button>
150
+ </template>
151
+ </DropdownSelect>
152
+ </div>
153
+
154
+ <div class="flex flex-col min-w-0 w-full flex-2"
155
+ :class="effectiveVariant === 'bare' ? 'items-center justify-center':''">
156
+ <input
157
+ v-model="phoneNumber"
158
+ type="tel"
159
+ :placeholder="selectedCountry?.format"
160
+ class="w-full rounded-r-none text-input outline-none transition-all duration-300"
161
+ :class="[
162
+ effectiveVariant === 'register'
163
+ ? 'bg-transparent border-y border-r border-l-0 rounded-none text-[1rem] text-gray-900 pr-3 py-[9px] px-2'
164
+ : effectiveVariant === 'underline'
165
+ ? 'bg-transparent border-none py-2 px-0 text-sm text-black'
166
+ : effectiveVariant === 'bare'
167
+ ? 'bg-transparent border-0 rounded-none p-0 pl-3 text-[16px] text-primaryColor placeholder:text-[#6b7280]'
168
+ : 'bg-input min-h-[34px] border rounded-r-md rounded-l-none py-1 px-2',
169
+ effectiveVariant === 'default'
170
+ ? (error ? 'border-red-500' : 'border-gray-300 focus:border-gray-400')
171
+ : '',
172
+ effectiveVariant === 'register'
173
+ ? (error ? 'border-red-500' : 'border-gray-300')
174
+ : '',
175
+ ]"
176
+ @input="onPhoneInput"
177
+ @focus="isFocused = true"
178
+ @blur="isFocused = false"
179
+ />
180
+ </div>
181
+ </div>
182
+ </div>
183
+ </template>
184
+ <script setup>
185
+ import { ref, computed, watch, onMounted } from 'vue'
186
+ import DropdownSelect from './DropdownSelect.vue'
187
+ import { useUserStore } from '../../stores/userStore'
188
+ const props = defineProps({
189
+ modelValue: { type: String, default: '' },
190
+ error: { type: [Boolean, String], default: false },
191
+ ispopup:{type:Boolean,default:false},
192
+ isRegister:{type:Boolean,default:false},
193
+ variant: { type: String, default: '' }, // '', 'register', 'underline', 'bare', 'theme'
194
+ label: { type: String, default: 'Phone number' },
195
+ // theme variant only
196
+ inputClass: { type: [String, Array, Object], default: 'osi-input' },
197
+ inputId: { type: String, default: '' },
198
+ disabled: { type: Boolean, default: false },
199
+ })
200
+
201
+ const emit = defineEmits(['update:modelValue', 'update', 'clear-error'])
202
+
203
+ const userStore = useUserStore()
204
+
205
+ const effectiveVariant = computed(() => {
206
+ if (props.variant) return props.variant
207
+ return props.isRegister ? 'register' : 'default'
208
+ })
209
+
210
+ const countries = [
211
+ { name: "Afghanistan", dial: "+93", flag: "🇦🇫", code: "AF", format: "XX XXX XXXX", len: 9 },
212
+ { name: "Albania", dial: "+355", flag: "🇦🇱", code: "AL", format: "XX XXX XXXX", len: 9 },
213
+ { name: "Algeria", dial: "+213", flag: "🇩🇿", code: "DZ", format: "XXX XXX XXXX", len: 10 },
214
+ { name: "Argentina", dial: "+54", flag: "🇦🇷", code: "AR", format: "XXX XXX XXXX", len: 10 },
215
+ { name: "Armenia", dial: "+374", flag: "🇦🇲", code: "AM", format: "XX XXX XXX", len: 8 },
216
+ { name: "Australia", dial: "+61", flag: "🇦🇺", code: "AU", format: "XXX XXX XXX", len: 9 },
217
+ { name: "Austria", dial: "+43", flag: "🇦🇹", code: "AT", format: "XXX XXXXXXX", len: 10 },
218
+ { name: "Azerbaijan", dial: "+994", flag: "🇦🇿", code: "AZ", format: "XX XXX XXXX", len: 9 },
219
+ { name: "Bahrain", dial: "+973", flag: "🇧🇭", code: "BH", format: "XXXX XXXX", len: 8 },
220
+ { name: "Bangladesh", dial: "+880", flag: "🇧🇩", code: "BD", format: "XXXX XXXXXX", len: 10 },
221
+ { name: "Belgium", dial: "+32", flag: "🇧🇪", code: "BE", format: "XXX XX XX XX", len: 9 },
222
+ { name: "Brazil", dial: "+55", flag: "🇧🇷", code: "BR", format: "XX XXXXX XXXX", len: 11 },
223
+ { name: "Canada", dial: "+1", flag: "🇨🇦", code: "CA", format: "XXX XXX XXXX", len: 10 },
224
+ { name: "Chile", dial: "+56", flag: "🇨🇱", code: "CL", format: "X XXXX XXXX", len: 9 },
225
+ { name: "China", dial: "+86", flag: "🇨🇳", code: "CN", format: "XXX XXXX XXXX", len: 11 },
226
+ { name: "Colombia", dial: "+57", flag: "🇨🇴", code: "CO", format: "XXX XXX XXXX", len: 10 },
227
+ { name: "Croatia", dial: "+385", flag: "🇭🇷", code: "HR", format: "XX XXX XXXX", len: 9 },
228
+ { name: "Cyprus", dial: "+357", flag: "🇨🇾", code: "CY", format: "XXXX XXXX", len: 8 },
229
+ { name: "Czech Republic", dial: "+420", flag: "🇨🇿", code: "CZ", format: "XXX XXX XXX", len: 9 },
230
+ { name: "Denmark", dial: "+45", flag: "🇩🇰", code: "DK", format: "XXXX XXXX", len: 8 },
231
+ { name: "Egypt", dial: "+20", flag: "🇪🇬", code: "EG", format: "XXX XXX XXXX", len: 10 },
232
+ { name: "Ethiopia", dial: "+251", flag: "🇪🇹", code: "ET", format: "XX XXX XXXX", len: 9 },
233
+ { name: "Finland", dial: "+358", flag: "🇫🇮", code: "FI", format: "XXX XXXXXXX", len: 10 },
234
+ { name: "France", dial: "+33", flag: "🇫🇷", code: "FR", format: "X XX XX XX XX", len: 9 },
235
+ { name: "Georgia", dial: "+995", flag: "🇬🇪", code: "GE", format: "XXX XXX XXX", len: 9 },
236
+ { name: "Germany", dial: "+49", flag: "🇩🇪", code: "DE", format: "XXX XXXXXXXX", len: 11 },
237
+ { name: "Ghana", dial: "+233", flag: "🇬🇭", code: "GH", format: "XXX XXX XXXX", len: 10 },
238
+ { name: "Greece", dial: "+30", flag: "🇬🇷", code: "GR", format: "XXX XXX XXXX", len: 10 },
239
+ { name: "Hungary", dial: "+36", flag: "🇭🇺", code: "HU", format: "XX XXX XXXX", len: 9 },
240
+ { name: "India", dial: "+91", flag: "🇮🇳", code: "IN", format: "XXXXX XXXXX", len: 10 },
241
+ { name: "Indonesia", dial: "+62", flag: "🇮🇩", code: "ID", format: "XXX XXXX XXXX", len: 11 },
242
+ { name: "Iran", dial: "+98", flag: "🇮🇷", code: "IR", format: "XXX XXX XXXX", len: 10 },
243
+ { name: "Iraq", dial: "+964", flag: "🇮🇶", code: "IQ", format: "XXX XXX XXXX", len: 10 },
244
+ { name: "Ireland", dial: "+353", flag: "🇮🇪", code: "IE", format: "XX XXX XXXX", len: 9 },
245
+ { name: "Israel", dial: "+972", flag: "🇮🇱", code: "IL", format: "XX XXX XXXX", len: 9 },
246
+ { name: "Italy", dial: "+39", flag: "🇮🇹", code: "IT", format: "XXX XXX XXXX", len: 10 },
247
+ { name: "Japan", dial: "+81", flag: "🇯🇵", code: "JP", format: "XXX XXXX XXXX", len: 11 },
248
+ { name: "Jordan", dial: "+962", flag: "🇯🇴", code: "JO", format: "X XXXX XXXX", len: 9 },
249
+ { name: "Kazakhstan", dial: "+7", flag: "🇰🇿", code: "KZ", format: "XXX XXX XXXX", len: 10 },
250
+ { name: "Kenya", dial: "+254", flag: "🇰🇪", code: "KE", format: "XXX XXX XXX", len: 9 },
251
+ { name: "Kuwait", dial: "+965", flag: "🇰🇼", code: "KW", format: "XXXX XXXX", len: 8 },
252
+ { name: "Lebanon", dial: "+961", flag: "🇱🇧", code: "LB", format: "XX XXX XXX", len: 8 },
253
+ { name: "Libya", dial: "+218", flag: "🇱🇾", code: "LY", format: "XX XXX XXXX", len: 9 },
254
+ { name: "Malaysia", dial: "+60", flag: "🇲🇾", code: "MY", format: "XX XXXX XXXX", len: 10 },
255
+ { name: "Mexico", dial: "+52", flag: "🇲🇽", code: "MX", format: "XXX XXX XXXX", len: 10 },
256
+ { name: "Morocco", dial: "+212", flag: "🇲🇦", code: "MA", format: "XXX XXX XXX", len: 9 },
257
+ { name: "Netherlands", dial: "+31", flag: "🇳🇱", code: "NL", format: "X XX XX XX XX", len: 9 },
258
+ { name: "New Zealand", dial: "+64", flag: "🇳🇿", code: "NZ", format: "XX XXX XXXX", len: 9 },
259
+ { name: "Nigeria", dial: "+234", flag: "🇳🇬", code: "NG", format: "XXX XXX XXXX", len: 10 },
260
+ { name: "Norway", dial: "+47", flag: "🇳🇴", code: "NO", format: "XXX XX XXX", len: 8 },
261
+ { name: "Oman", dial: "+968", flag: "🇴🇲", code: "OM", format: "XXXX XXXX", len: 8 },
262
+ { name: "Pakistan", dial: "+92", flag: "🇵🇰", code: "PK", format: "XXX XXX XXXX", len: 10 },
263
+ { name: "Palestine", dial: "+970", flag: "🇵🇸", code: "PS", format: "XXX XXX XXXX", len: 10 },
264
+ { name: "Philippines", dial: "+63", flag: "🇵🇭", code: "PH", format: "XXX XXX XXXX", len: 10 },
265
+ { name: "Poland", dial: "+48", flag: "🇵🇱", code: "PL", format: "XXX XXX XXX", len: 9 },
266
+ { name: "Portugal", dial: "+351", flag: "🇵🇹", code: "PT", format: "XXX XXX XXX", len: 9 },
267
+ { name: "Qatar", dial: "+974", flag: "🇶🇦", code: "QA", format: "XXXX XXXX", len: 8 },
268
+ { name: "Romania", dial: "+40", flag: "🇷🇴", code: "RO", format: "XXX XXX XXX", len: 9 },
269
+ { name: "Russia", dial: "+7", flag: "🇷🇺", code: "RU", format: "XXX XXX XXXX", len: 10 },
270
+ { name: "Saudi Arabia", dial: "+966", flag: "🇸🇦", code: "SA", format: "XX XXX XXXX", len: 9 },
271
+ { name: "Senegal", dial: "+221", flag: "🇸🇳", code: "SN", format: "XX XXX XXXX", len: 9 },
272
+ { name: "Serbia", dial: "+381", flag: "🇷🇸", code: "RS", format: "XX XXX XXXX", len: 9 },
273
+ { name: "Singapore", dial: "+65", flag: "🇸🇬", code: "SG", format: "XXXX XXXX", len: 8 },
274
+ { name: "South Africa", dial: "+27", flag: "🇿🇦", code: "ZA", format: "XX XXX XXXX", len: 9 },
275
+ { name: "South Korea", dial: "+82", flag: "🇰🇷", code: "KR", format: "XX XXXX XXXX", len: 10 },
276
+ { name: "Spain", dial: "+34", flag: "🇪🇸", code: "ES", format: "XXX XXX XXX", len: 9 },
277
+ { name: "Sudan", dial: "+249", flag: "🇸🇩", code: "SD", format: "XX XXX XXXX", len: 9 },
278
+ { name: "Sweden", dial: "+46", flag: "🇸🇪", code: "SE", format: "XXX XXX XXXX", len: 10 },
279
+ { name: "Switzerland", dial: "+41", flag: "🇨🇭", code: "CH", format: "XX XXX XXXX", len: 9 },
280
+ { name: "Syria", dial: "+963", flag: "🇸🇾", code: "SY", format: "XXX XXX XXX", len: 9 },
281
+ { name: "Taiwan", dial: "+886", flag: "🇹🇼", code: "TW", format: "XXX XXX XXXX", len: 10 },
282
+ { name: "Thailand", dial: "+66", flag: "🇹🇭", code: "TH", format: "XX XXX XXXX", len: 9 },
283
+ { name: "Tunisia", dial: "+216", flag: "🇹🇳", code: "TN", format: "XX XXX XXX", len: 8 },
284
+ { name: "Turkey", dial: "+90", flag: "🇹🇷", code: "TR", format: "XXX XXX XXXX", len: 10 },
285
+ { name: "UAE", dial: "+971", flag: "🇦🇪", code: "AE", format: "XX XXX XXXX", len: 9 },
286
+ { name: "Uganda", dial: "+256", flag: "🇺🇬", code: "UG", format: "XXX XXX XXX", len: 9 },
287
+ { name: "Ukraine", dial: "+380", flag: "🇺🇦", code: "UA", format: "XX XXX XXXX", len: 9 },
288
+ { name: "United Kingdom", dial: "+44", flag: "🇬🇧", code: "GB", format: "XXXX XXXXXX", len: 10 },
289
+ { name: "United States", dial: "+1", flag: "🇺🇸", code: "US", format: "XXX XXX XXXX", len: 10 },
290
+ { name: "Venezuela", dial: "+58", flag: "🇻🇪", code: "VE", format: "XXX XXX XXXX", len: 10 },
291
+ { name: "Vietnam", dial: "+84", flag: "🇻🇳", code: "VN", format: "XXX XXX XXXX", len: 10 },
292
+ { name: "Yemen", dial: "+967", flag: "🇾🇪", code: "YE", format: "XXX XXX XXX", len: 9 },
293
+ ]
294
+
295
+ const selectedCode = ref('US')
296
+ const phoneNumber = ref('')
297
+ const isFocused = ref(false)
298
+ const searchQuery = ref('')
299
+
300
+ let countryAutoSet = false
301
+
302
+ const syncCountry = () => {
303
+ const code = userStore.clientIpInfo?.country
304
+ if (!code) return
305
+ const upper = code.toUpperCase()
306
+ if (!countryAutoSet && selectedCode.value === 'US') {
307
+ const exists = countries.some(c => c.code === upper)
308
+ if (exists) {
309
+ selectedCode.value = upper
310
+ countryAutoSet = true
311
+ }
312
+ }
313
+ }
314
+
315
+ // Safely apply the country after the component finishes hydrating
316
+ onMounted(() => {
317
+ syncCountry()
318
+ })
319
+
320
+ watch(() => userStore.clientIpInfo?.country, syncCountry)
321
+
322
+ function onSearch(query) {
323
+ searchQuery.value = query
324
+ }
325
+
326
+ const countryOptions = computed(() => {
327
+ const q = searchQuery.value.toLowerCase()
328
+
329
+ return countries
330
+ .filter(c => c.name.toLowerCase().includes(q) || c.dial.includes(q))
331
+ .map(c => ({
332
+ value: c.code,
333
+ label: `${c.name} (${c.dial})`,
334
+ image: getFlagUrl(c.code),
335
+ flag: getFlagUrl(c.code),
336
+ dial: c.dial,
337
+ name: c.name,
338
+ }))
339
+ })
340
+
341
+ const selectedCountry = computed(() =>
342
+ countries.find(c => c.code === selectedCode.value)
343
+ )
344
+
345
+ const status = computed(() => {
346
+ if (!phoneNumber.value) return 'idle'
347
+ return phoneNumber.value.length === selectedCountry.value.len ? 'success' : 'error'
348
+ })
349
+
350
+ const hintText = computed(() => {
351
+ if (!phoneNumber.value) return ''
352
+ if (status.value === 'success') return 'Valid number'
353
+ const diff = selectedCountry.value.len - phoneNumber.value.length
354
+ return diff > 0
355
+ ? `${diff} digit${diff > 1 ? 's' : ''} short`
356
+ : `${-diff} digit${-diff > 1 ? 's' : ''} too many`
357
+ })
358
+
359
+ function onCountryChange() {
360
+ countryAutoSet = true
361
+ phoneNumber.value = ''
362
+ }
363
+
364
+ function onPhoneInput() {
365
+ phoneNumber.value = phoneNumber.value.replace(/\D/g, '')
366
+ emit('clear-error')
367
+ }
368
+
369
+ function emitUpdate() {
370
+ const e164 = phoneNumber.value ? `${selectedCountry.value.dial}${phoneNumber.value}` : ''
371
+ const isValid = status.value === 'success'
372
+ emit('update:modelValue', e164)
373
+ emit('update', { isValid, e164 })
374
+ }
375
+
376
+ watch([phoneNumber, selectedCode], emitUpdate)
377
+
378
+ watch(() => props.modelValue, (newVal) => {
379
+ if (!newVal) {
380
+ phoneNumber.value = ''
381
+ return
382
+ }
383
+ const currentE164 = `${selectedCountry.value?.dial || ''}${phoneNumber.value}`
384
+ if (currentE164 === newVal) return
385
+
386
+ // Prefer keeping the currently selected country if it still fits the new value
387
+ if (selectedCountry.value && newVal.startsWith(selectedCountry.value.dial)) {
388
+ phoneNumber.value = newVal.slice(selectedCountry.value.dial.length)
389
+ return
390
+ }
391
+
392
+ const matchedCountry = countries.slice().sort((a,b) => b.dial.length - a.dial.length).find(c => newVal.startsWith(c.dial))
393
+ if (matchedCountry) {
394
+ selectedCode.value = matchedCountry.code
395
+ phoneNumber.value = newVal.slice(matchedCountry.dial.length)
396
+ } else {
397
+ phoneNumber.value = newVal.replace(/\D/g, '')
398
+ }
399
+ }, { immediate: true })
400
+
401
+ const getFlagUrl = (code) => {
402
+ if (!code) return '';
403
+ return `https://flagcdn.com/w40/${code.toLowerCase()}.png`;
404
+ };
405
+ </script>
406
+
407
+ <style scoped>
408
+ .osi-phone-dropdown {
409
+ display: flex;
410
+ flex: none;
411
+ }
412
+ .underline-label {
413
+ position: absolute;
414
+ top: -22px;
415
+ left: 0;
416
+ font-size: 0.85rem;
417
+ color: #000;
418
+ pointer-events: none;
419
+ }
420
+ .dropdown-trigger-btn {
421
+ -webkit-appearance: none;
422
+ appearance: none;
423
+ outline: none !important;
424
+ box-shadow: none !important;
425
+ -webkit-tap-highlight-color: transparent;
426
+ }
427
+ .dropdown-trigger-btn:focus,
428
+ .dropdown-trigger-btn:focus-visible,
429
+ .dropdown-trigger-btn:active,
430
+ .dropdown-trigger-btn:hover {
431
+ outline: none !important;
432
+ box-shadow: none !important;
433
+ }
434
+ </style>
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Ref-counted page scroll lock.
3
+ *
4
+ * `document.body.style.overflow` was being set directly in 27 places across 8
5
+ * files — some also setting documentElement, some not, and all of them writing
6
+ * "auto" on release rather than restoring the original value. Two overlapping
7
+ * owners (cart drawer open over a product lightbox) meant whichever closed
8
+ * first unlocked the page underneath the other.
9
+ *
10
+ * A count fixes that: the page unlocks when the last owner releases, not the
11
+ * first. The original inline values are captured on the 0 -> 1 transition and
12
+ * restored on 1 -> 0, so a theme that sets its own overflow keeps it.
13
+ *
14
+ * Module-level state is correct here even though this app is multi-tenant: it
15
+ * is browser DOM state, and every function no-ops on the server.
16
+ */
17
+ let lockCount = 0;
18
+ let previous = null;
19
+
20
+ /**
21
+ * True while the page is locked (and for a frame after, while its scroll
22
+ * position is put back). useLayoutShell ignores scroll events meanwhile: the
23
+ * lock and the restore are not the visitor scrolling, and reading them used
24
+ * to hide the header (a jump counted as "scrolling down") or reset its state.
25
+ * Client-only in practice — never changes during SSR.
26
+ */
27
+ export const scrollLocked = ref(false);
28
+
29
+ function applyLock() {
30
+ if (!import.meta.client) return;
31
+
32
+ const body = document.body;
33
+ const root = document.documentElement;
34
+
35
+ previous = {
36
+ bodyOverflow: body.style.overflow,
37
+ rootOverflow: root.style.overflow,
38
+ bodyPaddingRight: body.style.paddingRight,
39
+ y: window.scrollY || root.scrollTop || 0,
40
+ };
41
+ scrollLocked.value = true;
42
+
43
+ // The scrollbar disappears with overflow: hidden — pad its width back so
44
+ // the page (and a sticky header) doesn't shift sideways.
45
+ const gutter = window.innerWidth - root.clientWidth;
46
+ if (gutter > 0) body.style.paddingRight = `${gutter}px`;
47
+
48
+ body.style.overflow = "hidden";
49
+ root.style.overflow = "hidden";
50
+ }
51
+
52
+ function releaseLock() {
53
+ if (!import.meta.client) return;
54
+
55
+ const y = previous?.y ?? null;
56
+ document.body.style.overflow = previous?.bodyOverflow ?? "";
57
+ document.documentElement.style.overflow = previous?.rootOverflow ?? "";
58
+ document.body.style.paddingRight = previous?.bodyPaddingRight ?? "";
59
+ previous = null;
60
+
61
+ // Some browsers (iOS Safari, and any page with scroll-behavior: smooth)
62
+ // move the page when the lock comes off — closing a drawer used to scroll
63
+ // to the top. Put it back where it was, instantly.
64
+ if (y !== null && Math.abs((window.scrollY || 0) - y) > 1) {
65
+ window.scrollTo({ top: y, left: 0, behavior: "instant" });
66
+ }
67
+ requestAnimationFrame(() => {
68
+ scrollLocked.value = false;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * @param {Ref<boolean>|(() => boolean)} [source]
74
+ * Optional. When given, the lock follows it automatically — this covers most
75
+ * call sites (`watch(showFilters)`, `watch(lightboxOpen)`, `watch(isSheetOpen)`)
76
+ * and removes the release-on-unmount bug those watchers all had.
77
+ */
78
+ export function useBodyScrollLock(source) {
79
+ // Per-caller, so one component unmounting can only release its own locks.
80
+ let held = 0;
81
+
82
+ function lock() {
83
+ if (!import.meta.client) return;
84
+ held += 1;
85
+ lockCount += 1;
86
+ if (lockCount === 1) applyLock();
87
+ }
88
+
89
+ function unlock() {
90
+ if (!import.meta.client || held === 0) return;
91
+ held -= 1;
92
+ lockCount = Math.max(0, lockCount - 1);
93
+ if (lockCount === 0) releaseLock();
94
+ }
95
+
96
+ function set(locked) {
97
+ if (locked) {
98
+ if (held === 0) lock();
99
+ } else {
100
+ while (held > 0) unlock();
101
+ }
102
+ }
103
+
104
+ /** Release everything this caller holds, however many times it locked. */
105
+ function releaseAll() {
106
+ while (held > 0) unlock();
107
+ }
108
+
109
+ if (source) {
110
+ watch(
111
+ typeof source === "function" ? source : () => unref(source),
112
+ (locked) => set(!!locked),
113
+ { immediate: true }
114
+ );
115
+ }
116
+
117
+ // A drawer closed by navigation never runs its own close handler, which is
118
+ // how the old code left the page permanently unscrollable.
119
+ if (getCurrentInstance()) onScopeDispose(releaseAll);
120
+
121
+ return { lock, unlock, set, releaseAll };
122
+ }