@shopware/cms-base-layer 0.0.0-canary-20250116171244

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/components/SwCategoryNavigation.vue +44 -0
  4. package/components/SwCategoryNavigationLink.vue +57 -0
  5. package/components/SwContactForm.vue +392 -0
  6. package/components/SwListingProductPrice.vue +88 -0
  7. package/components/SwMedia3D.vue +34 -0
  8. package/components/SwNewsletterForm.vue +347 -0
  9. package/components/SwPagination.vue +106 -0
  10. package/components/SwProductAddToCart.vue +93 -0
  11. package/components/SwProductCard.vue +285 -0
  12. package/components/SwProductGallery.vue +39 -0
  13. package/components/SwProductListingFilter.vue +42 -0
  14. package/components/SwProductListingFilters.vue +292 -0
  15. package/components/SwProductPrice.vue +99 -0
  16. package/components/SwProductReviews.vue +99 -0
  17. package/components/SwProductUnits.vue +54 -0
  18. package/components/SwSharedPrice.vue +19 -0
  19. package/components/SwSlider.vue +328 -0
  20. package/components/SwVariantConfigurator.vue +116 -0
  21. package/components/listing-filters/SwFilterPrice.vue +160 -0
  22. package/components/listing-filters/SwFilterProperties.vue +123 -0
  23. package/components/listing-filters/SwFilterRating.vue +101 -0
  24. package/components/listing-filters/SwFilterShippingFree.vue +104 -0
  25. package/components/public/cms/CmsGenericBlock.md +27 -0
  26. package/components/public/cms/CmsGenericBlock.vue +63 -0
  27. package/components/public/cms/CmsGenericElement.md +31 -0
  28. package/components/public/cms/CmsGenericElement.vue +38 -0
  29. package/components/public/cms/CmsNoComponent.vue +27 -0
  30. package/components/public/cms/CmsPage.md +36 -0
  31. package/components/public/cms/CmsPage.vue +65 -0
  32. package/components/public/cms/block/CmsBlockCategoryNavigation.vue +16 -0
  33. package/components/public/cms/block/CmsBlockCenterText.vue +26 -0
  34. package/components/public/cms/block/CmsBlockCrossSelling.vue +15 -0
  35. package/components/public/cms/block/CmsBlockCustomForm.vue +17 -0
  36. package/components/public/cms/block/CmsBlockDefault.vue +14 -0
  37. package/components/public/cms/block/CmsBlockForm.vue +17 -0
  38. package/components/public/cms/block/CmsBlockGalleryBuybox.vue +25 -0
  39. package/components/public/cms/block/CmsBlockImage.vue +16 -0
  40. package/components/public/cms/block/CmsBlockImageBubbleRow.vue +32 -0
  41. package/components/public/cms/block/CmsBlockImageCover.vue +17 -0
  42. package/components/public/cms/block/CmsBlockImageFourColumn.vue +29 -0
  43. package/components/public/cms/block/CmsBlockImageGallery.vue +18 -0
  44. package/components/public/cms/block/CmsBlockImageHighlightRow.vue +27 -0
  45. package/components/public/cms/block/CmsBlockImageSimpleGrid.vue +24 -0
  46. package/components/public/cms/block/CmsBlockImageSlider.vue +17 -0
  47. package/components/public/cms/block/CmsBlockImageText.vue +19 -0
  48. package/components/public/cms/block/CmsBlockImageTextBubble.vue +51 -0
  49. package/components/public/cms/block/CmsBlockImageTextCover.vue +25 -0
  50. package/components/public/cms/block/CmsBlockImageTextGallery.vue +85 -0
  51. package/components/public/cms/block/CmsBlockImageTextRow.vue +43 -0
  52. package/components/public/cms/block/CmsBlockImageThreeColumn.vue +21 -0
  53. package/components/public/cms/block/CmsBlockImageThreeCover.vue +27 -0
  54. package/components/public/cms/block/CmsBlockImageTwoColumn.vue +25 -0
  55. package/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +15 -0
  56. package/components/public/cms/block/CmsBlockProductHeading.vue +26 -0
  57. package/components/public/cms/block/CmsBlockProductListing.vue +17 -0
  58. package/components/public/cms/block/CmsBlockProductSlider.vue +16 -0
  59. package/components/public/cms/block/CmsBlockProductThreeColumn.vue +22 -0
  60. package/components/public/cms/block/CmsBlockSidebarFilter.vue +17 -0
  61. package/components/public/cms/block/CmsBlockText.vue +15 -0
  62. package/components/public/cms/block/CmsBlockTextHero.vue +15 -0
  63. package/components/public/cms/block/CmsBlockTextOnImage.vue +20 -0
  64. package/components/public/cms/block/CmsBlockTextTeaser.vue +16 -0
  65. package/components/public/cms/block/CmsBlockTextTeaserSection.vue +21 -0
  66. package/components/public/cms/block/CmsBlockTextThreeColumn.vue +22 -0
  67. package/components/public/cms/block/CmsBlockTextTwoColumn.vue +28 -0
  68. package/components/public/cms/block/CmsBlockVimeoVideo.vue +17 -0
  69. package/components/public/cms/block/CmsBlockYoutubeVideo.vue +17 -0
  70. package/components/public/cms/element/CmsElementBuyBox.md +1 -0
  71. package/components/public/cms/element/CmsElementBuyBox.vue +190 -0
  72. package/components/public/cms/element/CmsElementCategoryNavigation.md +1 -0
  73. package/components/public/cms/element/CmsElementCategoryNavigation.vue +167 -0
  74. package/components/public/cms/element/CmsElementCrossSelling.md +1 -0
  75. package/components/public/cms/element/CmsElementCrossSelling.vue +106 -0
  76. package/components/public/cms/element/CmsElementCustomForm.md +1 -0
  77. package/components/public/cms/element/CmsElementCustomForm.vue +27 -0
  78. package/components/public/cms/element/CmsElementForm.md +1 -0
  79. package/components/public/cms/element/CmsElementForm.vue +27 -0
  80. package/components/public/cms/element/CmsElementImage.md +1 -0
  81. package/components/public/cms/element/CmsElementImage.vue +105 -0
  82. package/components/public/cms/element/CmsElementImageGallery.md +1 -0
  83. package/components/public/cms/element/CmsElementImageGallery.vue +249 -0
  84. package/components/public/cms/element/CmsElementImageGallery3dPlaceholder.vue +53 -0
  85. package/components/public/cms/element/CmsElementImageSlider.md +1 -0
  86. package/components/public/cms/element/CmsElementImageSlider.vue +29 -0
  87. package/components/public/cms/element/CmsElementManufacturerLogo.md +1 -0
  88. package/components/public/cms/element/CmsElementManufacturerLogo.vue +11 -0
  89. package/components/public/cms/element/CmsElementProductBox.md +1 -0
  90. package/components/public/cms/element/CmsElementProductBox.vue +14 -0
  91. package/components/public/cms/element/CmsElementProductDescriptionReviews.md +1 -0
  92. package/components/public/cms/element/CmsElementProductDescriptionReviews.vue +109 -0
  93. package/components/public/cms/element/CmsElementProductListing.md +1 -0
  94. package/components/public/cms/element/CmsElementProductListing.vue +245 -0
  95. package/components/public/cms/element/CmsElementProductName.md +1 -0
  96. package/components/public/cms/element/CmsElementProductName.vue +10 -0
  97. package/components/public/cms/element/CmsElementProductSlider.md +1 -0
  98. package/components/public/cms/element/CmsElementProductSlider.vue +80 -0
  99. package/components/public/cms/element/CmsElementSidebarFilter.md +1 -0
  100. package/components/public/cms/element/CmsElementSidebarFilter.vue +12 -0
  101. package/components/public/cms/element/CmsElementText.md +1 -0
  102. package/components/public/cms/element/CmsElementText.vue +186 -0
  103. package/components/public/cms/element/CmsElementVimeoVideo.md +1 -0
  104. package/components/public/cms/element/CmsElementVimeoVideo.vue +63 -0
  105. package/components/public/cms/element/CmsElementYoutubeVideo.md +1 -0
  106. package/components/public/cms/element/CmsElementYoutubeVideo.vue +43 -0
  107. package/components/public/cms/section/CmsSectionDefault.md +3 -0
  108. package/components/public/cms/section/CmsSectionDefault.vue +21 -0
  109. package/components/public/cms/section/CmsSectionSidebar.md +3 -0
  110. package/components/public/cms/section/CmsSectionSidebar.vue +49 -0
  111. package/components/public/cms/skeleton/ProductCardSkeleton.vue +44 -0
  112. package/dist/index.d.mts +5 -0
  113. package/dist/index.d.ts +5 -0
  114. package/dist/index.mjs +31 -0
  115. package/helpers/clientOnly.ts +11 -0
  116. package/helpers/html-to-vue/ast.ts +72 -0
  117. package/helpers/html-to-vue/getOptionsFromNode.test.ts +129 -0
  118. package/helpers/html-to-vue/getOptionsFromNode.ts +52 -0
  119. package/helpers/html-to-vue/renderToHtml.ts +45 -0
  120. package/helpers/html-to-vue/renderer.ts +56 -0
  121. package/helpers/media/isSpatial.ts +8 -0
  122. package/index.cjs +7 -0
  123. package/nuxt.config.ts +21 -0
  124. package/package.json +69 -0
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import { useCmsTranslations, useProductPrice } from "@shopware/composables";
3
+ import { defu } from "defu";
4
+ import { toRefs } from "vue";
5
+ import type { Schemas } from "#shopware";
6
+
7
+ const props = defineProps<{
8
+ product: Schemas["Product"];
9
+ }>();
10
+
11
+ type Translations = {
12
+ listing: {
13
+ variantsFrom: string;
14
+ previously: string;
15
+ to: string;
16
+ };
17
+ };
18
+
19
+ let translations: Translations = {
20
+ listing: {
21
+ variantsFrom: "variants from",
22
+ previously: "previously",
23
+ from: "from",
24
+ to: "to",
25
+ },
26
+ };
27
+
28
+ translations = defu(useCmsTranslations(), translations) as Translations;
29
+
30
+ const { product } = toRefs(props);
31
+
32
+ const {
33
+ price,
34
+ unitPrice,
35
+ displayFromVariants,
36
+ displayFrom,
37
+ isListPrice,
38
+ regulationPrice,
39
+ } = useProductPrice(product);
40
+ </script>
41
+
42
+ <template>
43
+ <div :id="product.id">
44
+ <SwSharedPrice
45
+ v-if="isListPrice"
46
+ class="text-l text-gray-900 basis-2/6 justify-end line-through"
47
+ :value="price?.listPrice?.price"
48
+ />
49
+ <template v-if="!isListPrice">
50
+ <div class="h-6"><!-- placeholder --></div>
51
+ </template>
52
+ <SwSharedPrice
53
+ v-if="displayFromVariants"
54
+ class="text-xl text-gray-900 basis-2/6 justify-end"
55
+ :value="displayFromVariants"
56
+ >
57
+ <template #beforePrice
58
+ ><span v-if="displayFromVariants" class="text-sm">{{
59
+ translations.listing.variantsFrom
60
+ }}</span></template
61
+ >
62
+ </SwSharedPrice>
63
+ <SwSharedPrice
64
+ class="text-gray-900 basis-2/6"
65
+ :class="{
66
+ 'text-red-600 font-bold': isListPrice,
67
+ 'justify-end text-xl':
68
+ regulationPrice || !regulationPrice || !displayFromVariants,
69
+ }"
70
+ :value="unitPrice"
71
+ >
72
+ <template #beforePrice
73
+ ><span v-if="displayFrom || displayFromVariants" class="text-sm">{{
74
+ translations.listing.from
75
+ }}</span></template
76
+ >
77
+ </SwSharedPrice>
78
+ <template v-if="regulationPrice">
79
+ <div class="flex gap-2 justify-end text-gray-500 text-3.5 mb-2">
80
+ {{ translations.listing.previously }}
81
+ <SharedPrice :value="regulationPrice" />
82
+ </div>
83
+ </template>
84
+ <template v-if="!regulationPrice">
85
+ <div class="h-7"><!-- placeholder --></div>
86
+ </template>
87
+ </div>
88
+ </template>
@@ -0,0 +1,34 @@
1
+ <script lang="ts" setup>
2
+ import { OrbitControls, useGLTF } from "@tresjs/cientos";
3
+ import { TresCanvas } from "@tresjs/core";
4
+ import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from "three";
5
+
6
+ const props = defineProps<{
7
+ src: string;
8
+ }>();
9
+
10
+ const gl = {
11
+ clearColor: "#FFF",
12
+ shadows: true,
13
+ alpha: false,
14
+ shadowMapType: BasicShadowMap,
15
+ outputColorSpace: SRGBColorSpace,
16
+ toneMapping: NoToneMapping,
17
+ windowSize: false,
18
+ };
19
+
20
+ const { scene: model } = await useGLTF(props.src);
21
+ </script>
22
+ <template>
23
+ <TresCanvas v-bind="gl">
24
+ <TresPerspectiveCamera
25
+ :args="[75, 1, 0.1, 2000]"
26
+ :position="[0, 0, 500]"
27
+ :look-at="[0, 0, 0]"
28
+ />
29
+ <OrbitControls />
30
+ <primitive :object="model" />
31
+ <TresDirectionalLight :position="[3, 3, 3]" :intensity="1" />
32
+ <TresAmbientLight :intensity="2" />
33
+ </TresCanvas>
34
+ </template>
@@ -0,0 +1,347 @@
1
+ <script setup lang="ts">
2
+ import { ApiClientError } from "@shopware/api-client";
3
+ import type { ApiError } from "@shopware/api-client";
4
+ import type { CmsElementForm } from "@shopware/composables";
5
+ import { useCmsTranslations } from "@shopware/composables";
6
+ import { useVuelidate } from "@vuelidate/core";
7
+ import type { ValidationRuleWithoutParams } from "@vuelidate/core";
8
+ import { email, required } from "@vuelidate/validators";
9
+ import { defu } from "defu";
10
+ import { computed, reactive, ref } from "vue";
11
+ import { useCmsElementConfig, useNewsletter, useSalutations } from "#imports";
12
+
13
+ const props = defineProps<{
14
+ content: CmsElementForm;
15
+ }>();
16
+
17
+ type Translations = {
18
+ form: {
19
+ subscribeLabel: string;
20
+ unsubscribeLabel: string;
21
+ action: string;
22
+ email: string;
23
+ emailPlaceholder: string;
24
+ salutation: string;
25
+ salutationPlaceholder: string;
26
+ firstName: string;
27
+ firstNamePlaceholder: string;
28
+ lastName: string;
29
+ lastNamePlaceholder: string;
30
+ privacy: string;
31
+ privacyLabel: string;
32
+ submit: string;
33
+ newsletterBenefits: string;
34
+ };
35
+ };
36
+
37
+ let translations: Translations = {
38
+ form: {
39
+ subscribeLabel: "Subscribe to newsletter",
40
+ unsubscribeLabel: "Unsubscribe from newsletter",
41
+ action: "Action",
42
+ email: "Email address",
43
+ emailPlaceholder: "Enter email address...",
44
+ salutation: "Salutation",
45
+ salutationPlaceholder: "Enter salutation...",
46
+ firstName: "First name",
47
+ firstNamePlaceholder: "Enter first name...",
48
+ lastName: "Last name",
49
+ lastNamePlaceholder: "Enter last name...",
50
+ privacy: "Privacy",
51
+ privacyLabel: "I have read the data protection information.",
52
+ submit: "Submit",
53
+ newsletterBenefits:
54
+ "Be aware of upcoming sales and events.Receive gifts and special offers!",
55
+ },
56
+ };
57
+
58
+ translations = defu(useCmsTranslations(), translations) as Translations;
59
+
60
+ const loading = ref<boolean>();
61
+ const formSent = ref<boolean>(false);
62
+ const errorMessages = ref<ApiError[]>([]);
63
+ const subscriptionOptions: {
64
+ label: string;
65
+ value: "subscribe" | "unsubscribe";
66
+ }[] = [
67
+ {
68
+ label: translations.form.subscribeLabel,
69
+ value: "subscribe",
70
+ },
71
+ {
72
+ label: translations.form.unsubscribeLabel,
73
+ value: "unsubscribe",
74
+ },
75
+ ];
76
+ const { getSalutations } = useSalutations();
77
+ const { getConfigValue } = useCmsElementConfig(props.content);
78
+ const { newsletterSubscribe, newsletterUnsubscribe } = useNewsletter();
79
+
80
+ const getFormTitle = computed(() => getConfigValue("title"));
81
+ const state = reactive({
82
+ option: subscriptionOptions[0].value,
83
+ salutationId: "",
84
+ firstName: "",
85
+ lastName: "",
86
+ email: "",
87
+ checkbox: false,
88
+ });
89
+
90
+ type Rules = {
91
+ email: {
92
+ required: ValidationRuleWithoutParams;
93
+ email: ValidationRuleWithoutParams;
94
+ };
95
+ checkbox: {
96
+ required: ValidationRuleWithoutParams;
97
+ isTrue: (value: boolean) => boolean;
98
+ };
99
+ firstName: {
100
+ required: ValidationRuleWithoutParams;
101
+ minLength: number;
102
+ };
103
+ lastName: {
104
+ required: ValidationRuleWithoutParams;
105
+ minLength: number;
106
+ };
107
+ salutationId: {
108
+ required: ValidationRuleWithoutParams;
109
+ };
110
+ };
111
+ const rules = computed(() => {
112
+ let temp: Partial<Rules> = {
113
+ email: {
114
+ required,
115
+ email,
116
+ },
117
+ checkbox: {
118
+ required,
119
+ isTrue: (value: boolean) => value === true,
120
+ },
121
+ };
122
+ if (state.option === "subscribe") {
123
+ temp = {
124
+ ...temp,
125
+ firstName: {
126
+ required,
127
+ minLength: 3,
128
+ },
129
+ lastName: {
130
+ required,
131
+ minLength: 3,
132
+ },
133
+ salutationId: {
134
+ required,
135
+ },
136
+ };
137
+ }
138
+ return temp;
139
+ });
140
+
141
+ const $v = useVuelidate(rules, state);
142
+ const invokeSubmit = async () => {
143
+ $v.value.$touch();
144
+ const valid = await $v.value.$validate();
145
+ if (valid) {
146
+ loading.value = true;
147
+ try {
148
+ if (state.option === "subscribe") {
149
+ await newsletterSubscribe({
150
+ ...state,
151
+ });
152
+ } else {
153
+ await newsletterUnsubscribe(state.email);
154
+ }
155
+ formSent.value = true;
156
+ } catch (e) {
157
+ if (e instanceof ApiClientError) {
158
+ errorMessages.value = e.details.errors;
159
+ }
160
+ } finally {
161
+ loading.value = false;
162
+ }
163
+ }
164
+ };
165
+ </script>
166
+ <template>
167
+ <form class="w-full relative" @submit.prevent="invokeSubmit">
168
+ <div
169
+ v-if="loading"
170
+ class="absolute inset-0 flex items-center justify-center z-10 bg-white/50"
171
+ >
172
+ <div
173
+ class="h-15 w-15 i-carbon-progress-bar-round animate-spin c-gray-500"
174
+ />
175
+ </div>
176
+ <h3 class="pb-3 mb-10 border-b border-gray-300">
177
+ {{
178
+ getFormTitle
179
+ ? getFormTitle
180
+ : state.option === "subscribe"
181
+ ? translations.form.subscribeLabel
182
+ : translations.form.unsubscribeLabel
183
+ }}
184
+ </h3>
185
+ <template v-if="!formSent">
186
+ <div class="grid grid-cols-12 gap-5">
187
+ <div class="col-span-12">
188
+ <label for="option">{{ translations.form.action }} *</label>
189
+ <select
190
+ id="option"
191
+ v-model="state.option"
192
+ name="option"
193
+ class="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
194
+ >
195
+ <option
196
+ v-for="subscription in subscriptionOptions"
197
+ :key="subscription.value"
198
+ :value="subscription.value"
199
+ >
200
+ {{ subscription.label }}
201
+ </option>
202
+ </select>
203
+ </div>
204
+ <div class="col-span-12">
205
+ <label for="email-address">{{ translations.form.email }} *</label>
206
+ <input
207
+ id="email-address"
208
+ v-model="state.email"
209
+ name="email"
210
+ type="email"
211
+ autocomplete="email"
212
+ :class="[
213
+ $v.email?.$error
214
+ ? 'border-red-600 focus:border-red-600'
215
+ : 'border-gray-300 focus:border-indigo-500',
216
+ ]"
217
+ class="appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
218
+ :placeholder="translations.form.emailPlaceholder"
219
+ @blur="$v.email?.$touch()"
220
+ />
221
+ <span
222
+ v-if="$v.email?.$error"
223
+ class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
224
+ >
225
+ {{ $v.email?.$errors[0].$message }}
226
+ </span>
227
+ </div>
228
+ <div v-if="state.option === 'subscribe'" class="col-span-4">
229
+ <label for="salutation">{{ translations.form.salutation }} *</label>
230
+ <select
231
+ id="salutation"
232
+ v-model="state.salutationId"
233
+ name="salutation"
234
+ class="appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
235
+ :class="[
236
+ $v.salutationId?.$error
237
+ ? 'border-red-600 focus:border-red-600'
238
+ : 'border-gray-300 focus:border-indigo-500',
239
+ ]"
240
+ @blur="$v.salutationId?.$touch()"
241
+ >
242
+ <option disabled selected value="">
243
+ {{ translations.form.salutationPlaceholder }}
244
+ </option>
245
+ <option
246
+ v-for="salutation in getSalutations"
247
+ :key="salutation.id"
248
+ :value="salutation.id"
249
+ >
250
+ {{ salutation.displayName }}
251
+ </option>
252
+ </select>
253
+ <span
254
+ v-if="$v.salutationId?.$error"
255
+ class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
256
+ >
257
+ {{ $v.salutationId?.$errors[0].$message }}
258
+ </span>
259
+ </div>
260
+ <div v-if="state.option === 'subscribe'" class="col-span-4">
261
+ <label for="first-name">{{ translations.form.firstName }} *</label>
262
+ <input
263
+ id="first-name"
264
+ v-model="state.firstName"
265
+ name="first-name"
266
+ type="text"
267
+ autocomplete="given-name"
268
+ class="appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
269
+ :class="[
270
+ $v.firstName?.$error
271
+ ? 'border-red-600 focus:border-red-600'
272
+ : 'border-gray-300 focus:border-indigo-500',
273
+ ]"
274
+ :placeholder="translations.form.firstNamePlaceholder"
275
+ @blur="$v.firstName?.$touch()"
276
+ />
277
+ <span
278
+ v-if="$v.firstName?.$error"
279
+ class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
280
+ >
281
+ {{ $v.firstName?.$errors[0].$message }}
282
+ </span>
283
+ </div>
284
+ <div v-if="state.option === 'subscribe'" class="col-span-4">
285
+ <label for="last-name">{{ translations.form.lastName }} *</label>
286
+ <input
287
+ id="last-name"
288
+ v-model="state.lastName"
289
+ name="last-name"
290
+ type="text"
291
+ autocomplete="family-name"
292
+ class="appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
293
+ :class="[
294
+ $v.lastName?.$error
295
+ ? 'border-red-600 focus:border-red-600'
296
+ : 'border-gray-300 focus:border-indigo-500',
297
+ ]"
298
+ :placeholder="translations.form.lastNamePlaceholder"
299
+ @blur="$v.lastName?.$touch()"
300
+ />
301
+ <span
302
+ v-if="$v.lastName?.$error"
303
+ class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
304
+ >
305
+ {{ $v.lastName?.$errors[0].$message }}
306
+ </span>
307
+ </div>
308
+ <div class="col-span-12">
309
+ <label>{{ translations.form.privacy }} *</label>
310
+ <div class="flex gap-3 items-start">
311
+ <input
312
+ id="privacy"
313
+ v-model="state.checkbox"
314
+ name="privacy"
315
+ type="checkbox"
316
+ class="mt-1 focus:ring-indigo-500 h-4 w-4 border text-indigo-600 rounded"
317
+ :class="[
318
+ $v.checkbox?.$error ? 'border-red-600' : 'border-gray-300',
319
+ ]"
320
+ />
321
+ <div>
322
+ <label
323
+ :class="[$v.checkbox?.$error ? 'text-red-600' : '']"
324
+ for="privacy"
325
+ >
326
+ {{ translations.form.privacyLabel }}
327
+ </label>
328
+ </div>
329
+ </div>
330
+ </div>
331
+ </div>
332
+ <div class="flex justify-end mt-10">
333
+ <button
334
+ class="group relative flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-75"
335
+ type="submit"
336
+ >
337
+ {{ translations.form.submit }}
338
+ </button>
339
+ </div>
340
+ </template>
341
+ <template v-else>
342
+ <p class="py-10 text-lg text-center">
343
+ {{ translations.form.newsletterBenefits }}
344
+ </p>
345
+ </template>
346
+ </form>
347
+ </template>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { useCmsTranslations } from "@shopware/composables";
3
+ import { defu } from "defu";
4
+
5
+ defineProps<{
6
+ total: number;
7
+ current: number;
8
+ }>();
9
+
10
+ type Translations = {
11
+ listing: {
12
+ previous: string;
13
+ next: string;
14
+ };
15
+ };
16
+
17
+ let translations: Translations = {
18
+ listing: {
19
+ previous: "Previous",
20
+ next: "Next",
21
+ },
22
+ };
23
+
24
+ translations = defu(useCmsTranslations(), translations) as Translations;
25
+
26
+ defineEmits<(e: "changePage", page: number) => void>();
27
+ </script>
28
+ <template>
29
+ <nav
30
+ class="relative z-0 inline-flex rounded-md shadow-sm space-x-px"
31
+ aria-label="Pagination"
32
+ >
33
+ <button
34
+ v-if="current - 1 >= 2"
35
+ class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 bg-white text-sm font-medium text-secondary-500 hover:bg-secondary-50"
36
+ @click="$emit('changePage', current - 1)"
37
+ >
38
+ <span class="sr-only">{{ translations.listing.previous }}</span>
39
+ <!-- Heroicon name: solid/chevron-left -->
40
+ <div class="w-5 h-5 i-carbon-chevron-left" />
41
+ </button>
42
+ <button
43
+ v-if="current > 2"
44
+ class="bg-white border-secondary-300 text-secondary-500 hover:bg-secondary-50 relative inline-flex items-center px-4 py-2 border text-sm font-medium"
45
+ @click="$emit('changePage', 1)"
46
+ >
47
+ <span class="sr-only">Page </span>1
48
+ </button>
49
+ <span
50
+ v-if="current - 1 > 2"
51
+ class="relative inline-flex items-center px-4 py-2 border border-secondary-300 bg-white text-sm font-medium text-secondary-700"
52
+ >
53
+ ...
54
+ </span>
55
+ <button
56
+ v-if="current > 1"
57
+ class="bg-white border-secondary-300 text-secondary-500 hover:bg-secondary-50 relative inline-flex items-center px-4 py-2 border text-sm font-medium"
58
+ :class="[current == 2 ? 'rounded-l-md border border-secondary-300' : '']"
59
+ @click="$emit('changePage', current - 1)"
60
+ >
61
+ <span class="sr-only">Page </span>{{ current - 1 }}
62
+ </button>
63
+ <button
64
+ aria-current="page"
65
+ class="bg-indigo-50 border-indigo-500 text-indigo-600 relative inline-flex items-center px-4 py-2 border text-sm font-medium"
66
+ :class="[
67
+ current - 1 >= 1 ? '' : 'rounded-l-md border border-secondary-300',
68
+ total == current ? 'rounded-r-md border border-secondary-300' : '',
69
+ ]"
70
+ >
71
+ <span class="sr-only">Page </span>{{ current }}
72
+ </button>
73
+ <button
74
+ v-if="current < total"
75
+ class="bg-white border-secondary-300 text-secondary-500 hover:bg-secondary-50 relative inline-flex items-center px-4 py-2 border text-sm font-medium"
76
+ :class="[
77
+ total == current + 1 ? 'rounded-r-md border border-secondary-300' : '',
78
+ ]"
79
+ @click="$emit('changePage', current + 1)"
80
+ >
81
+ <span class="sr-only">Page </span>{{ current + 1 }}
82
+ </button>
83
+ <span
84
+ v-if="total - current > 2"
85
+ class="relative inline-flex items-center px-4 py-2 border border-secondary-300 bg-white text-sm font-medium text-secondary-700"
86
+ >
87
+ ...
88
+ </span>
89
+ <button
90
+ v-if="total - current > 1"
91
+ class="bg-white border-secondary-300 text-secondary-500 hover:bg-secondary-50 relative inline-flex items-center px-4 py-2 border text-sm font-medium"
92
+ @click="$emit('changePage', total)"
93
+ >
94
+ {{ total }}
95
+ </button>
96
+ <button
97
+ v-if="total > current + 1"
98
+ class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 bg-white text-sm font-medium text-secondary-500 hover:bg-secondary-50"
99
+ @click="$emit('changePage', current + 1)"
100
+ >
101
+ <span class="sr-only">{{ translations.listing.next }}</span>
102
+ <!-- Heroicon name: solid/chevron-right -->
103
+ <div class="w-5 h-5 i-carbon-chevron-right" />
104
+ </button>
105
+ </nav>
106
+ </template>
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import { useCmsTranslations } from "@shopware/composables";
3
+ import { getCmsTranslate } from "@shopware/helpers";
4
+ import { defu } from "defu";
5
+ import { toRefs } from "vue";
6
+ import {
7
+ useAddToCart,
8
+ useCartErrorParamsResolver,
9
+ useCartNotification,
10
+ useNotifications,
11
+ } from "#imports";
12
+ import type { Schemas } from "#shopware";
13
+
14
+ const { pushSuccess, pushError } = useNotifications();
15
+ const { getErrorsCodes } = useCartNotification();
16
+ const { resolveCartError } = useCartErrorParamsResolver();
17
+ const props = defineProps<{
18
+ product: Schemas["Product"];
19
+ }>();
20
+
21
+ type Translations = {
22
+ product: {
23
+ addedToCart: string;
24
+ qty: string;
25
+ addToCart: string;
26
+ };
27
+ errors: {
28
+ [key: string]: string;
29
+ };
30
+ };
31
+
32
+ let translations: Translations = {
33
+ product: {
34
+ addedToCart: "has been added to cart.",
35
+ qty: "Qty",
36
+ addToCart: "Add to cart",
37
+ },
38
+ errors: {
39
+ "product-stock-reached":
40
+ "The product {name} is only available {quantity} times",
41
+ },
42
+ };
43
+
44
+ translations = defu(useCmsTranslations(), translations) as Translations;
45
+
46
+ const { product } = toRefs(props);
47
+ const { addToCart, quantity } = useAddToCart(product);
48
+
49
+ const addToCartProxy = async () => {
50
+ await addToCart();
51
+ const errors = getErrorsCodes();
52
+ for (const element of errors) {
53
+ const { messageKey, params } = resolveCartError(element);
54
+ pushError(getCmsTranslate(translations.errors[messageKey], params));
55
+ }
56
+
57
+ if (!errors.length)
58
+ pushSuccess(
59
+ `${props.product?.translated.name} ${translations.product.addedToCart}`,
60
+ );
61
+ };
62
+ </script>
63
+
64
+ <template>
65
+ <div class="flex flex-row mt-10">
66
+ <div class="basis-1/4 relative -top-6">
67
+ <label for="qty" class="text-sm">{{ translations.product.qty }}</label>
68
+ <input
69
+ id="qty"
70
+ v-model="quantity"
71
+ type="number"
72
+ :min="product.minPurchase || 1"
73
+ :max="product.calculatedMaxPurchase"
74
+ :step="product.purchaseSteps || 1"
75
+ class="border rounded-md py-2 px-4 border-solid border-1 border-cyan-600 w-full mt-4"
76
+ data-testid="product-quantity"
77
+ />
78
+ </div>
79
+ <div class="basis-3/4 ml-4">
80
+ <button
81
+ :disabled="!product.available"
82
+ class="py-2 px-6 w-full mt-4 bg-gradient-to-r from-cyan-500 to-blue-500 transition ease-in-out hover:bg-gradient-to-l duration-300 cursor-pointer border border-transparent rounded-md flex items-center justify-center text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
83
+ :class="{
84
+ 'opacity-50 cursor-not-allowed': !product.available,
85
+ }"
86
+ data-testid="add-to-cart-button"
87
+ @click="addToCartProxy"
88
+ >
89
+ 🛍 {{ translations.product.addToCart }}
90
+ </button>
91
+ </div>
92
+ </div>
93
+ </template>