@forgecart/cli 1.0.1 → 1.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 (81) hide show
  1. package/dist/package.json +2 -1
  2. package/dist/src/commands/delete.js +2 -0
  3. package/dist/src/commands/delete.js.map +1 -1
  4. package/dist/src/commands/deploy.d.ts.map +1 -1
  5. package/dist/src/commands/deploy.js +36 -28
  6. package/dist/src/commands/deploy.js.map +1 -1
  7. package/dist/src/commands/env.js +15 -8
  8. package/dist/src/commands/env.js.map +1 -1
  9. package/dist/src/commands/init.js +56 -4
  10. package/dist/src/commands/init.js.map +1 -1
  11. package/dist/src/commands/list.js +2 -0
  12. package/dist/src/commands/list.js.map +1 -1
  13. package/dist/src/commands/scale.js +1 -0
  14. package/dist/src/commands/scale.js.map +1 -1
  15. package/dist/src/commands/status.js +3 -0
  16. package/dist/src/commands/status.js.map +1 -1
  17. package/dist/src/commands/whoami.js +2 -0
  18. package/dist/src/commands/whoami.js.map +1 -1
  19. package/dist/src/lib/api-client.d.ts +10 -4
  20. package/dist/src/lib/api-client.d.ts.map +1 -1
  21. package/dist/src/lib/api-client.js +11 -7
  22. package/dist/src/lib/api-client.js.map +1 -1
  23. package/dist/src/lib/env-sync.d.ts +13 -5
  24. package/dist/src/lib/env-sync.d.ts.map +1 -1
  25. package/dist/src/lib/env-sync.js +120 -31
  26. package/dist/src/lib/env-sync.js.map +1 -1
  27. package/dist/src/lib/nx-generator.d.ts +5 -3
  28. package/dist/src/lib/nx-generator.d.ts.map +1 -1
  29. package/dist/src/lib/nx-generator.js +37 -30
  30. package/dist/src/lib/nx-generator.js.map +1 -1
  31. package/dist/src/utils/spinner.d.ts +12 -0
  32. package/dist/src/utils/spinner.d.ts.map +1 -1
  33. package/dist/src/utils/spinner.js +68 -0
  34. package/dist/src/utils/spinner.js.map +1 -1
  35. package/dist/templates/application-additions/src/app/app.module.ts +6 -6
  36. package/dist/templates/application-additions/src/services/index.ts +1 -1
  37. package/dist/templates/application-additions/src/services/product-event-listener.service.ts +150 -0
  38. package/dist/templates/application-e2e-additions/src/support/global-setup.ts +16 -0
  39. package/dist/templates/application-e2e-additions/src/support/test-setup.ts +9 -0
  40. package/dist/templates/storefront-additions/postcss.config.js +5 -0
  41. package/dist/templates/storefront-additions/src/app/cart/page.tsx +77 -0
  42. package/dist/templates/storefront-additions/src/app/global.css +1 -0
  43. package/dist/templates/storefront-additions/src/app/layout.tsx +25 -0
  44. package/dist/templates/storefront-additions/src/app/page.tsx +43 -0
  45. package/dist/templates/storefront-additions/src/components/AddToCartButton.tsx +29 -0
  46. package/dist/templates/storefront-additions/src/components/CartItem.tsx +82 -0
  47. package/dist/templates/storefront-additions/src/components/Header.tsx +42 -0
  48. package/dist/templates/storefront-additions/src/components/ProductCard.tsx +57 -0
  49. package/dist/templates/storefront-additions/src/components/QuantitySelector.tsx +35 -0
  50. package/dist/templates/storefront-additions/src/context/CartContext.tsx +158 -0
  51. package/dist/templates/storefront-additions/tailwind.config.js +8 -0
  52. package/dist/templates/storefront-e2e-additions/playwright.config.ts +26 -0
  53. package/dist/templates/storefront-e2e-additions/src/example.spec.ts +11 -0
  54. package/package.json +2 -2
  55. package/templates/application-additions/src/app/app.module.ts +6 -6
  56. package/templates/application-additions/src/services/index.ts +1 -1
  57. package/templates/application-additions/src/services/product-event-listener.service.ts +150 -0
  58. package/templates/application-e2e-additions/src/support/global-setup.ts +16 -0
  59. package/templates/application-e2e-additions/src/support/test-setup.ts +9 -0
  60. package/templates/storefront-additions/postcss.config.js +5 -0
  61. package/templates/storefront-additions/src/app/cart/page.tsx +77 -0
  62. package/templates/storefront-additions/src/app/global.css +1 -0
  63. package/templates/storefront-additions/src/app/layout.tsx +25 -0
  64. package/templates/storefront-additions/src/app/page.tsx +43 -0
  65. package/templates/storefront-additions/src/components/AddToCartButton.tsx +29 -0
  66. package/templates/storefront-additions/src/components/CartItem.tsx +82 -0
  67. package/templates/storefront-additions/src/components/Header.tsx +42 -0
  68. package/templates/storefront-additions/src/components/ProductCard.tsx +57 -0
  69. package/templates/storefront-additions/src/components/QuantitySelector.tsx +35 -0
  70. package/templates/storefront-additions/src/context/CartContext.tsx +158 -0
  71. package/templates/storefront-additions/tailwind.config.js +8 -0
  72. package/templates/storefront-e2e-additions/playwright.config.ts +26 -0
  73. package/templates/storefront-e2e-additions/src/example.spec.ts +11 -0
  74. package/dist/templates/application-additions/env.example +0 -7
  75. package/dist/templates/application-additions/src/services/price-fetcher.service.ts +0 -71
  76. package/dist/templates/storefront-additions/env.example +0 -3
  77. package/dist/templates/storefront-additions/src/app/products/page.tsx +0 -66
  78. package/templates/application-additions/env.example +0 -7
  79. package/templates/application-additions/src/services/price-fetcher.service.ts +0 -71
  80. package/templates/storefront-additions/env.example +0 -3
  81. package/templates/storefront-additions/src/app/products/page.tsx +0 -66
