@spree/docs 0.1.183 → 0.1.184

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.
@@ -1,14 +1,88 @@
1
1
  ---
2
2
  title: Media
3
3
  sidebarTitle: "Media"
4
- description: Manage product media in Spree images, hosted and external video, named variants, focal points, and how media is uploaded, resized, and served via the Store API.
4
+ description: The store-wide media libraryuploading once and reusing everywhere, images and video, focal points, and how files are resized and served.
5
5
  ---
6
6
 
7
7
  ## Overview
8
8
 
9
- Spree handles uploads, processing, and delivery for product media. Images are automatically converted to WebP format and preprocessed into multiple sizes for optimal performance.
9
+ Spree handles uploading, processing and serving the images and video a store shows. Files are converted to WebP and preprocessed into several sizes, so a listing page isn't downloading full-resolution photographs.
10
10
 
11
- ## Product Media
11
+ Files live in a **store-wide media library**. A file is uploaded once and can then be placed wherever it's needed — on a product, on a category, inside a description — without being uploaded again.
12
+
13
+ ```mermaid
14
+ flowchart LR
15
+ Upload["Upload once"] --> Library["Media library"]
16
+ Library --> P["On a product"]
17
+ Library --> C["On a category"]
18
+ Library --> D["Inside a description"]
19
+ ```
20
+
21
+ ## The media library
22
+
23
+ Every file in a store is in the library, whether or not it's currently placed on anything. That means a file can be uploaded ahead of time, browsed, searched, and reused.
24
+
25
+ ```typescript Admin SDK
26
+ // Browse everything in the store
27
+ const { data: media } = await adminClient.media.list()
28
+
29
+ // Where is this file being used?
30
+ const { data: usage } = await adminClient.media.usage('med_xxx')
31
+ ```
32
+
33
+ | Field | What it tells you |
34
+ |---|---|
35
+ | `attached` | Whether the file is placed on anything yet |
36
+ | `filename`, `content_type`, `byte_size` | What the file actually is |
37
+ | `viewable_type` / `viewable_id` | What it's placed on, if anything |
38
+ | `media_type` | `image`, `video` or `external_video` |
39
+ | `alt` | Alt text, for accessibility and SEO |
40
+ | `position` | Order within a gallery |
41
+
42
+ ### Reuse shares the file, not the record
43
+
44
+ Placing a library file on a product creates a **new media row that shares the same underlying file**:
45
+
46
+ ```typescript Admin SDK
47
+ await adminClient.products.media.create('prod_xxx', {
48
+ source_media_id: 'med_xxx',
49
+ alt: 'Worn over a navy jumper',
50
+ position: 2,
51
+ })
52
+ ```
53
+
54
+ The copy gets its own alt text, its own position, and its own variant links — because the same photograph might be the third image on one product and the hero on another, with different alt text describing what matters in each context. What it doesn't get is a second copy of the bytes.
55
+
56
+ > **NOTE:** This is why there's no single "shared asset" record to manage. Each placement is independently editable, while storage is shared. Detaching a photo from one product has no effect on any other.
57
+
58
+ ### Deleting, safely
59
+
60
+ A file in use can't be deleted by accident:
61
+
62
+ ```typescript Admin SDK
63
+ // Returns 422 with the list of places it's used
64
+ await adminClient.media.delete('med_xxx')
65
+
66
+ // Remove it from everywhere, then delete
67
+ await adminClient.media.delete('med_xxx', { detach: true })
68
+ ```
69
+
70
+ The dashboard shows you the usage list and asks before doing the second one. Check `usage` before offering a delete in your own tooling.
71
+
72
+ ## What can carry media
73
+
74
+ | Owner | Typical use |
75
+ |---|---|
76
+ | **Product** | The main gallery |
77
+ | **Variant** | Images specific to one colour or size |
78
+ | **Category** | A banner or tile for a navigation page |
79
+ | **Collection** | The same, for a merchandising group |
80
+
81
+ Categories and collections also have simple `image` and `square_image` slots. Setting one places the file; clearing it **removes the placement but keeps the file in the library**, so nothing is destroyed by tidying up a page.
82
+
83
+ Sellers and stores keep their branding as plain attachments — a logo isn't merchandising, and it doesn't belong in a library people browse for product photos.
84
+
85
+ ## Product media
12
86
 
