omni-sync-sdk 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -260,6 +260,111 @@ export function isLoggedIn(): boolean {
260
260
 
261
261
  ---
262
262
 
263
+ ## Important: Cart & Checkout Data Structures
264
+
265
+ ### Nested Product/Variant Structure
266
+
267
+ Cart and Checkout items use a **nested structure** for product and variant data. This is a common pattern that prevents data duplication and ensures consistency.
268
+
269
+ **Common Mistake:**
270
+
271
+ ```typescript
272
+ // WRONG - product name is NOT at top level
273
+ const name = item.name; // undefined!
274
+ const sku = item.sku; // undefined!
275
+ ```
276
+
277
+ **Correct Access Pattern:**
278
+
279
+ ```typescript
280
+ // CORRECT - access via nested objects
281
+ const name = item.product.name;
282
+ const sku = item.product.sku;
283
+ const variantName = item.variant?.name;
284
+ const variantSku = item.variant?.sku;
285
+ ```
286
+
287
+ ### Field Mapping Reference
288
+
289
+ | What You Want | CartItem | CheckoutLineItem |
290
+ | -------------- | ------------------------- | ------------------------- |
291
+ | Product Name | `item.product.name` | `item.product.name` |
292
+ | Product SKU | `item.product.sku` | `item.product.sku` |
293
+ | Product ID | `item.productId` | `item.productId` |
294
+ | Product Images | `item.product.images` | `item.product.images` |
295
+ | Variant Name | `item.variant?.name` | `item.variant?.name` |
296
+ | Variant SKU | `item.variant?.sku` | `item.variant?.sku` |
297
+ | Variant ID | `item.variantId` | `item.variantId` |
298
+ | Unit Price | `item.unitPrice` (string) | `item.unitPrice` (string) |
299
+ | Quantity | `item.quantity` | `item.quantity` |
300
+
301
+ ### Price Fields Are Strings
302
+
303
+ All monetary values in Cart and Checkout are returned as **strings** (e.g., `"29.99"`) to preserve decimal precision across different systems. Use `parseFloat()` or the `formatPrice()` helper:
304
+
305
+ ```typescript
306
+ // Monetary fields that are strings:
307
+ // - CartItem: unitPrice, discountAmount
308
+ // - Cart: subtotal, discountAmount
309
+ // - CheckoutLineItem: unitPrice, discountAmount
310
+ // - Checkout: subtotal, discountAmount, shippingAmount, taxAmount, total
311
+ // - ShippingRate: price
312
+
313
+ import { formatPrice } from 'omni-sync-sdk';
314
+
315
+ // Option 1: Using formatPrice helper (recommended)
316
+ const cart = await omni.getCart(cartId);
317
+ const total = formatPrice(cart.subtotal); // "$59.98"
318
+ const totalNum = formatPrice(cart.subtotal, { asNumber: true }); // 59.98
319
+
320
+ // Option 2: Manual parseFloat
321
+ const subtotal = parseFloat(cart.subtotal);
322
+ const discount = parseFloat(cart.discountAmount);
323
+ const total = subtotal - discount;
324
+
325
+ // Line item total
326
+ cart.items.forEach((item) => {
327
+ const lineTotal = parseFloat(item.unitPrice) * item.quantity;
328
+ console.log(`${item.product.name}: $${lineTotal.toFixed(2)}`);
329
+ });
330
+ ```
331
+
332
+ ### Complete Cart Item Display Example
333
+
334
+ ```typescript
335
+ import type { CartItem } from 'omni-sync-sdk';
336
+ import { formatPrice } from 'omni-sync-sdk';
337
+
338
+ function CartItemRow({ item }: { item: CartItem }) {
339
+ // Access nested product data
340
+ const productName = item.product.name;
341
+ const productSku = item.product.sku;
342
+ const productImage = item.product.images?.[0]?.url;
343
+
344
+ // Access nested variant data (if exists)
345
+ const variantName = item.variant?.name;
346
+ const displayName = variantName ? `${productName} - ${variantName}` : productName;
347
+
348
+ // Format price using helper
349
+ const unitPrice = formatPrice(item.unitPrice);
350
+ const lineTotal = formatPrice(item.unitPrice, { asNumber: true }) * item.quantity;
351
+
352
+ return (
353
+ <div className="flex items-center gap-4">
354
+ <img src={productImage} alt={displayName} className="w-16 h-16 object-cover" />
355
+ <div className="flex-1">
356
+ <h3 className="font-medium">{displayName}</h3>
357
+ <p className="text-sm text-gray-500">SKU: {item.variant?.sku || productSku}</p>
358
+ </div>
359
+ <span className="text-gray-600">Qty: {item.quantity}</span>
360
+ <span className="font-medium">${lineTotal.toFixed(2)}</span>
361
+ </div>
362
+ );
363
+ }
364
+ ```
365
+
366
+ ---
367
+
263
368
  ## API Reference
264
369
 
265
370
  ### Products
@@ -473,19 +578,36 @@ function ProductPrice({ product }: { product: Product }) {
473
578
 
474
579
  #### Rendering Product Descriptions
475
580
 
476
- **IMPORTANT**: Product descriptions may contain HTML (from Shopify/WooCommerce) or plain text. Always check `descriptionFormat` before rendering:
581
+ > **CRITICAL**: Product descriptions from Shopify/WooCommerce contain HTML tags. If you render them as plain text, users will see raw `<p>`, `<ul>`, `<li>` tags instead of formatted content!
582
+
583
+ Use the SDK helper functions to handle this automatically:
477
584
 
478
585
  ```tsx
479
- // Correct way to render product descriptions
480
- {
481
- product.description &&
482
- (product.descriptionFormat === 'html' ? (
483
- // HTML content from Shopify/WooCommerce - render as HTML
484
- <div dangerouslySetInnerHTML={{ __html: product.description }} />
485
- ) : (
486
- // Plain text - render normally
487
- <p>{product.description}</p>
488
- ));
586
+ import { isHtmlDescription, getDescriptionContent } from 'omni-sync-sdk';
587
+
588
+ // Option 1: Using isHtmlDescription helper (recommended)
589
+ function ProductDescription({ product }: { product: Product }) {
590
+ if (!product.description) return null;
591
+
592
+ if (isHtmlDescription(product)) {
593
+ // HTML from Shopify/WooCommerce - MUST use dangerouslySetInnerHTML
594
+ return <div dangerouslySetInnerHTML={{ __html: product.description }} />;
595
+ }
596
+
597
+ // Plain text - render normally
598
+ return <p>{product.description}</p>;
599
+ }
600
+
601
+ // Option 2: Using getDescriptionContent helper
602
+ function ProductDescription({ product }: { product: Product }) {
603
+ const content = getDescriptionContent(product);
604
+ if (!content) return null;
605
+
606
+ if ('html' in content) {
607
+ return <div dangerouslySetInnerHTML={{ __html: content.html }} />;
608
+ }
609
+
610
+ return <p>{content.text}</p>;
489
611
  }
490
612
  ```
491
613
 
@@ -496,6 +618,13 @@ function ProductPrice({ product }: { product: Product }) {
496
618
  | TikTok | `'text'` | Render as plain text |
497
619
  | Manual entry | `'text'` | Render as plain text |
498
620
 
621
+ **Common Mistake** - DO NOT do this:
622
+
623
+ ```tsx
624
+ // WRONG - HTML will show as raw tags like <p>Hello</p>
625
+ <p>{product.description}</p>
626
+ ```
627
+
499
628
  ---
500
629
 
501
630
  ### Local Cart (Guest Users) - RECOMMENDED
@@ -988,6 +1117,98 @@ interface ShippingRate {
988
1117
  }
989
1118
  ```
990
1119
 
1120
+ #### Shipping Rates: Complete Flow
1121
+
1122
+ The shipping flow involves setting an address and then selecting from available rates:
1123
+
1124
+ ```typescript
1125
+ // Step 1: Set shipping address - this returns available rates
1126
+ const { checkout, rates } = await omni.setShippingAddress(checkoutId, {
1127
+ firstName: 'John',
1128
+ lastName: 'Doe',
1129
+ line1: '123 Main St',
1130
+ city: 'New York',
1131
+ region: 'NY',
1132
+ postalCode: '10001',
1133
+ country: 'US',
1134
+ });
1135
+
1136
+ // Step 2: Handle empty rates (edge case)
1137
+ if (rates.length === 0) {
1138
+ // No shipping options available for this address
1139
+ // This can happen when:
1140
+ // - Store doesn't ship to this address/country
1141
+ // - All shipping methods have restrictions that exclude this address
1142
+ // - Shipping rates haven't been configured in the store
1143
+
1144
+ return (
1145
+ <div className="bg-yellow-50 p-4 rounded">
1146
+ <p className="font-medium">No shipping options available</p>
1147
+ <p className="text-sm text-gray-600">
1148
+ We currently cannot ship to this address. Please try a different address or contact us for
1149
+ assistance.
1150
+ </p>
1151
+ </div>
1152
+ );
1153
+ }
1154
+
1155
+ // Step 3: Display available rates to customer
1156
+ <div className="space-y-2">
1157
+ <h3 className="font-medium">Select Shipping Method</h3>
1158
+ {rates.map((rate) => (
1159
+ <label key={rate.id} className="flex items-center gap-3 p-3 border rounded cursor-pointer">
1160
+ <input
1161
+ type="radio"
1162
+ name="shipping"
1163
+ value={rate.id}
1164
+ checked={selectedRateId === rate.id}
1165
+ onChange={() => setSelectedRateId(rate.id)}
1166
+ />
1167
+ <div className="flex-1">
1168
+ <span className="font-medium">{rate.name}</span>
1169
+ {rate.description && <p className="text-sm text-gray-500">{rate.description}</p>}
1170
+ {rate.estimatedDays && (
1171
+ <p className="text-sm text-gray-500">Estimated delivery: {rate.estimatedDays} business days</p>
1172
+ )}
1173
+ </div>
1174
+ <span className="font-medium">${parseFloat(rate.price).toFixed(2)}</span>
1175
+ </label>
1176
+ ))}
1177
+ </div>;
1178
+
1179
+ // Step 4: Select the shipping method
1180
+ await omni.selectShippingMethod(checkoutId, selectedRateId);
1181
+ ```
1182
+
1183
+ **Handling Empty Shipping Rates:**
1184
+
1185
+ When no shipping rates are available, you have several options:
1186
+
1187
+ ```typescript
1188
+ // Option 1: Show helpful message
1189
+ if (rates.length === 0) {
1190
+ return <NoShippingAvailable address={shippingAddress} />;
1191
+ }
1192
+
1193
+ // Option 2: Allow customer to contact store
1194
+ if (rates.length === 0) {
1195
+ return (
1196
+ <div>
1197
+ <p>Shipping not available to your location.</p>
1198
+ <a href="/contact">Request a shipping quote</a>
1199
+ </div>
1200
+ );
1201
+ }
1202
+
1203
+ // Option 3: Validate before proceeding
1204
+ function canProceedToPayment(checkout: Checkout, rates: ShippingRate[]): boolean {
1205
+ if (rates.length === 0) return false;
1206
+ if (!checkout.shippingRateId) return false;
1207
+ if (!checkout.email) return false;
1208
+ return true;
1209
+ }
1210
+ ```
1211
+
991
1212
  ---
992
1213
 
993
1214
  ### Customer Authentication
@@ -1726,6 +1947,7 @@ export default function ProductsPage() {
1726
1947
  'use client';
1727
1948
  import { useEffect, useState } from 'react';
1728
1949
  import { omni } from '@/lib/omni-sync';
1950
+ import { isHtmlDescription } from 'omni-sync-sdk';
1729
1951
  import type { Product } from 'omni-sync-sdk';
1730
1952
 
1731
1953
  export default function ProductPage({ params }: { params: { id: string } }) {
@@ -1791,9 +2013,9 @@ export default function ProductPage({ params }: { params: { id: string } }) {
1791
2013
  ${product.salePrice || product.basePrice}
1792
2014
  </p>
1793
2015
 
1794
- {/* Render description based on format (HTML from Shopify/WooCommerce, text otherwise) */}
2016
+ {/* IMPORTANT: Use isHtmlDescription() to render HTML descriptions correctly */}
1795
2017
  {product.description && (
1796
- product.descriptionFormat === 'html' ? (
2018
+ isHtmlDescription(product) ? (
1797
2019
  <div className="mt-4 text-gray-600" dangerouslySetInnerHTML={{ __html: product.description }} />
1798
2020
  ) : (
1799
2021
  <p className="mt-4 text-gray-600">{product.description}</p>
@@ -3010,7 +3232,7 @@ const handlePlaceOrder = () => {
3010
3232
  - **Use toast notifications (Sonner) for user feedback on actions**
3011
3233
  - Persist cart ID in localStorage
3012
3234
  - Persist customer token after login
3013
- - **Check `descriptionFormat` and render HTML with `dangerouslySetInnerHTML` when format is `'html'`**
3235
+ - **Use `isHtmlDescription(product)` helper and render HTML with `dangerouslySetInnerHTML` when it returns true**
3014
3236
  - **Wrap SDK calls in try/catch and show error toasts**
3015
3237
 
3016
3238
  ### DON'T:
@@ -3020,7 +3242,7 @@ const handlePlaceOrder = () => {
3020
3242
  - Skip implementing required pages
3021
3243
  - Write `const products = [...]` - use the API!
3022
3244
  - Use `@apply group` in CSS - Tailwind doesn't allow 'group' in @apply. Use `className="group"` on the element instead
3023
- - **Render `product.description` as plain text without checking `descriptionFormat` - HTML will show as raw tags!**
3245
+ - **Render `product.description` as plain text without using `isHtmlDescription()` - HTML will show as raw tags like `<p>`, `<ul>`, `<li>`!**
3024
3246
 
3025
3247
  ---
3026
3248