hydrogen-forge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +212 -0
  2. package/dist/commands/add.d.ts +7 -0
  3. package/dist/commands/add.d.ts.map +1 -0
  4. package/dist/commands/add.js +123 -0
  5. package/dist/commands/add.js.map +1 -0
  6. package/dist/commands/create.d.ts +8 -0
  7. package/dist/commands/create.d.ts.map +1 -0
  8. package/dist/commands/create.js +160 -0
  9. package/dist/commands/create.js.map +1 -0
  10. package/dist/commands/setup-mcp.d.ts +7 -0
  11. package/dist/commands/setup-mcp.d.ts.map +1 -0
  12. package/dist/commands/setup-mcp.js +179 -0
  13. package/dist/commands/setup-mcp.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +50 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/lib/generators.d.ts +6 -0
  19. package/dist/lib/generators.d.ts.map +1 -0
  20. package/dist/lib/generators.js +470 -0
  21. package/dist/lib/generators.js.map +1 -0
  22. package/dist/lib/utils.d.ts +17 -0
  23. package/dist/lib/utils.d.ts.map +1 -0
  24. package/dist/lib/utils.js +101 -0
  25. package/dist/lib/utils.js.map +1 -0
  26. package/package.json +54 -0
  27. package/templates/starter/.env.example +21 -0
  28. package/templates/starter/.graphqlrc.ts +27 -0
  29. package/templates/starter/README.md +117 -0
  30. package/templates/starter/app/assets/favicon.svg +28 -0
  31. package/templates/starter/app/components/AddToCartButton.tsx +102 -0
  32. package/templates/starter/app/components/Aside.tsx +136 -0
  33. package/templates/starter/app/components/CartLineItem.tsx +229 -0
  34. package/templates/starter/app/components/CartMain.tsx +131 -0
  35. package/templates/starter/app/components/CartSummary.tsx +315 -0
  36. package/templates/starter/app/components/CollectionFilters.tsx +330 -0
  37. package/templates/starter/app/components/CollectionGrid.tsx +141 -0
  38. package/templates/starter/app/components/Footer.tsx +218 -0
  39. package/templates/starter/app/components/Header.tsx +296 -0
  40. package/templates/starter/app/components/PageLayout.tsx +174 -0
  41. package/templates/starter/app/components/PaginatedResourceSection.tsx +41 -0
  42. package/templates/starter/app/components/ProductCard.tsx +151 -0
  43. package/templates/starter/app/components/ProductForm.tsx +156 -0
  44. package/templates/starter/app/components/ProductGallery.tsx +164 -0
  45. package/templates/starter/app/components/ProductGrid.tsx +64 -0
  46. package/templates/starter/app/components/ProductImage.tsx +23 -0
  47. package/templates/starter/app/components/ProductItem.tsx +44 -0
  48. package/templates/starter/app/components/ProductPrice.tsx +97 -0
  49. package/templates/starter/app/components/SearchDialog.tsx +599 -0
  50. package/templates/starter/app/components/SearchForm.tsx +68 -0
  51. package/templates/starter/app/components/SearchFormPredictive.tsx +76 -0
  52. package/templates/starter/app/components/SearchResults.tsx +161 -0
  53. package/templates/starter/app/components/SearchResultsPredictive.tsx +461 -0
  54. package/templates/starter/app/entry.client.tsx +21 -0
  55. package/templates/starter/app/entry.server.tsx +53 -0
  56. package/templates/starter/app/graphql/customer-account/CustomerAddressMutations.ts +64 -0
  57. package/templates/starter/app/graphql/customer-account/CustomerDetailsQuery.ts +40 -0
  58. package/templates/starter/app/graphql/customer-account/CustomerOrderQuery.ts +90 -0
  59. package/templates/starter/app/graphql/customer-account/CustomerOrdersQuery.ts +63 -0
  60. package/templates/starter/app/graphql/customer-account/CustomerUpdateMutation.ts +25 -0
  61. package/templates/starter/app/lib/context.ts +60 -0
  62. package/templates/starter/app/lib/fragments.ts +234 -0
  63. package/templates/starter/app/lib/orderFilters.ts +90 -0
  64. package/templates/starter/app/lib/redirect.ts +23 -0
  65. package/templates/starter/app/lib/search.ts +79 -0
  66. package/templates/starter/app/lib/session.ts +72 -0
  67. package/templates/starter/app/lib/variants.ts +46 -0
  68. package/templates/starter/app/root.tsx +209 -0
  69. package/templates/starter/app/routes/$.tsx +11 -0
  70. package/templates/starter/app/routes/[robots.txt].tsx +117 -0
  71. package/templates/starter/app/routes/[sitemap.xml].tsx +16 -0
  72. package/templates/starter/app/routes/_index.tsx +167 -0
  73. package/templates/starter/app/routes/account.$.tsx +9 -0
  74. package/templates/starter/app/routes/account._index.tsx +5 -0
  75. package/templates/starter/app/routes/account.addresses.tsx +516 -0
  76. package/templates/starter/app/routes/account.orders.$id.tsx +222 -0
  77. package/templates/starter/app/routes/account.orders._index.tsx +222 -0
  78. package/templates/starter/app/routes/account.profile.tsx +133 -0
  79. package/templates/starter/app/routes/account.tsx +97 -0
  80. package/templates/starter/app/routes/account_.authorize.tsx +5 -0
  81. package/templates/starter/app/routes/account_.login.tsx +7 -0
  82. package/templates/starter/app/routes/account_.logout.tsx +11 -0
  83. package/templates/starter/app/routes/api.$version.[graphql.json].tsx +14 -0
  84. package/templates/starter/app/routes/blogs.$blogHandle.$articleHandle.tsx +129 -0
  85. package/templates/starter/app/routes/blogs.$blogHandle._index.tsx +175 -0
  86. package/templates/starter/app/routes/blogs._index.tsx +109 -0
  87. package/templates/starter/app/routes/cart.$lines.tsx +70 -0
  88. package/templates/starter/app/routes/cart.tsx +117 -0
  89. package/templates/starter/app/routes/collections.$handle.tsx +161 -0
  90. package/templates/starter/app/routes/collections._index.tsx +133 -0
  91. package/templates/starter/app/routes/collections.all.tsx +122 -0
  92. package/templates/starter/app/routes/discount.$code.tsx +48 -0
  93. package/templates/starter/app/routes/pages.$handle.tsx +88 -0
  94. package/templates/starter/app/routes/policies.$handle.tsx +93 -0
  95. package/templates/starter/app/routes/policies._index.tsx +69 -0
  96. package/templates/starter/app/routes/products.$handle.tsx +232 -0
  97. package/templates/starter/app/routes/search.tsx +426 -0
  98. package/templates/starter/app/routes/sitemap.$type.$page[.xml].tsx +23 -0
  99. package/templates/starter/app/routes.ts +9 -0
  100. package/templates/starter/app/styles/app.css +574 -0
  101. package/templates/starter/app/styles/reset.css +139 -0
  102. package/templates/starter/app/styles/tailwind.css +116 -0
  103. package/templates/starter/customer-accountapi.generated.d.ts +543 -0
  104. package/templates/starter/env.d.ts +7 -0
  105. package/templates/starter/eslint.config.js +247 -0
  106. package/templates/starter/guides/predictiveSearch/predictiveSearch.jpg +0 -0
  107. package/templates/starter/guides/predictiveSearch/predictiveSearch.md +394 -0
  108. package/templates/starter/guides/search/search.jpg +0 -0
  109. package/templates/starter/guides/search/search.md +335 -0
  110. package/templates/starter/package.json +71 -0
  111. package/templates/starter/postcss.config.js +6 -0
  112. package/templates/starter/public/.gitkeep +0 -0
  113. package/templates/starter/react-router.config.ts +13 -0
  114. package/templates/starter/server.ts +59 -0
  115. package/templates/starter/storefrontapi.generated.d.ts +1264 -0
  116. package/templates/starter/tailwind.config.js +83 -0
  117. package/templates/starter/tsconfig.json +67 -0
  118. package/templates/starter/vite.config.ts +32 -0