@@ -1,3 +1,4 @@
1
+ import chalk from 'chalk';
1
2
  import ora from 'ora';
2
3
  export function createSpinner(text) {
3
4
  return ora({
@@ -17,4 +18,71 @@ export async function withSpinner(text, successText, failText, fn) {
17
18
  throw error;
18
19
  }
19
20
  }
21
+ /**
22
+ * Displays a spinner with inline log streaming below it.
23
+ * Logs are displayed as they arrive from the subscription.
24
+ */
25
+ export async function withSpinnerAndLogs(text, successText, failText, fn, logStream, onCancel) {
26
+ const spinner = createSpinner(text).start();
27
+ let cancelled = false;
28
+ if (logStream) {
29
+ ;
30
+ (async () => {
31
+ try {
32
+ for await (const result of logStream) {
33
+ if (cancelled)
34
+ break;
35
+ const log = result.deploymentLogs;
36
+ if (!log?.message)
37
+ continue;
38
+ let displayMessage = log.message;
39
+ try {
40
+ const parsed = JSON.parse(log.message);
41
+ if ('Message' in parsed) {
42
+ displayMessage = parsed.Message?.trim() || '';
43
+ }
44
+ }
45
+ catch {
46
+ // Not JSON, use as-is
47
+ }
48
+ if (!displayMessage)
49
+ continue;
50
+ const timestamp = new Date(log.timestamp).toLocaleTimeString('en-US', {
51
+ hour12: false,
52
+ hour: '2-digit',
53
+ minute: '2-digit',
54
+ second: '2-digit',
55
+ });
56
+ spinner.clear();
57
+ console.log(` ${chalk.gray(timestamp)} ${displayMessage}`);
58
+ spinner.render();
59
+ }
60
+ }
61
+ catch {
62
+ // Stream ended
63
+ }
64
+ })();
65
+ }
66
+ try {
67
+ const result = await fn();
68
+ cancelled = true;
69
+ // Close the async iterator to stop the WebSocket connection
70
+ if (logStream?.return) {
71
+ logStream.return(undefined);
72
+ }
73
+ onCancel?.();
74
+ spinner.succeed(successText);
75
+ return result;
76
+ }
77
+ catch (error) {
78
+ cancelled = true;
79
+ // Close the async iterator to stop the WebSocket connection
80
+ if (logStream?.return) {
81
+ logStream.return(undefined);
82
+ }
83
+ onCancel?.();
84
+ spinner.fail(failText);
85
+ throw error;
86
+ }
87
+ }
20
88
  //# sourceMappingURL=spinner.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"spinner.js","sourceRoot":"","sources":["../../../src/utils/spinner.ts"],"names":[],"mappings":"AAAA,OAAO,GAAY,MAAM,KAAK,CAAA;AAE9B,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,GAAG,CAAC;QACT,IAAI;QACJ,OAAO,EAAE,MAAM;KAChB,CAAC,CAAA;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAY,EACZ,WAAmB,EACnB,QAAgB,EAChB,EAAoB;IAEpB,MAAM,OAAO,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAA;IAE3C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,EAAE,EAAE,CAAA;QACzB,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;QAC5B,OAAO,MAAM,CAAA;IACf,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QACtB,MAAM,KAAK,CAAA;IACb,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"spinner.js","sourceRoot":"","sources":["../../../src/utils/spinner.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,8 @@
1
- import { Module } from '@nestjs/common';
2
- import { ConfigModule } from '@nestjs/config';
3
- import { AppController } from './app.controller';
4
- import { AppService } from './app.service';
5
- import { ForgeCartService, PriceFetcherService } from '../services';
1
+ import { Module } from '@nestjs/common'
2
+ import { ConfigModule } from '@nestjs/config'
3
+ import { AppController } from './app.controller'
4
+ import { AppService } from './app.service'
5
+ import { ForgeCartService, ProductEventListenerService } from '../services'
6
6
 
7
7
  @Module({
8
8
  imports: [
@@ -11,6 +11,6 @@ import { ForgeCartService, PriceFetcherService } from '../services';
11
11
  }),
12
12
  ],
13
13
  controllers: [AppController],
14
- providers: [AppService, ForgeCartService, PriceFetcherService],
14
+ providers: [AppService, ForgeCartService, ProductEventListenerService],
15
15
  })
