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 +1 -1
- package/public/components/ImagesField.js +98 -98
- package/public/components/OptionsEditor.js +121 -121
- package/public/components/ProductDetail.js +287 -287
- package/public/components/ProductForm.js +384 -384
- package/public/components/PurchasesManager.js +178 -178
- package/sdk.js +74 -74
package/package.json
CHANGED
|
@@ -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);
|