@@ -0,0 +1,156 @@
1
+ import {Link, useNavigate} from 'react-router';
2
+ import {type MappedProductOptions} from '@shopify/hydrogen';
3
+ import type {
4
+ Maybe,
5
+ ProductOptionValueSwatch,
6
+ } from '@shopify/hydrogen/storefront-api-types';
7
+ import {AddToCartButton} from './AddToCartButton';
8
+ import {useAside} from './Aside';
9
+ import type {ProductFragment} from 'storefrontapi.generated';
10
+
11
+ export function ProductForm({
12
+ productOptions,
13
+ selectedVariant,
14
+ }: {
15
+ productOptions: MappedProductOptions[];
16
+ selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
17
+ }) {
18
+ const navigate = useNavigate();
19
+ const {open} = useAside();
20
+
21
+ return (
22
+ <div className="space-y-6">
23
+ {/* Product Options */}
24
+ {productOptions.map((option) => {
25
+ // If there is only a single value in the option values, don't display the option
26
+ if (option.optionValues.length === 1) return null;
27
+
28
+ return (
29
+ <div key={option.name} className="space-y-3">
30
+ <h5 className="text-sm font-medium text-secondary-900">
31
+ {option.name}
32
+ </h5>
33
+ <div className="flex flex-wrap gap-2">
34
+ {option.optionValues.map((value) => {
35
+ const {
36
+ name,
37
+ handle,
38
+ variantUriQuery,
39
+ selected,
40
+ available,
41
+ exists,
42
+ isDifferentProduct,
43
+ swatch,
44
+ } = value;
45
+
46
+ const baseClasses =
47
+ 'relative flex min-w-[3rem] items-center justify-center rounded-md border px-3 py-2 text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2';
48
+
49
+ const stateClasses = selected
50
+ ? 'border-primary-600 bg-primary-50 text-primary-700'
51
+ : available
52
+ ? 'border-secondary-300 bg-white text-secondary-900 hover:border-secondary-400'
53
+ : 'border-secondary-200 bg-secondary-50 text-secondary-400 cursor-not-allowed';
54
+
55
+ if (isDifferentProduct) {
56
+ return (
57
+ <Link
58
+ key={option.name + name}
59
+ className={`${baseClasses} ${stateClasses}`}
60
+ prefetch="intent"
61
+ preventScrollReset
62
+ replace
63
+ to={`/products/${handle}?${variantUriQuery}`}
64
+ >
65
+ <ProductOptionSwatch swatch={swatch} name={name} />
66
+ {!available && (
67
+ <span className="absolute inset-0 flex items-center justify-center">
68
+ <span className="h-px w-full rotate-[-45deg] bg-secondary-400" />
69
+ </span>
70
+ )}
71
+ </Link>
72
+ );
73
+ }
74
+
75
+ return (
76
+ <button
77
+ key={option.name + name}
78
+ type="button"
79
+ className={`${baseClasses} ${stateClasses}`}
80
+ disabled={!exists}
81
+ onClick={() => {
82
+ if (!selected && exists) {
83
+ void navigate(`?${variantUriQuery}`, {
84
+ replace: true,
85
+ preventScrollReset: true,
86
+ });
87
+ }
88
+ }}
89
+ >
90
+ <ProductOptionSwatch swatch={swatch} name={name} />
91
+ {!available && exists && (
92
+ <span className="absolute inset-0 flex items-center justify-center">
93
+ <span className="h-px w-full rotate-[-45deg] bg-secondary-400" />
94
+ </span>
95
+ )}
96
+ </button>
97
+ );
98
+ })}
99
+ </div>
100
+ </div>
101
+ );
102
+ })}
103
+
104
+ {/* Add to Cart */}
105
+ <div className="space-y-3 pt-2">
106
+ <AddToCartButton
107
+ disabled={!selectedVariant || !selectedVariant.availableForSale}
108
+ onClick={() => {
109
+ open('cart');
110
+ }}
111
+ lines={
112
+ selectedVariant
113
+ ? [
114
+ {
115
+ merchandiseId: selectedVariant.id,
116
+ quantity: 1,
117
+ selectedVariant,
118
+ },
119
+ ]
120
+ : []
121
+ }
122
+ >
123
+ {selectedVariant?.availableForSale ? 'Add to Cart' : 'Sold Out'}
124
+ </AddToCartButton>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ function ProductOptionSwatch({
131
+ swatch,
132
+ name,
133
+ }: {
134
+ swatch?: Maybe<ProductOptionValueSwatch> | undefined;
135
+ name: string;
136
+ }) {
137
+ const image = swatch?.image?.previewImage?.url;
138
+ const color = swatch?.color;
139
+
140
+ if (!image && !color) {
141
+ return <span>{name}</span>;
142
+ }
143
+
144
+ return (
145
+ <span
146
+ aria-label={name}
147
+ className="block h-6 w-6 rounded-full border border-secondary-300 shadow-sm"
148
+ style={{
149
+ backgroundColor: color || 'transparent',
150
+ backgroundImage: image ? `url(${image})` : undefined,
151
+ backgroundSize: 'cover',
152
+ backgroundPosition: 'center',
153
+ }}
154
+ />
155
+ );
156
+ }
@@ -0,0 +1,164 @@
1
+ import {useState} from 'react';
2
+ import {Image} from '@shopify/hydrogen';
3
+
4
+ export interface ProductGalleryProps {
5
+ images: Array<{
6
+ id?: string;
7
+ url: string;
8
+ altText?: string | null;
9
+ width?: number;
10
+ height?: number;
11
+ }>;
12
+ selectedVariantImage?: {
13
+ id?: string;
14
+ url: string;
15
+ altText?: string | null;
16
+ width?: number;
17
+ height?: number;
18
+ } | null;
19
+ }
20
+
21
+ export function ProductGallery({
22
+ images,
23
+ selectedVariantImage,
24
+ }: ProductGalleryProps) {
25
+ const [selectedIndex, setSelectedIndex] = useState(0);
26
+
27
+ // If there's a selected variant image, find its index or use it as the main image
28
+ const allImages = selectedVariantImage
29
+ ? [
30
+ selectedVariantImage,
31
+ ...images.filter((img) => img.id !== selectedVariantImage.id),
32
+ ]
33
+ : images;
34
+
35
+ const mainImage = allImages[selectedIndex] || allImages[0];
36
+ const hasThumbnails = allImages.length > 1;
37
+
38
+ if (!allImages.length) {
39
+ return (
40
+ <div className="aspect-square w-full rounded-lg bg-secondary-100">
41
+ <div className="flex h-full w-full items-center justify-center">
42
+ <svg
43
+ className="h-16 w-16 text-secondary-300"
44
+ fill="none"
45
+ stroke="currentColor"
46
+ viewBox="0 0 24 24"
47
+ >
48
+ <path
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ strokeWidth={1}
52
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
53
+ />
54
+ </svg>
55
+ </div>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ return (
61
+ <div className="flex flex-col gap-4">
62
+ {/* Main Image */}
63
+ <div className="relative aspect-square overflow-hidden rounded-lg bg-secondary-100">
64
+ {mainImage && (
65
+ <Image
66
+ alt={mainImage.altText || 'Product image'}
67
+ aspectRatio="1/1"
68
+ data={mainImage}
69
+ sizes="(min-width: 1024px) 50vw, 100vw"
70
+ className="h-full w-full object-cover object-center"
71
+ />
72
+ )}
73
+
74
+ {/* Navigation Arrows */}
75
+ {hasThumbnails && (
76
+ <>
77
+ <button
78
+ type="button"
79
+ className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-white/80 p-2 shadow-md backdrop-blur-sm transition-all hover:bg-white disabled:opacity-50"
80
+ onClick={() =>
81
+ setSelectedIndex((prev) =>
82
+ prev === 0 ? allImages.length - 1 : prev - 1,
83
+ )
84
+ }
85
+ aria-label="Previous image"
86
+ >
87
+ <svg
88
+ className="h-5 w-5 text-secondary-900"
89
+ fill="none"
90
+ stroke="currentColor"
91
+ viewBox="0 0 24 24"
92
+ >
93
+ <path
94
+ strokeLinecap="round"
95
+ strokeLinejoin="round"
96
+ strokeWidth={2}
97
+ d="M15 19l-7-7 7-7"
98
+ />
99
+ </svg>
100
+ </button>
101
+ <button
102
+ type="button"
103
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white/80 p-2 shadow-md backdrop-blur-sm transition-all hover:bg-white disabled:opacity-50"
104
+ onClick={() =>
105
+ setSelectedIndex((prev) =>
106
+ prev === allImages.length - 1 ? 0 : prev + 1,
107
+ )
108
+ }
109
+ aria-label="Next image"
110
+ >
111
+ <svg
112
+ className="h-5 w-5 text-secondary-900"
113
+ fill="none"
114
+ stroke="currentColor"
115
+ viewBox="0 0 24 24"
116
+ >
117
+ <path
118
+ strokeLinecap="round"
119
+ strokeLinejoin="round"
120
+ strokeWidth={2}
121
+ d="M9 5l7 7-7 7"
122
+ />
123
+ </svg>
124
+ </button>
125
+ </>
126
+ )}
127
+
128
+ {/* Image Counter */}
129
+ {hasThumbnails && (
130
+ <div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-xs text-white backdrop-blur-sm">
131
+ {selectedIndex + 1} / {allImages.length}
132
+ </div>
133
+ )}
134
+ </div>
135
+
136
+ {/* Thumbnails */}
137
+ {hasThumbnails && (
138
+ <div className="flex gap-2 overflow-x-auto pb-2">
139
+ {allImages.map((image, index) => (
140
+ <button
141
+ key={image.id || index}
142
+ type="button"
143
+ className={`relative aspect-square w-16 flex-shrink-0 overflow-hidden rounded-md transition-all ${
144
+ index === selectedIndex
145
+ ? 'ring-2 ring-primary-600 ring-offset-2'
146
+ : 'opacity-70 hover:opacity-100'
147
+ }`}
148
+ onClick={() => setSelectedIndex(index)}
149
+ aria-label={`View image ${index + 1}`}
150
+ >
151
+ <Image
152
+ alt={image.altText || `Product thumbnail ${index + 1}`}
153
+ aspectRatio="1/1"
154
+ data={image}
155
+ sizes="64px"
156
+ className="h-full w-full object-cover object-center"
157
+ />
158
+ </button>
159
+ ))}
160
+ </div>
161
+ )}
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,64 @@
1
+ import {ProductCard, type ProductCardProps} from './ProductCard';
2
+
3
+ export interface ProductGridProps {
4
+ products: ProductCardProps['product'][];
5
+ columns?: 2 | 3 | 4;
6
+ loading?: 'eager' | 'lazy';
7
+ showVendor?: boolean;
8
+ }
9
+
10
+ export function ProductGrid({
11
+ products,
12
+ columns = 4,
13
+ loading = 'lazy',
14
+ showVendor = false,
15
+ }: ProductGridProps) {
16
+ const gridCols = {
17
+ 2: 'grid-cols-1 sm:grid-cols-2',
18
+ 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
19
+ 4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
20
+ };
21
+
22
+ if (!products || products.length === 0) {
23
+ return (
24
+ <div className="flex min-h-[200px] items-center justify-center rounded-lg border border-dashed border-secondary-300 bg-secondary-50">
25
+ <p className="text-secondary-500">No products found</p>
26
+ </div>
27
+ );
28
+ }
29
+
30
+ return (
31
+ <div className={`grid gap-4 sm:gap-6 ${gridCols[columns]}`}>
32
+ {products.map((product, index) => (
33
+ <ProductCard
34
+ key={product.id}
35
+ product={product}
36
+ loading={index < 4 ? 'eager' : loading}
37
+ showVendor={showVendor}
38
+ />
39
+ ))}
40
+ </div>
41
+ );
42
+ }
43
+
44
+ export function ProductGridSkeleton({columns = 4}: {columns?: 2 | 3 | 4}) {
45
+ const gridCols = {
46
+ 2: 'grid-cols-1 sm:grid-cols-2',
47
+ 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
48
+ 4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
49
+ };
50
+
51
+ return (
52
+ <div className={`grid gap-4 sm:gap-6 ${gridCols[columns]}`}>
53
+ {Array.from({length: columns * 2}).map((_, index) => (
54
+ <div key={index} className="animate-pulse">
55
+ <div className="aspect-square rounded-lg bg-secondary-200" />
56
+ <div className="mt-3 space-y-2">
57
+ <div className="h-4 w-3/4 rounded bg-secondary-200" />
58
+ <div className="h-4 w-1/2 rounded bg-secondary-200" />
59
+ </div>
60
+ </div>
61
+ ))}
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,23 @@
1
+ import type {ProductVariantFragment} from 'storefrontapi.generated';
2
+ import {Image} from '@shopify/hydrogen';
3
+
4
+ export function ProductImage({
5
+ image,
6
+ }: {
7
+ image: ProductVariantFragment['image'];
8
+ }) {
9
+ if (!image) {
10
+ return <div className="product-image" />;
11
+ }
12
+ return (
13
+ <div className="product-image">
14
+ <Image
15
+ alt={image.altText || 'Product Image'}
16
+ aspectRatio="1/1"
17
+ data={image}
18
+ key={image.id}
19
+ sizes="(min-width: 45em) 50vw, 100vw"
20
+ />
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,44 @@
1
+ import {Link} from 'react-router';
2
+ import {Image, Money} from '@shopify/hydrogen';
3
+ import type {
4
+ ProductItemFragment,
5
+ CollectionItemFragment,
6
+ RecommendedProductFragment,
7
+ } from 'storefrontapi.generated';
8
+ import {useVariantUrl} from '~/lib/variants';
9
+
10
+ export function ProductItem({
11
+ product,
12
+ loading,
13
+ }: {
14
+ product:
15
+ | CollectionItemFragment
16
+ | ProductItemFragment
17
+ | RecommendedProductFragment;
18
+ loading?: 'eager' | 'lazy';
19
+ }) {
20
+ const variantUrl = useVariantUrl(product.handle);
21
+ const image = product.featuredImage;
22
+ return (
23
+ <Link
24
+ className="product-item"
25
+ key={product.id}
26
+ prefetch="intent"
27
+ to={variantUrl}
28
+ >
29
+ {image && (
30
+ <Image
31
+ alt={image.altText || product.title}
32
+ aspectRatio="1/1"
33
+ data={image}
34
+ loading={loading}
35
+ sizes="(min-width: 45em) 400px, 100vw"
36
+ />
37
+ )}
38
+ <h4>{product.title}</h4>
39
+ <small>
40
+ <Money data={product.priceRange.minVariantPrice} />
41
+ </small>
42
+ </Link>
43
+ );
44
+ }
@@ -0,0 +1,97 @@
1
+ import {Money} from '@shopify/hydrogen';
2
+ import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types';
3
+
4
+ export interface ProductPriceProps {
5
+ price?: MoneyV2;
6
+ compareAtPrice?: MoneyV2 | null;
7
+ size?: 'sm' | 'md' | 'lg';
8
+ layout?: 'inline' | 'stacked';
9
+ }
10
+
11
+ export function ProductPrice({
12
+ price,
13
+ compareAtPrice,
14
+ size = 'md',
15
+ layout = 'inline',
16
+ }: ProductPriceProps) {
17
+ const isOnSale =
18
+ compareAtPrice?.amount &&
19
+ price?.amount &&
20
+ parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
21
+
22
+ const sizeClasses = {
23
+ sm: 'text-sm',
24
+ md: 'text-lg',
25
+ lg: 'text-2xl',
26
+ };
27
+
28
+ const containerClasses =
29
+ layout === 'inline' ? 'flex items-center gap-2' : 'flex flex-col gap-1';
30
+
31
+ if (!price) {
32
+ return (
33
+ <span className={`${sizeClasses[size]} text-secondary-500`}>&nbsp;</span>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <div className={containerClasses}>
39
+ {isOnSale ? (
40
+ <>
41
+ <span className={`${sizeClasses[size]} font-semibold text-red-600`}>
42
+ <Money data={price} />
43
+ </span>
44
+ {compareAtPrice && (
45
+ <span
46
+ className={`${size === 'lg' ? 'text-lg' : 'text-sm'} text-secondary-500 line-through`}
47
+ >
48
+ <Money data={compareAtPrice} />
49
+ </span>
50
+ )}
51
+ <SaleBadge
52
+ price={price}
53
+ compareAtPrice={compareAtPrice}
54
+ size={size}
55
+ />
56
+ </>
57
+ ) : (
58
+ <span
59
+ className={`${sizeClasses[size]} font-semibold text-secondary-900`}
60
+ >
61
+ <Money data={price} />
62
+ </span>
63
+ )}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ function SaleBadge({
69
+ price,
70
+ compareAtPrice,
71
+ size,
72
+ }: {
73
+ price: MoneyV2;
74
+ compareAtPrice: MoneyV2;
75
+ size: 'sm' | 'md' | 'lg';
76
+ }) {
77
+ const savings = parseFloat(compareAtPrice.amount) - parseFloat(price.amount);
78
+ const percentOff = Math.round(
79
+ (savings / parseFloat(compareAtPrice.amount)) * 100,
80
+ );
81
+
82
+ if (percentOff <= 0) return null;
83
+
84
+ const badgeSizeClasses = {
85
+ sm: 'text-xs px-1.5 py-0.5',
86
+ md: 'text-xs px-2 py-0.5',
87
+ lg: 'text-sm px-2 py-1',
88
+ };
89
+
90
+ return (
91
+ <span
92
+ className={`${badgeSizeClasses[size]} rounded bg-red-100 font-medium text-red-700`}
93
+ >
94
+ Save {percentOff}%
95
+ </span>
96
+ );
97
+ }