16
16
  export class AppModule {}
@@ -1,2 +1,2 @@
1
1
  export { ForgeCartService } from './forgecart.service'
2
- export { PriceFetcherService } from './price-fetcher.service'
2
+ export { ProductEventListenerService } from './product-event-listener.service'
@@ -0,0 +1,150 @@
1
+ import {
2
+ Injectable,
3
+ OnModuleInit,
4
+ OnModuleDestroy,
5
+ Logger,
6
+ } from '@nestjs/common'
7
+ import { ConfigService } from '@nestjs/config'
8
+ import { ForgeCartService } from './forgecart.service'
9
+
10
+ interface EventSubscription {
11
+ unsubscribe: () => void
12
+ }
13
+
14
+ @Injectable()
15
+ export class ProductEventListenerService
16
+ implements OnModuleInit, OnModuleDestroy
17
+ {
18
+ private readonly logger = new Logger(ProductEventListenerService.name)
19
+ private subscriptions: EventSubscription[] = []
20
+ private isAuthenticated = false
21
+
22
+ constructor(
23
+ private readonly forgeCart: ForgeCartService,
24
+ private readonly configService: ConfigService
25
+ ) {}
26
+
27
+ async onModuleInit(): Promise<void> {
28
+ await this.initialize()
29
+ }
30
+
31
+ onModuleDestroy(): void {
32
+ this.cleanup()
33
+ }
34
+
35
+ private async initialize(): Promise<void> {
36
+ const username = this.configService.get<string>('FORGECART_ADMIN_USERNAME')
37
+ const password = this.configService.get<string>('FORGECART_ADMIN_PASSWORD')
38
+
39
+ // Skip if credentials are not provided
40
+ if (!username || !password) {
41
+ this.logger.warn(
42
+ 'FORGECART_ADMIN_USERNAME and FORGECART_ADMIN_PASSWORD not set. ' +
43
+ 'Product event listeners will not be registered.'
44
+ )
45
+ return
46
+ }
47
+
48
+ try {
49
+ // Login with admin credentials
50
+ const loginResult = await this.forgeCart.admin.administrator.login({
51
+ username,
52
+ password,
53
+ })
54
+
55
+ const login = (
56
+ loginResult as { login?: { __typename?: string; message?: string } }
57
+ )?.login
58
+ if (login?.__typename !== 'CurrentUser') {
59
+ this.logger.error(
60
+ 'Admin login failed:',
61
+ login?.message || 'Unknown error'
62
+ )
63
+ return
64
+ }
65
+
66
+ this.isAuthenticated = true
67
+ this.logger.log('Successfully authenticated as admin')
68
+
69
+ // Register event handlers
70
+ this.registerEventHandlers()
71
+ } catch (error) {
72
+ this.logger.error('Failed to initialize product event listener:', error)
73
+ }
74
+ }
75
+
76
+ private registerEventHandlers(): void {
77
+ if (!this.isAuthenticated) return
78
+
79
+ const sdk = this.forgeCart.getSDK()
80
+
81
+ // Non-blocking handlers
82
+ const productUpdatedSub = sdk.admin.registerEventHandler(
83
+ 'product.updated',
84
+ (event) => {
85
+ this.logger.log(`Product updated: ${JSON.stringify(event)}`)
86
+ this.handleProductUpdated(event)
87
+ }
88
+ )
89
+ this.subscriptions.push(productUpdatedSub)
90
+
91
+ const productCreatedSub = sdk.admin.registerEventHandler(
92
+ 'product.created',
93
+ (event) => {
94
+ this.logger.log(`Product created: ${JSON.stringify(event)}`)
95
+ this.handleProductCreated(event)
96
+ }
97
+ )
98
+ this.subscriptions.push(productCreatedSub)
99
+
100
+ const productDeletedSub = sdk.admin.registerEventHandler(
101
+ 'product.deleted',
102
+ (event) => {
103
+ this.logger.log(`Product deleted: ${JSON.stringify(event)}`)
104
+ this.handleProductDeleted(event)
105
+ }
106
+ )
107
+ this.subscriptions.push(productDeletedSub)
108
+
109
+ // Blocking handler (must complete within 1000ms)
110
+ const variantUpdatedSub = sdk.admin.registerBlockingEventHandler(
111
+ 'product.variant.updated',
112
+ async (event) => {
113
+ this.logger.log(
114
+ `Product variant updated (blocking): ${JSON.stringify(event)}`
115
+ )
116
+ await this.handleVariantUpdated(event)
117
+ }
118
+ )
119
+ this.subscriptions.push(variantUpdatedSub)
120
+
121
+ this.logger.log('Product event handlers registered successfully')
122
+ }
123
+
124
+ private handleProductUpdated(event: unknown): void {
125
+ // Implement custom logic here
126
+ this.logger.debug('handleProductUpdated called', event)
127
+ }
128
+
129
+ private handleProductCreated(event: unknown): void {
130
+ // Implement custom logic here
131
+ this.logger.debug('handleProductCreated called', event)
132
+ }
133
+
134
+ private handleProductDeleted(event: unknown): void {
135
+ // Implement custom logic here
136
+ this.logger.debug('handleProductDeleted called', event)
137
+ }
138
+
139
+ private async handleVariantUpdated(event: unknown): Promise<void> {
140
+ // Blocking handler - must complete within 1000ms
141
+ this.logger.debug('handleVariantUpdated called', event)
142
+ }
143
+
144
+ private cleanup(): void {
145
+ for (const subscription of this.subscriptions) {
146
+ subscription.unsubscribe()
147
+ }
148
+ this.subscriptions = []
149
+ }
150
+ }
@@ -0,0 +1,16 @@
1
+ import { waitForPortOpen } from '@nx/node/utils'
2
+
3
+ /* eslint-disable */
4
+ var __TEARDOWN_MESSAGE__: string
5
+
6
+ module.exports = async function () {
7
+ // Start services that the app needs to run (e.g. database, docker-compose, etc.).
8
+ console.log('\nSetting up...\n')
9
+
10
+ const host = process.env.HOST ?? 'localhost'
11
+ const port = process.env.PORT ? Number(process.env.PORT) : 8080 // Match application's default port
12
+ await waitForPortOpen(port, { host })
13
+
14
+ // Hint: Use `globalThis` to pass variables to global teardown.
15
+ globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'
16
+ }
@@ -0,0 +1,9 @@
1
+ /* eslint-disable */
2
+ import axios from 'axios'
3
+
4
+ module.exports = async function () {
5
+ // Configure axios for tests to use.
6
+ const host = process.env.HOST ?? 'localhost'
7
+ const port = process.env.PORT ?? '8080' // Match application's default port
8
+ axios.defaults.baseURL = `http://${host}:${port}`
9
+ }
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ }
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ import Link from 'next/link'
4
+ import { useCart } from '../../context/CartContext'
5
+ import { CartItem } from '../../components/CartItem'
6
+
7
+ export default function CartPage() {
8
+ const { cart, loading, itemCount } = useCart()
9
+
10
+ if (loading && !cart) {
11
+ return (
12
+ <div className="max-w-4xl mx-auto px-4 py-8">
13
+ <h1 className="text-3xl font-bold mb-8 text-gray-900">Your Cart</h1>
14
+ <p className="text-gray-600">Loading cart...</p>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ if (!cart || cart.lines.length === 0) {
20
+ return (
21
+ <div className="max-w-4xl mx-auto px-4 py-8">
22
+ <h1 className="text-3xl font-bold mb-8 text-gray-900">Your Cart</h1>
23
+ <p className="text-gray-600 mb-4">Your cart is empty.</p>
24
+ <Link
25
+ href="/"
26
+ className="inline-block bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700"
27
+ >
28
+ Continue Shopping
29
+ </Link>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ const subtotal = (cart.subTotalWithTax / 100).toFixed(2)
35
+ const total = (cart.totalWithTax / 100).toFixed(2)
36
+
37
+ return (
38
+ <div className="max-w-4xl mx-auto px-4 py-8">
39
+ <h1 className="text-3xl font-bold mb-8 text-gray-900">Your Cart</h1>
40
+
41
+ <div className="bg-white rounded-lg shadow p-6">
42
+ {cart.lines
43
+ .filter((line) => line.productVariant)
44
+ .map((line) => (
45
+ <CartItem key={line.id} line={line as never} />
46
+ ))}
47
+
48
+ <div className="mt-6 pt-4 border-t">
49
+ <div className="flex justify-between text-lg mb-2">
50
+ <span className="text-gray-600">Subtotal ({itemCount} items)</span>
51
+ <span className="font-semibold">
52
+ {subtotal} {cart.currencyCode}
53
+ </span>
54
+ </div>
55
+ <div className="flex justify-between text-xl font-bold">
56
+ <span>Total</span>
57
+ <span>
58
+ {total} {cart.currencyCode}
59
+ </span>
60
+ </div>
61
+ </div>
62
+
63
+ <div className="mt-6 flex gap-4">
64
+ <Link
65
+ href="/"
66
+ className="flex-1 text-center border border-gray-300 py-3 rounded-lg hover:bg-gray-50"
67
+ >
68
+ Continue Shopping
69
+ </Link>
70
+ <button className="flex-1 bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700">
71
+ Checkout
72
+ </button>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1 @@
1
+ @import "tailwindcss";
@@ -0,0 +1,25 @@
1
+ import './global.css'
2
+ import { CartProvider } from '../context/CartContext'
3
+ import { Header } from '../components/Header'
4
+
5
+ export const metadata = {
6
+ title: 'Store',
7
+ description: 'Your ecommerce store',
8
+ }
9
+
10
+ export default function RootLayout({
11
+ children,
12
+ }: {
13
+ children: React.ReactNode
14
+ }) {
15
+ return (
16
+ <html lang="en">
17
+ <body className="bg-gray-50 min-h-screen">
18
+ <CartProvider>
19
+ <Header />
20
+ <main>{children}</main>
21
+ </CartProvider>
22
+ </body>
23
+ </html>
24
+ )
25
+ }
@@ -0,0 +1,43 @@
1
+ import { ProductList, Product } from '@forgecart/sdk/shop'
2
+ import forgecart from '../lib/forgecart'
3
+ import { ProductCard } from '../components/ProductCard'
4
+
5
+ async function getProducts(): Promise<ProductList> {
6
+ try {
7
+ const result = await forgecart.shop.products.products({
8
+ options: {
9
+ take: 20,
10
+ },
11
+ })
12
+ return (result.products as ProductList) || { items: [], totalItems: 0 }
13
+ } catch (error) {
14
+ console.error('Failed to fetch products:', error)
15
+ return {
16
+ items: [] as Product[],
17
+ totalItems: 0,
18
+ }
19
+ }
20
+ }
21
+
22
+ export default async function HomePage() {
23
+ const products = await getProducts()
24
+
25
+ return (
26
+ <div className="max-w-7xl mx-auto px-4 py-8">
27
+ <h1 className="text-3xl font-bold mb-8 text-gray-900">Our Products</h1>
28
+
29
+ {products.items.length === 0 ? (
30
+ <p className="text-gray-600">
31
+ No products found. Make sure your ForgeCart channel is configured
32
+ correctly.
33
+ </p>
34
+ ) : (
35
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
36
+ {products.items.map((product: Product) => (
37
+ <ProductCard key={product.id} product={product as never} />
38
+ ))}
39
+ </div>
40
+ )}
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,29 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useCart } from '../context/CartContext'
5
+
6
+ interface AddToCartButtonProps {
7
+ productVariantId: string
8
+ }
9
+
10
+ export function AddToCartButton({ productVariantId }: AddToCartButtonProps) {
11
+ const { addToCart } = useCart()
12
+ const [isAdding, setIsAdding] = useState(false)
13
+
14
+ const handleClick = async () => {
15
+ setIsAdding(true)
16
+ await addToCart(productVariantId, 1)
17
+ setIsAdding(false)
18
+ }
19
+
20
+ return (
21
+ <button
22
+ onClick={handleClick}
23
+ disabled={isAdding}
24
+ className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed transition-colors"
25
+ >
26
+ {isAdding ? 'Adding...' : 'Add to Cart'}
27
+ </button>
28
+ )
29
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ import { useCart } from '../context/CartContext'
4
+ import { QuantitySelector } from './QuantitySelector'
5
+
6
+ interface CartLine {
7
+ id: string
8
+ quantity: number
9
+ productVariant: {
10
+ id: string
11
+ name: string
12
+ price: number
13
+ priceWithTax: number
14
+ currencyCode: string
15
+ }
16
+ featuredAsset?: {
17
+ preview: string
18
+ }
19
+ unitPriceWithTax: number
20
+ linePriceWithTax: number
21
+ }
22
+
23
+ interface CartItemProps {
24
+ line: CartLine
25
+ }
26
+
27
+ export function CartItem({ line }: CartItemProps) {
28
+ const { updateQuantity, removeItem, loading } = useCart()
29
+
30
+ // Defensive check for incomplete line data
31
+ if (!line.productVariant) {
32
+ return null
33
+ }
34
+
35
+ const unitPrice = (line.unitPriceWithTax / 100).toFixed(2)
36
+ const lineTotal = (line.linePriceWithTax / 100).toFixed(2)
37
+
38
+ return (
39
+ <div className="flex gap-4 py-4 border-b">
40
+ {line.featuredAsset && (
41
+ <img
42
+ src={line.featuredAsset.preview}
43
+ alt={line.productVariant.name}
44
+ className="w-20 h-20 object-cover rounded"
45
+ />
46
+ )}
47
+ {!line.featuredAsset && (
48
+ <div className="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
49
+ <span className="text-gray-400 text-xs">No img</span>
50
+ </div>
51
+ )}
52
+ <div className="flex-1">
53
+ <h3 className="font-semibold text-gray-900">
54
+ {line.productVariant.name}
55
+ </h3>
56
+ <p className="text-gray-600 text-sm">
57
+ {unitPrice} {line.productVariant.currencyCode} each
58
+ </p>
59
+ <div className="flex items-center justify-between mt-2">
60
+ <QuantitySelector
61
+ quantity={line.quantity}
62
+ onIncrease={() => updateQuantity(line.id, line.quantity + 1)}
63
+ onDecrease={() => updateQuantity(line.id, line.quantity - 1)}
64
+ disabled={loading}
65
+ />
66
+ <button
67
+ onClick={() => removeItem(line.id)}
68
+ disabled={loading}
69
+ className="text-red-600 hover:text-red-800 text-sm disabled:opacity-50"
70
+ >
71
+ Remove
72
+ </button>
73
+ </div>
74
+ </div>
75
+ <div className="text-right">
76
+ <p className="font-bold text-gray-900">
77
+ {lineTotal} {line.productVariant.currencyCode}
78
+ </p>
79
+ </div>
80
+ </div>
81
+ )
82
+ }
@@ -0,0 +1,42 @@
1
+ 'use client'
2
+
3
+ import Link from 'next/link'
4
+ import { useCart } from '../context/CartContext'
5
+
6
+ export function Header() {
7
+ const { itemCount, loading } = useCart()
8
+
9
+ return (
10
+ <header className="bg-white shadow-sm border-b">
11
+ <div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
12
+ <Link href="/" className="text-xl font-bold text-gray-900">
13
+ Store
14
+ </Link>
15
+ <Link
16
+ href="/cart"
17
+ className="relative flex items-center gap-2 text-gray-700 hover:text-gray-900"
18
+ >
19
+ <svg
20
+ className="w-6 h-6"
21
+ fill="none"
22
+ stroke="currentColor"
23
+ viewBox="0 0 24 24"
24
+ >
25
+ <path
26
+ strokeLinecap="round"
27
+ strokeLinejoin="round"
28
+ strokeWidth={2}
29
+ d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
30
+ />
31
+ </svg>
32
+ <span>Cart</span>
33
+ {itemCount > 0 && (
34
+ <span className="absolute -top-2 -right-2 bg-blue-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
35
+ {loading ? '...' : itemCount}
36
+ </span>
37
+ )}
38
+ </Link>
39
+ </div>
40
+ </header>
41
+ )
42
+ }
@@ -0,0 +1,57 @@
1
+ import { AddToCartButton } from './AddToCartButton'
2
+
3
+ interface ProductVariant {
4
+ id: string
5
+ price: number
6
+ priceWithTax: number
7
+ currencyCode: string
8
+ }
9
+
10
+ interface Product {
11
+ id: string
12
+ name: string
13
+ description: string
14
+ featuredAsset?: {
15
+ preview: string
16
+ }
17
+ variants: ProductVariant[]
18
+ }
19
+
20
+ interface ProductCardProps {
21
+ product: Product
22
+ }
23
+
24
+ export function ProductCard({ product }: ProductCardProps) {
25
+ const variant = product.variants[0]
26
+ const price = variant ? (variant.priceWithTax / 100).toFixed(2) : '0.00'
27
+ const currencyCode = variant?.currencyCode ?? 'USD'
28
+
29
+ return (
30
+ <div className="bg-white border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow">
31
+ {product.featuredAsset && (
32
+ <img
33
+ src={product.featuredAsset.preview}
34
+ alt={product.name}
35
+ className="w-full h-48 object-cover"
36
+ />
37
+ )}
38
+ {!product.featuredAsset && (
39
+ <div className="w-full h-48 bg-gray-200 flex items-center justify-center">
40
+ <span className="text-gray-400">No image</span>
41
+ </div>
42
+ )}
43
+ <div className="p-4">
44
+ <h2 className="text-lg font-semibold mb-2 text-gray-900">
45
+ {product.name}
46
+ </h2>
47
+ <p className="text-gray-600 text-sm mb-3 line-clamp-2">
48
+ {product.description}
49
+ </p>
50
+ <p className="text-lg font-bold text-blue-600 mb-4">
51
+ {price} {currencyCode}
52
+ </p>
53
+ {variant && <AddToCartButton productVariantId={variant.id} />}
54
+ </div>
55
+ </div>
56
+ )
57
+ }