kempo-products 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-products",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A product catalog for kempo cms: browse, filter and inquire about products, with no checkout required",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant",
@@ -1,98 +1,98 @@
1
- import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
2
- import { html } from '/kempo-ui/lit-all.min.js';
3
- import Toast from '/kempo-ui/components/Toast.js';
4
- import '/kempo-ui/components/Icon.js';
5
- import '/kempo-ui/components/Spinner.js';
6
-
7
- /*
8
- A product's pictures, uploaded to kempo-media. The first is the primary one shown in lists. Only
9
- used when kempo-media is installed; the form leaves it out otherwise.
10
-
11
- <k-prod-images .value=${product.images} .assets=${imagesMap}></k-prod-images>
12
-
13
- `value` is the list of media ids and `assets` a map of id -> { path, thumbnail, name } to show.
14
- Fires `change` with { value, assets }: the new list of ids, and every description it knows, so the
15
- parent can keep showing pictures just uploaded.
16
- */
17
- export default class ImagesField extends ShadowComponent {
18
- static properties = {
19
- value: { attribute: false },
20
- assets: { attribute: false },
21
- disabled: { type: Boolean, reflect: true },
22
- uploading: { state: true },
23
- };
24
-
25
- constructor(){
26
- super();
27
- this.value = [];
28
- this.assets = {};
29
- this.disabled = false;
30
- this.uploading = 0;
31
- }
32
-
33
- /*
34
- Utility functions
35
- */
36
- commit = value => {
37
- this.value = value;
38
- this.dispatchEvent(new CustomEvent('change', { detail: { value, assets: this.assets }, bubbles: true }));
39
- };
40
-
41
- /*
42
- Event handlers
43
- */
44
- handleFiles = async event => {
45
- const files = [...event.target.files];
46
- event.target.value = '';
47
- if(!files.length) return;
48
- const { uploadMedia } = await import('/kempo-media/sdk.js');
49
- const added = [];
50
- this.uploading += files.length;
51
- for(const file of files){
52
- const [error, data] = await uploadMedia(file);
53
- this.uploading -= 1;
54
- if(error){
55
- Toast.error(`${file.name}: ${error.msg}`);
56
- continue;
57
- }
58
- const { asset } = data;
59
- this.assets = {
60
- ...this.assets,
61
- [asset.id]: { id: asset.id, name: asset.originalName, alt: asset.altText ?? '', path: `/${asset.path}`, thumbnail: asset.thumbnailPath ? `/${asset.thumbnailPath}` : null },
62
- };
63
- added.push(asset.id);
64
- }
65
- if(added.length) this.commit([...this.value, ...added]);
66
- };
67
-
68
- makePrimary = id => this.commit([id, ...this.value.filter(other => other !== id)]);
69
-
70
- remove = id => this.commit(this.value.filter(other => other !== id));
71
-
72
- /*
73
- View
74
- */
75
- renderTile(id, index){
76
- const asset = this.assets[id];
77
- return html`<div style="position: relative; width: 6rem; aspect-ratio: 1; border: 1px solid var(--c_border); border-radius: var(--radius); overflow: hidden; display: flex; align-items: center; justify-content: center;" title=${asset?.name ?? 'Missing file'}>
78
- ${asset ? html`<img src=${asset.thumbnail ?? asset.path} alt=${asset.alt || asset.name} style="width: 100%; height: 100%; object-fit: cover;">` : html`<k-icon name="image"></k-icon>`}
79
- ${index === 0
80
- ? html`<span style="position: absolute; left: 0; bottom: 0; right: 0; background: var(--c_bg); font-size: 0.7rem; text-align: center; line-height: 1.4;"><k-icon name="star_filled"></k-icon> Primary</span>`
81
- : html`<button type="button" class="no-btn" style="position: absolute; left: 0; bottom: 0; right: 0; background: var(--c_bg); font-size: 0.7rem; line-height: 1.4; cursor: pointer;" ?disabled=${this.disabled} @click=${() => this.makePrimary(id)}><k-icon name="star"></k-icon> Make primary</button>`}
82
- <button type="button" class="no-btn" style="position: absolute; top: 0; right: 0; background: var(--c_bg); cursor: pointer; line-height: 1;" aria-label="Remove this image" ?disabled=${this.disabled} @click=${() => this.remove(id)}><k-icon name="close"></k-icon></button>
83
- </div>`;
84
- }
85
-
86
- render(){
87
- return html`<div class="d-f" style="gap: var(--spacer_h); align-items: flex-start;">
88
- ${this.value.map((id, index) => this.renderTile(id, index))}
89
- ${this.uploading ? html`<k-spinner></k-spinner>` : ''}
90
- <label class="btn" style="margin: 0; cursor: pointer;">
91
- <k-icon name="add"></k-icon> Add images
92
- <input type="file" accept="image/*" multiple hidden ?disabled=${this.disabled} @change=${this.handleFiles}>
93
- </label>
94
- </div>`;
95
- }
96
- }
97
-
98
- customElements.define('k-prod-images', ImagesField);
1
+ import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
2
+ import { html } from '/kempo-ui/lit-all.min.js';
3
+ import Toast from '/kempo-ui/components/Toast.js';
4
+ import '/kempo-ui/components/Icon.js';
5
+ import '/kempo-ui/components/Spinner.js';
6
+
7
+ /*
8
+ A product's pictures, uploaded to kempo-media. The first is the primary one shown in lists. Only
9
+ used when kempo-media is installed; the form leaves it out otherwise.
10
+
11
+ <k-prod-images .value=${product.images} .assets=${imagesMap}></k-prod-images>
12
+
13
+ `value` is the list of media ids and `assets` a map of id -> { path, thumbnail, name } to show.
14
+ Fires `change` with { value, assets }: the new list of ids, and every description it knows, so the
15
+ parent can keep showing pictures just uploaded.
16
+ */
17
+ export default class ImagesField extends ShadowComponent {
18
+ static properties = {
19
+ value: { attribute: false },
20
+ assets: { attribute: false },
21
+ disabled: { type: Boolean, reflect: true },
22
+ uploading: { state: true },
23
+ };
24
+
25
+ constructor(){
26
+ super();
27
+ this.value = [];
28
+ this.assets = {};
29
+ this.disabled = false;
30
+ this.uploading = 0;
31
+ }
32
+
33
+ /*
34
+ Utility functions
35
+ */
36
+ commit = value => {
37
+ this.value = value;
38
+ this.dispatchEvent(new CustomEvent('change', { detail: { value, assets: this.assets }, bubbles: true }));
39
+ };
40
+
41
+ /*
42
+ Event handlers
43
+ */
44
+ handleFiles = async event => {
45
+ const files = [...event.target.files];
46
+ event.target.value = '';
47
+ if(!files.length) return;
48
+ const { uploadMedia } = await import('/kempo-media/sdk.js');
49
+ const added = [];
50
+ this.uploading += files.length;
51
+ for(const file of files){
52
+ const [error, data] = await uploadMedia(file);
53
+ this.uploading -= 1;
54
+ if(error){
55
+ Toast.error(`${file.name}: ${error.msg}`);
56
+ continue;
57
+ }
58
+ const { asset } = data;
59
+ this.assets = {
60
+ ...this.assets,
61
+ [asset.id]: { id: asset.id, name: asset.originalName, alt: asset.altText ?? '', path: `/${asset.path}`, thumbnail: asset.thumbnailPath ? `/${asset.thumbnailPath}` : null },
62
+ };
63
+ added.push(asset.id);
64
+ }
65
+ if(added.length) this.commit([...this.value, ...added]);
66
+ };
67
+
68
+ makePrimary = id => this.commit([id, ...this.value.filter(other => other !== id)]);
69
+
70
+ remove = id => this.commit(this.value.filter(other => other !== id));
71
+
72
+ /*
73
+ View
74
+ */
75
+ renderTile(id, index){
76
+ const asset = this.assets[id];
77
+ return html`<div style="position: relative; width: 6rem; aspect-ratio: 1; border: 1px solid var(--c_border); border-radius: var(--radius); overflow: hidden; display: flex; align-items: center; justify-content: center;" title=${asset?.name ?? 'Missing file'}>
78
+ ${asset ? html`<img src=${asset.thumbnail ?? asset.path} alt=${asset.alt || asset.name} style="width: 100%; height: 100%; object-fit: cover;">` : html`<k-icon name="image"></k-icon>`}
79
+ ${index === 0
80
+ ? html`<span style="position: absolute; left: 0; bottom: 0; right: 0; background: var(--c_bg); font-size: 0.7rem; text-align: center; line-height: 1.4;"><k-icon name="star_filled"></k-icon> Primary</span>`
81
+ : html`<button type="button" class="no-btn" style="position: absolute; left: 0; bottom: 0; right: 0; background: var(--c_bg); font-size: 0.7rem; line-height: 1.4; cursor: pointer;" ?disabled=${this.disabled} @click=${() => this.makePrimary(id)}><k-icon name="star"></k-icon> Make primary</button>`}
82
+ <button type="button" class="no-btn" style="position: absolute; top: 0; right: 0; background: var(--c_bg); cursor: pointer; line-height: 1;" aria-label="Remove this image" ?disabled=${this.disabled} @click=${() => this.remove(id)}><k-icon name="close"></k-icon></button>
83
+ </div>`;
84
+ }
85
+
86
+ render(){
87
+ return html`<div class="d-f" style="gap: var(--spacer_h); align-items: flex-start;">
88
+ ${this.value.map((id, index) => this.renderTile(id, index))}
89
+ ${this.uploading ? html`<k-spinner></k-spinner>` : ''}
90
+ <label class="btn" style="margin: 0; cursor: pointer;">
91
+ <k-icon name="add"></k-icon> Add images
92
+ <input type="file" accept="image/*" multiple hidden ?disabled=${this.disabled} @change=${this.handleFiles}>
93
+ </label>
94
+ </div>`;
95
+ }
96
+ }
97
+
98
+ customElements.define('k-prod-images', ImagesField);
@@ -1,121 +1,121 @@
1
- import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
2
- import { html } from '/kempo-ui/lit-all.min.js';
3
- import '/kempo-ui/components/Icon.js';
4
- import '/products/components/MoneyInput.js';
5
-
6
- /*
7
- Edits a product's options: the choices a buyer makes (colour, clear coat, size). Each choice has
8
- a price that is, by default, relative to the product's base price: "-1.00" takes a dollar off,
9
- "0.00" changes nothing. An option set to "replace the base price" instead uses its choice's price
10
- as the price, the way most shops price variations.
11
-
12
- <k-prod-options-editor .value=${product.options} decimals="2"></k-prod-options-editor>
13
-
14
- `managed` is the name of an extension that keeps stock in step with something else; it decides
15
- which choices are in stock, so that box is shown read-only. Fires `change` with { value }: the
16
- whole list of options.
17
- */
18
- export default class OptionsEditor extends ShadowComponent {
19
- static properties = {
20
- value: { attribute: false },
21
- decimals: { type: Number },
22
- managed: { type: String },
23
- options: { state: true },
24
- };
25
-
26
- constructor(){
27
- super();
28
- this.value = [];
29
- this.decimals = 2;
30
- this.managed = '';
31
- this.options = [];
32
- }
33
-
34
- /*
35
- Lifecycle callbacks
36
- */
37
- willUpdate(changed){
38
- if(changed.has('value')) this.options = structuredClone(this.value ?? []);
39
- }
40
-
41
- /*
42
- Utility functions
43
- */
44
- commit = options => {
45
- this.options = options;
46
- this.dispatchEvent(new CustomEvent('change', { detail: { value: structuredClone(options) }, bubbles: true }));
47
- };
48
-
49
- patchOption = (index, changes) => this.commit(this.options.map((option, i) => i === index ? { ...option, ...changes } : option));
50
-
51
- patchChoice = (index, choiceIndex, changes) => this.patchOption(index, {
52
- choices: this.options[index].choices.map((choice, i) => i === choiceIndex ? { ...choice, ...changes } : choice),
53
- });
54
-
55
- /*
56
- Event handlers
57
- */
58
- addOption = () => this.commit([...this.options, {
59
- label: '', required: true, priceType: 'adjust', choices: [{ label: '', price: 0, available: true }],
60
- }]);
61
-
62
- removeOption = index => this.commit(this.options.filter((_, i) => i !== index));
63
-
64
- addChoice = index => this.patchOption(index, { choices: [...this.options[index].choices, { label: '', price: 0, available: true }] });
65
-
66
- removeChoice = (index, choiceIndex) => this.patchOption(index, { choices: this.options[index].choices.filter((_, i) => i !== choiceIndex) });
67
-
68
- /*
69
- View
70
- */
71
- renderChoice(option, index, choice, choiceIndex){
72
- const replacing = option.priceType === 'replace';
73
- return html`<div class="d-f b r pq mbh" style="align-items: center; gap: var(--spacer_h); flex-wrap: nowrap;">
74
- <input type="text" class="flex" style="width: auto; min-width: 0; margin: 0;" placeholder="Choice, e.g. Red" aria-label="Choice name"
75
- .value=${choice.label} @input=${event => this.patchChoice(index, choiceIndex, { label: event.target.value })}>
76
- <k-prod-money style="width: 8rem;" ?signed=${!replacing} decimals=${this.decimals} .value=${choice.price} placeholder=${replacing ? 'Price' : '+/- 0.00'}
77
- label=${replacing ? 'Price of this choice' : 'Price change for this choice'}
78
- @change=${event => this.patchChoice(index, choiceIndex, { price: event.detail.value ?? 0 })}></k-prod-money>
79
- <label class="checkbox" style="margin: 0; white-space: nowrap;" title=${this.managed ? `In stock is kept up to date by ${this.managed}` : 'Untick when this choice is sold out'}>
80
- <input type="checkbox" .checked=${choice.available} ?disabled=${Boolean(this.managed)}
81
- @change=${event => this.patchChoice(index, choiceIndex, { available: event.target.checked })}> In stock
82
- </label>
83
- <button type="button" class="no-btn tc-danger" style="cursor: pointer;" title="Remove this choice" aria-label="Remove choice"
84
- ?disabled=${option.choices.length < 2} @click=${() => this.removeChoice(index, choiceIndex)}><k-icon name="delete"></k-icon></button>
85
- </div>`;
86
- }
87
-
88
- renderOption(option, index){
89
- return html`<div class="card mb">
90
- <div class="d-f mbh" style="align-items: center; gap: var(--spacer_h); flex-wrap: nowrap;">
91
- <input type="text" class="flex" style="width: auto; min-width: 0; margin: 0;" placeholder="Option, e.g. Clear coat" aria-label="Option name"
92
- .value=${option.label} @input=${event => this.patchOption(index, { label: event.target.value })}>
93
- <select style="width: auto; margin: 0;" aria-label="How the choice prices work" @change=${event => this.patchOption(index, { priceType: event.target.value })}>
94
- <option value="adjust" ?selected=${option.priceType === 'adjust'}>Adds to the base price</option>
95
- <option value="replace" ?selected=${option.priceType === 'replace'}>Replaces the base price</option>
96
- </select>
97
- <label class="checkbox" style="margin: 0; white-space: nowrap;">
98
- <input type="checkbox" .checked=${option.required} @change=${event => this.patchOption(index, { required: event.target.checked })}> Required
99
- </label>
100
- <button type="button" class="no-btn tc-danger" style="cursor: pointer;" title="Remove this whole option, with all its choices" aria-label="Remove option"
101
- @click=${() => this.removeOption(index)}><k-icon name="delete"></k-icon></button>
102
- </div>
103
- <div class="ml">
104
- <small class="d-b tc-muted mbq">Choices</small>
105
- ${option.choices.map((choice, choiceIndex) => this.renderChoice(option, index, choice, choiceIndex))}
106
- <button type="button" class="btn" @click=${() => this.addChoice(index)}><k-icon name="add"></k-icon> Add choice</button>
107
- </div>
108
- </div>`;
109
- }
110
-
111
- render(){
112
- return html`<div>
113
- ${this.managed ? html`<p class="tc-muted">Which choices are in stock is kept up to date by the ${this.managed} extension.</p>` : ''}
114
- ${this.options.map((option, index) => this.renderOption(option, index))}
115
- <button type="button" class="btn" @click=${this.addOption}><k-icon name="add"></k-icon> Add option</button>
116
- ${this.options.length ? html`<small class="d-b tc-muted mtq">A choice is added to the base price by default: -1.00 takes a dollar off and 0.00 changes nothing. Only one option can replace the base price.</small>` : ''}
117
- </div>`;
118
- }
119
- }
120
-
121
- customElements.define('k-prod-options-editor', OptionsEditor);
1
+ import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
2
+ import { html } from '/kempo-ui/lit-all.min.js';
3
+ import '/kempo-ui/components/Icon.js';
4
+ import '/products/components/MoneyInput.js';
5
+
6
+ /*
7
+ Edits a product's options: the choices a buyer makes (colour, clear coat, size). Each choice has
8
+ a price that is, by default, relative to the product's base price: "-1.00" takes a dollar off,
9
+ "0.00" changes nothing. An option set to "replace the base price" instead uses its choice's price
10
+ as the price, the way most shops price variations.
11
+
12
+ <k-prod-options-editor .value=${product.options} decimals="2"></k-prod-options-editor>
13
+
14
+ `managed` is the name of an extension that keeps stock in step with something else; it decides
15
+ which choices are in stock, so that box is shown read-only. Fires `change` with { value }: the
16
+ whole list of options.
17
+ */
18
+ export default class OptionsEditor extends ShadowComponent {
19
+ static properties = {
20
+ value: { attribute: false },
21
+ decimals: { type: Number },
22
+ managed: { type: String },
23
+ options: { state: true },
24
+ };
25
+
26
+ constructor(){
27
+ super();
28
+ this.value = [];
29
+ this.decimals = 2;
30
+ this.managed = '';
31
+ this.options = [];
32
+ }
33
+
34
+ /*
35
+ Lifecycle callbacks
36
+ */
37
+ willUpdate(changed){
38
+ if(changed.has('value')) this.options = structuredClone(this.value ?? []);
39
+ }
40
+
41
+ /*
42
+ Utility functions
43
+ */
44
+ commit = options => {
45
+ this.options = options;
46
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: structuredClone(options) }, bubbles: true }));
47
+ };
48
+
49
+ patchOption = (index, changes) => this.commit(this.options.map((option, i) => i === index ? { ...option, ...changes } : option));
50
+
51
+ patchChoice = (index, choiceIndex, changes) => this.patchOption(index, {
52
+ choices: this.options[index].choices.map((choice, i) => i === choiceIndex ? { ...choice, ...changes } : choice),
53
+ });
54
+
55
+ /*
56
+ Event handlers
57
+ */
58
+ addOption = () => this.commit([...this.options, {
59
+ label: '', required: true, priceType: 'adjust', choices: [{ label: '', price: 0, available: true }],
60
+ }]);
61
+
62
+ removeOption = index => this.commit(this.options.filter((_, i) => i !== index));
63
+
64
+ addChoice = index => this.patchOption(index, { choices: [...this.options[index].choices, { label: '', price: 0, available: true }] });
65
+
66
+ removeChoice = (index, choiceIndex) => this.patchOption(index, { choices: this.options[index].choices.filter((_, i) => i !== choiceIndex) });
67
+
68
+ /*
69
+ View
70
+ */
71
+ renderChoice(option, index, choice, choiceIndex){
72
+ const replacing = option.priceType === 'replace';
73
+ return html`<div class="d-f b r pq mbh" style="align-items: center; gap: var(--spacer_h); flex-wrap: nowrap;">
74
+ <input type="text" class="flex" style="width: auto; min-width: 0; margin: 0;" placeholder="Choice, e.g. Red" aria-label="Choice name"
75
+ .value=${choice.label} @input=${event => this.patchChoice(index, choiceIndex, { label: event.target.value })}>
76
+ <k-prod-money style="width: 8rem;" ?signed=${!replacing} decimals=${this.decimals} .value=${choice.price} placeholder=${replacing ? 'Price' : '+/- 0.00'}
77
+ label=${replacing ? 'Price of this choice' : 'Price change for this choice'}
78
+ @change=${event => this.patchChoice(index, choiceIndex, { price: event.detail.value ?? 0 })}></k-prod-money>
79
+ <label class="checkbox" style="margin: 0; white-space: nowrap;" title=${this.managed ? `In stock is kept up to date by ${this.managed}` : 'Untick when this choice is sold out'}>
80
+ <input type="checkbox" .checked=${choice.available} ?disabled=${Boolean(this.managed)}
81
+ @change=${event => this.patchChoice(index, choiceIndex, { available: event.target.checked })}> In stock
82
+ </label>
83
+ <button type="button" class="no-btn tc-danger" style="cursor: pointer;" title="Remove this choice" aria-label="Remove choice"
84
+ ?disabled=${option.choices.length < 2} @click=${() => this.removeChoice(index, choiceIndex)}><k-icon name="delete"></k-icon></button>
85
+ </div>`;
86
+ }
87
+
88
+ renderOption(option, index){
89
+ return html`<div class="card mb">
90
+ <div class="d-f mbh" style="align-items: center; gap: var(--spacer_h); flex-wrap: nowrap;">
91
+ <input type="text" class="flex" style="width: auto; min-width: 0; margin: 0;" placeholder="Option, e.g. Clear coat" aria-label="Option name"
92
+ .value=${option.label} @input=${event => this.patchOption(index, { label: event.target.value })}>
93
+ <select style="width: auto; margin: 0;" aria-label="How the choice prices work" @change=${event => this.patchOption(index, { priceType: event.target.value })}>
94
+ <option value="adjust" ?selected=${option.priceType === 'adjust'}>Adds to the base price</option>
95
+ <option value="replace" ?selected=${option.priceType === 'replace'}>Replaces the base price</option>
96
+ </select>
97
+ <label class="checkbox" style="margin: 0; white-space: nowrap;">
98
+ <input type="checkbox" .checked=${option.required} @change=${event => this.patchOption(index, { required: event.target.checked })}> Required
99
+ </label>
100
+ <button type="button" class="no-btn tc-danger" style="cursor: pointer;" title="Remove this whole option, with all its choices" aria-label="Remove option"
101
+ @click=${() => this.removeOption(index)}><k-icon name="delete"></k-icon></button>
102
+ </div>
103
+ <div class="ml">
104
+ <small class="d-b tc-muted mbq">Choices</small>
105
+ ${option.choices.map((choice, choiceIndex) => this.renderChoice(option, index, choice, choiceIndex))}
106
+ <button type="button" class="btn" @click=${() => this.addChoice(index)}><k-icon name="add"></k-icon> Add choice</button>
107
+ </div>
108
+ </div>`;
109
+ }
110
+
111
+ render(){
112
+ return html`<div>
113
+ ${this.managed ? html`<p class="tc-muted">Which choices are in stock is kept up to date by the ${this.managed} extension.</p>` : ''}
114
+ ${this.options.map((option, index) => this.renderOption(option, index))}
115
+ <button type="button" class="btn" @click=${this.addOption}><k-icon name="add"></k-icon> Add option</button>
116
+ ${this.options.length ? html`<small class="d-b tc-muted mtq">A choice is added to the base price by default: -1.00 takes a dollar off and 0.00 changes nothing. Only one option can replace the base price.</small>` : ''}
117
+ </div>`;
118
+ }
119
+ }
120
+
121
+ customElements.define('k-prod-options-editor', OptionsEditor);