13
87
  A media record carries:
14
88
 
@@ -19,9 +93,9 @@ A media record carries:
19
93
  - **Preprocessed named variants** for fast delivery
20
94
  - **`variant_ids`** — which product variants the media represents. An empty array means it represents the product as a whole.
21
95
 
22
- ### Product-level Gallery
96
+ ### Product-level gallery
23
97
 
24
- In Spree 5.5 the **product** is the default owner of media. Before 5.5, every image was pinned to a specific variant (usually the master), and sharing the same image across variants meant re-uploading the file. From 5.5 onward, an image lives on the product, and any subset of variants can reference it through `variant_ids` — without duplicating the underlying file.
98
+ Media belongs to the **product**, and any subset of its variants can reference the same file through `variant_ids` so one photograph can represent three colourways without being uploaded three times.
25
99
 
26
100
  #### Uploading a product-level image
27
101
 
@@ -323,9 +397,48 @@ curl 'https://api.mystore.com/api/v3/store/products/spree-tote?expand=media,vari
323
397
  | `poster_url` | string \| null | Still frame for a video |
324
398
  | `original_url` | string \| null | Full-size image URL (inline disposition) |
325
399
  | `mini_url` ... `xlarge_url` | string \| null | Named variant URLs |
326
- | `download_url` | string \| null | Same blob as `original_url` but with `Content-Disposition: attachment`. Admin API only. |
327
400
 
328
- ## Image Processing
401
+ The Admin API adds the fields the library needs:
402
+
403
+ | Field | Type | Description |
404
+ |---|---|---|
405
+ | `attached` | boolean | Whether the file is placed on anything, or sitting unused in the library |
406
+ | `viewable_type` / `viewable_id` | string \| null | What it's placed on — `product`, `variant`, `category`, `collection` |
407
+ | `filename`, `content_type`, `byte_size` | | What the file actually is |
408
+ | `embed_url` | string \| null | The rendition used inside rich text — sized to fit, not cropped |
409
+ | `signed_id` | string \| null | Lets a plain attachment field adopt this file without re-uploading |
410
+ | `download_url` | string \| null | The same file with `Content-Disposition: attachment` |
411
+
412
+ > **NOTE:** Two behaviours to expect when rendering a gallery:
413
+ >
414
+ > **`original_url` is null for any playable video** — there's no still image to size. Use `poster_url`.
415
+ >
416
+ > **A video's sized URLs come from its poster.** If a video has a poster, every `*_url` resolves from that frame, so a grid of mixed images and video renders uniformly.
417
+
418
+ ## Images in descriptions
419
+
420
+ Rich text fields — a product description, a category's copy — can carry embedded images. In the dashboard, the editor opens the media library so a merchant picks an existing file or uploads a new one.
421
+
422
+ Embedded images use their own rendition, sized to fit rather than cropped to a square. A size chart or a diagram keeps its proportions, where a gallery thumbnail would have had its edges cut off.
423
+
424
+ > **WARNING:** An embedded image is a plain image URL in the HTML — nothing records which description uses which file.
425
+ >
426
+ > Spree's usage check does its best by searching descriptions for the file, but it can't be exhaustive. Deleting a library file can leave a broken image in a description, so treat the usage list as a warning rather than proof a file is unused.
427
+
428
+ ## Who can manage media
429
+
430
+ Media is its own permission, separate from products:
431
+
432
+ | Action | Requires |
433
+ |---|---|
434
+ | A product's own gallery — add, reorder, remove | Permission to edit **products** |
435
+ | Browsing the library, checking usage, deleting a file outright | **Media** permission |
436
+
437
+ The reasoning: reaching a file through a product you can already edit isn't the same as enumerating every file in the store. Someone who manages one product's photos shouldn't automatically be able to browse — or delete — everything the business has ever uploaded.
438
+
439
+ Note the asymmetry that follows. Removing an image from a product removes the *placement* and needs only product permission; deleting the file everywhere is a library action and needs the media key.
440
+
441
+ ## Image processing
329
442
 
330
443
  Spree uses [libvips](https://www.libvips.org/) for image processing. Images are automatically:
331
444
 
@@ -5,11 +5,13 @@ description: How Spree models products, variants, option types, images, prices,
5
5
 
6
6
  ## Overview
7
7
 
8
- A product represents something you sell. Each product has one or more **variants** the actual purchasable items with their own SKU, price, and inventory. For example, a "T-Shirt" product might have variants for each size and color combination.
8
+ A product is the listing a customer browses. A **variant** is the thing they actually buy.
9
9
 
10
- Products are organized into **categories**a flexible hierarchy for grouping products. Categories can be filtered, sorted, and searched via the Store API.
10
+ That split runs through everything on this page. The product holds what's shared name, description, images, which categories it's filed under. Each variant holds what differs — its SKU, its price, its stock. A T-shirt is one product; small navy is a variant.
11
11
 
12
- > **INFO:** Product names, descriptions, slugs, and SEO fields are [translatable](translations.md#resource-translations).
12
+ **Every product has at least one variant**, even when there's nothing to choose. A book with no size or colour still has a single variant carrying its SKU, price and stock; you just never render a picker.
13
+
14
+ > **INFO:** Product names, descriptions, slugs and SEO fields are [translatable](translations.md#resource-translations).
13
15
 
14
16
  ```mermaid
15
17
  erDiagram
@@ -59,21 +61,26 @@ erDiagram
59
61
  ## Product Attributes
60
62
 
61
63
  | Attribute | Description | Translatable |
62
- |-----------|-------------|:---:|
64
+ |---|---|:---:|
63
65
  | `name` | Product name | Yes |
64
- | `description` | Full product description | Yes |
65
- | `slug` | URL-friendly identifier (e.g., `spree-tote`) | Yes |
66
- | `status` | `draft`, `active`, or `archived`. A marketplace adds `proposed` and `rejected` — see Seller submissions below | No |
67
- | `available_on` | Date the product becomes available for sale | No |
68
- | `discontinue_on` | Date the product is no longer available | No |
69
- | `meta_title` | Custom SEO title | Yes |
70
- | `meta_description` | SEO description | Yes |
71
- | `meta_keywords` | SEO keywords | Yes |
72
- | `purchasable` | Whether the product can be added to cart | No |
73
- | `in_stock` | Whether any variant has stock available | No |
74
- | `price` | Default variant's price in the current currency | No |
75
- | `thumbnail_url` | URL to the product's first image always returned, no expand needed | No |
76
- | `tags` | Array of tag strings for filtering | No |
66
+ | `description` / `description_html` | Description as plain text and as formatted HTML | Yes |
67
+ | `slug` | URL identifier, e.g. `spree-tote` | Yes |
68
+ | `status` | `draft`, `active` or `archived`. A marketplace adds `proposed` and `rejected` — see [Seller submissions](#seller-submissions) | No |
69
+ | `available_on` | When it goes on sale | No |
70
+ | `discontinue_on` | When it comes off | No |
71
+ | `meta_title` / `meta_description` / `meta_keywords` | SEO fields | Yes |
72
+ | `purchasable` | Whether it can be added to a cart | No |
73
+ | `in_stock` | Whether any variant has stock | No |
74
+ | `backorderable` | Whether it can be ordered while out of stock | No |
75
+ | `preorder` / `preorder_ships_at` | Whether it's sold ahead of availability, and when it ships | No |
76
+ | `available` | Whether it's on sale right now, by date and status | No |
77
+ | `price` / `original_price` | The [default variant's](#the-default-variant) price, and its compare-at price | No |
78
+ | `default_variant_id` | Which variant represents the product | No |
79
+ | `variant_count` | How many variants it has | No |
80
+ | `thumbnail_url` | First image — always returned, no expand needed | No |
81
+ | `tags` | Tags, for filtering | No |
82
+
83
+ The Admin API adds the operational fields on top: `product_type_id`, `delivery_profile_id`, `tax_category_id`, `seller_id`, `metadata`, `created_at` / `updated_at` / `deleted_at`.
77
84
 
78
85
  ## Listing Products
79
86
 
@@ -160,18 +167,20 @@ Pass `expand` to include related resources in a single response — see [expand
160
167
 
161
168
  The examples above use the **Store API** (publishable key, read-only, customer-facing). To **create and manage** products, use the [Admin API](../../api-reference/admin-api/introduction.md) — via the [Admin SDK](../sdk/admin/quickstart.md) or the [Spree CLI](../cli/admin-api.md).
162
169
 
163
- A product's purchasable attributes (SKU, prices, stock) live on its **variants**, which you can create inline:
170
+ A product's purchasable attributes (SKU, prices, stock) live on its **variants**, which you can create inline.
171
+
172
+ For a product with options, send the variants:
164
173
 
165
174
 
166
175
  ```typescript Admin SDK
167
176
  import { createAdminClient } from '@spree/admin-sdk'
168
177
 
169
- const client = createAdminClient({
178
+ const adminClient = createAdminClient({
170
179
  baseUrl: 'https://store.example.com',
171
180
  secretKey: 'sk_xxx',
172
181
  })
173
182
 
174
- const product = await client.products.create({
183
+ const product = await adminClient.products.create({
175
184
  name: 'Premium T-Shirt',
176
185
  description: 'Soft, organic cotton.',
177
186
  status: 'active',
@@ -183,7 +192,7 @@ const product = await client.products.create({
183
192
  { name: 'color', value: 'navy' },
184
193
  ],
185
194
  prices: [{ currency: 'USD', amount: '29.99' }],
186
- stock_items: [{ stock_location_id: 'sloc_xxx', count_on_hand: 50 }],
195
+ stock_levels: [{ stock_location_id: 'sloc_xxx', count_on_hand: 50 }],
187
196
  },
188
197
  ],
189
198
  })
@@ -202,6 +211,18 @@ spree api post /products -d '{
202
211
  ```
203
212
 
204
213
 
214
+ For something with no options at all, send prices directly and skip the variants array — Spree forwards them to the product's single variant:
215
+
216
+ ```typescript Admin SDK
217
+ await adminClient.products.create({
218
+ name: 'The Spree Handbook',
219
+ status: 'active',
220
+ prices: [{ currency: 'USD', amount: '19.99' }],
221
+ })
222
+ ```
223
+
224
+ Don't pass both.
225
+
205
226
  Update, clone, or archive a product (deleting soft-deletes it):
206
227
 
207
228
 
@@ -397,29 +418,54 @@ curl 'https://api.mystore.com/api/v3/store/products/filters?category_id=ctg_xxx'
397
418
 
398
419
  ## Variants
399
420
 
400
- Variants are the purchasable units of a product. Each variant has its own SKU, price, inventory, and images, and is defined by a unique combination of option values.
421
+ **A product is not the thing you buy a variant is.** The product is the listing; the variant is the actual item with a SKU, a price and stock.
422
+
423
+ That distinction is worth holding onto, because everything purchasable lives on the variant:
401
424
 
402
425
  | Attribute | Description |
403
- |-----------|-------------|
404
- | `sku` | Unique stock keeping unit |
405
- | `barcode` | Barcode (UPC, EAN, etc.) |
426
+ |---|---|
427
+ | `sku` | Stock keeping unit |
428
+ | `barcode` | Barcode UPC, EAN and so on |
406
429
  | `price` | Price in the current currency |
407
- | `original_price` | Compare-at price for showing discounts |
408
- | `weight`, `height`, `width`, `depth` | Dimensions for shipping calculations |
409
- | `in_stock` | Whether stock is available |
410
- | `backorderable` | Whether the variant can be ordered when out of stock |
411
- | `option_values` | The option values that define this variant (e.g., Size: Small, Color: Red) |
430
+ | `original_price` | Compare-at price, for showing a reduction |
431
+ | `weight`, `height`, `width`, `depth` | Used for delivery rates and labels |
432
+ | `in_stock` / `purchasable` | Whether it can be bought right now |
433
+ | `backorderable` | Whether it can be ordered while out of stock |
434
+ | `preorder` / `preorder_ships_at` | Whether it's sold ahead of availability |
435
+ | `option_values` | What distinguishes it — Size: Small, Colour: Red |
436
+ | `options_text` | Those values as one readable string |
437
+ | `track_inventory` | Whether stock is counted at all |
412
438
 
413
- ### Master Variant
439
+ Variants also carry the customs attributes — `hs_code`, `country_of_origin`, `customs_description` — used when a parcel crosses a border. See [Fees](fees.md#customs-classification).
414
440
 
415
- Every product has a **master variant** that holds default pricing and inventory. If a product has no option types (e.g., a book with no size/color), the master variant is the only purchasable variant.
441
+ ### Every product has at least one variant
416
442
 
417
- ### Regular Variants
443
+ There's no such thing as a product without one. A book with no size or colour still has a single variant holding its SKU, price and stock — you simply never show a picker for it.
418
444
 
419
- When a product has option types, each unique combination of option values creates a variant. For example, a T-shirt with sizes (S, M, L) and colors (Red, Green) has 6 variants:
445
+ > **NOTE:** Earlier versions of Spree had a special "master variant" alongside the real ones, which meant every query had to remember to exclude it. **That concept is gone.** A product's variants are all real, all purchasable, and all the same kind of thing.
420
446
 
421
- | SKU | Size | Color |
422
- |-----|------|-------|
447
+ ### The default variant
448
+
449
+ `default_variant_id` names the variant that represents the product — the price shown on a listing page, and what "add to cart" means before anyone picks anything.
450
+
451
+ ```typescript Store SDK
452
+ const product = await client.products.get('spree-tote')
453
+
454
+ product.default_variant_id // "var_xxx"
455
+ product.price // that variant's price
456
+ product.variant_count // 6
457
+ ```
458
+
459
+ For a single-variant product that's the only variant. For a product with options it's the first one, unless you say otherwise. If the default is ever removed, another is promoted automatically — a product is never left without one.
460
+
461
+ Products can also carry `buy_box_variant_id`, naming the variant a marketplace has chosen to feature when several sellers offer the same listing.
462
+
463
+ ### Options make variants
464
+
465
+ A product with option types has one variant per combination. A T-shirt in three sizes and two colours is six variants:
466
+
467
+ | SKU | Size | Colour |
468
+ |---|---|---|
423
469
  | `TEE-S-R` | Small | Red |
424
470
  | `TEE-S-G` | Small | Green |
425
471
  | `TEE-M-R` | Medium | Red |
@@ -427,20 +473,18 @@ When a product has option types, each unique combination of option values create
427
473
  | `TEE-L-R` | Large | Red |
428
474
  | `TEE-L-G` | Large | Green |
429
475
 
430
- The product's `default_variant_id` points to the first non-master variant (or the master variant if none exist).
431
-
432
- Add a variant to an existing product via the Admin API (SKU, prices, and stock all live on the variant):
476
+ Adding one to an existing product:
433
477
 
434
478
 
435
479
  ```typescript Admin SDK
436
- const variant = await client.products.variants.create('prod_xxx', {
480
+ await adminClient.products.variants.create('prod_xxx', {
437
481
  sku: 'TEE-L-R',
438
482
  options: [
439
483
  { name: 'size', value: 'Large' },
440
484
  { name: 'color', value: 'Red' },
441
485
  ],
442
486
  prices: [{ currency: 'USD', amount: '24.99' }],
443
- stock_items: [{ stock_location_id: 'sloc_xxx', count_on_hand: 30 }],
487
+ stock_levels: [{ stock_location_id: 'sloc_xxx', count_on_hand: 30 }],
444
488
  })
445
489
  ```
446
490
 
@@ -453,6 +497,8 @@ spree api post /products/prod_xxx/variants -d '{
453
497
  ```
454
498
 
455
499
 
500
+ Options are named by value rather than by ID, so you don't have to look up an option value before creating a variant that uses it.
501
+
456
502
  ## Option Types and Option Values
457
503
 
458
504
  Option types define the axes of variation for a product (e.g., Size, Color, Material). Option values are the specific choices within each type (e.g., Small, Medium, Large).
@@ -517,9 +563,76 @@ spree api post /option_types -d '{
517
563
  ```
518
564
 
519
565
 
566
+ ## Product Types
567
+
568
+ Merchants who sell more than one kind of thing end up repeating themselves. Every pair of shoes needs Size and Colour, belongs under Footwear, and wants a Material field. Every book needs an ISBN and an author.
569
+
570
+ A **product type** captures that once. Creating a product from a type gives it the right option types, the right categories, the right delivery profile, and a form asking for the fields that kind of product actually needs.
571
+
572
+ ```mermaid
573
+ erDiagram
574
+ ProductType ||--o{ Product : "creates"
575
+ ProductType }o--o{ OptionType : "seeds"
576
+ ProductType }o--o{ Category : "seeds"
577
+ ProductType }o--o{ CustomFieldDefinition : "asks for"
578
+ ProductType }o--o| DeliveryProfile : "ships by"
579
+
580
+ ProductType {
581
+ string name
582
+ integer products_count
583
+ }
584
+ ```
585
+
586
+ ```typescript Admin SDK
587
+ const shoes = await adminClient.productTypes.create({
588
+ name: 'Footwear',
589
+ option_type_ids: ['optt_size', 'optt_color'],
590
+ category_ids: ['ctg_footwear'],
591
+ delivery_profile_id: 'dp_standard',
592
+ custom_field_definitions: [
593
+ { id: 'cfdef_material', required: true, sort_order: 0 },
594
+ ],
595
+ })
596
+
597
+ await adminClient.products.create({
598
+ name: 'Trail Runner',
599
+ product_type_id: shoes.id,
600
+ status: 'draft',
601
+ })
602
+ ```
603
+
604
+ ### A type is a template, not a controller
605
+
606
+ This is the part that determines how you should think about them.
607
+
608
+ > **WARNING:** **Editing a product type never rewrites existing products.** Add an option type to Footwear next month and the shoes you created last month are untouched.
609
+
610
+ That's deliberate. A merchant who adds a field to a type is describing what *new* products should look like — not asking Spree to silently restructure a live catalogue, invalidate URLs, or change what customers can pick.
611
+
612
+ So the pieces behave in two distinct ways:
613
+
614
+ | Part of the type | Behaviour |
615
+ |---|---|
616
+ | Option types, categories, delivery profile | **Stamped at creation.** Copied onto the product, then independent |
617
+ | Custom field definitions | **Live by reference.** The form always reflects the type as it is now |
618
+
619
+ Seeding is also **additive** — it adds what's missing and never removes what a product already has. Reassigning a product's type is therefore safe: it seeds the new type's option types and categories alongside whatever was already there.
620
+
621
+ If you *do* want an edited type to reach the products already using it, that's an explicit action:
622
+
623
+ ```typescript Admin SDK
624
+ const { products_count } = await adminClient.productTypes.applyToProducts('pt_xxx')
625
+ ```
626
+
627
+ It runs in the background and is additive like the rest — never a side effect of saving a type.
628
+
629
+ > **INFO:** `required` on a type's custom field is **advisory** — it marks the field in the dashboard but isn't enforced on write, since Spree saves the product and its fields in two steps. Validate in your own tooling if you need it enforced.
630
+
631
+ A type in use can't be deleted; its products would lose the structure they were built from.
632
+
520
633
  ## Media
521
634
 
522
- Media can be attached to the product (via the master variant) or to individual variants. When displaying a product, show the images for the selected variant, falling back to the product-level images.
635
+ Media can be attached to a product or to individual variants. When displaying a product, show the images for the selected variant, falling back to the product's own.
523
636
 
524
637
  ### Thumbnails
525
638
 
@@ -560,7 +673,7 @@ const product = await client.products.get('spree-tote', {
560
673
  expand: ['media', 'variants'],
561
674
  })
562
675
 
563
- // Product-level images (from master variant)
676
+ // The product's own images
564
677
  product.media // [{ original_url: "https://cdn.../tote-front.jpg", position: 1 }, ...]
565
678
 
566
679
  // Each variant has its own thumbnail and media_count