Model refundable orders as separate purchases; add split action
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
A multi-game Purchase is now treated as an *unsplittable* bundle (one price, whole-purchase refund). Independently-refundable multi-item orders (e.g. a Steam cart) are instead recorded as N separate single-game purchases, so per-game pricing and per-game refunds work with the existing single-purchase machinery — no through-model needed. Add-purchase form (single form, single endpoint): - 1 game: unchanged. - 2+ games: a "Separate price per game" toggle appears (default off = one bundle price). On, the bundle Price hides and one price input per game appears; the view creates one single-game Purchase each from price_for_game_<id>. `price` is now optional so combined mode still validates. Split action: - A Split button on multi-game purchase rows opens a confirmation modal that replaces the bundle with one single-game purchase per game (price split evenly, needs_price_update set), then HX-Redirects to the list. New general-purpose `selection-fields` custom element renders one synced form field per selected item of a source SearchSelect (consuming the existing search-select:change contract); it knows nothing about prices, so it is reusable. Behavior in ts/elements/selection-fields.ts. Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split icon, and unit + Playwright e2e coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { readSelectionFieldsProps, SelectionFieldsProps } from "../generated/props.js";
|
||||
|
||||
/**
|
||||
* Renders one form field per selected item of a source SearchSelect (matched by
|
||||
* its data-name). Reacts to the SearchSelect's "search-select:change" event and
|
||||
* to its own "active" attribute. Typed values are preserved (keyed by item id)
|
||||
* across selection changes and active toggling.
|
||||
*/
|
||||
class SelectionFieldsElement extends HTMLElement {
|
||||
static get observedAttributes(): string[] {
|
||||
return ["active"];
|
||||
}
|
||||
|
||||
private props!: SelectionFieldsProps;
|
||||
private source: HTMLElement | null = null;
|
||||
private typedValues = new Map<string, string>();
|
||||
|
||||
private readonly onSourceChange = (event: Event): void => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
if (!detail || detail.name !== this.props.source) return;
|
||||
this.render();
|
||||
};
|
||||
|
||||
connectedCallback(): void {
|
||||
this.props = readSelectionFieldsProps(this);
|
||||
this.source = document.querySelector<HTMLElement>(
|
||||
`[data-search-select][data-name="${this.props.source}"]`,
|
||||
);
|
||||
document.addEventListener("search-select:change", this.onSourceChange);
|
||||
this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
document.removeEventListener("search-select:change", this.onSourceChange);
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
// connectedCallback assigns props; ignore the initial pre-connect call.
|
||||
if (this.props) this.render();
|
||||
}
|
||||
|
||||
private selectedItems(): { value: string; label: string }[] {
|
||||
if (!this.source) return [];
|
||||
const pills = this.source.querySelectorAll(
|
||||
"[data-search-select-pills] [data-pill]",
|
||||
);
|
||||
const items: { value: string; label: string }[] = [];
|
||||
pills.forEach((pill) => {
|
||||
const value = pill.getAttribute("data-value");
|
||||
if (!value) return;
|
||||
const labelElement = pill.querySelector("[data-search-select-label]");
|
||||
const label = labelElement?.textContent?.trim() || value;
|
||||
items.push({ value, label });
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
private captureTypedValues(): void {
|
||||
this.querySelectorAll<HTMLInputElement>(
|
||||
"[data-selection-fields-rows] input",
|
||||
).forEach((input) => {
|
||||
const itemId = input.getAttribute("data-item-id");
|
||||
if (itemId) this.typedValues.set(itemId, input.value);
|
||||
});
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const rows = this.querySelector<HTMLElement>("[data-selection-fields-rows]");
|
||||
const template = this.querySelector<HTMLTemplateElement>(
|
||||
"template[data-selection-fields-row]",
|
||||
);
|
||||
if (!rows || !template) return;
|
||||
|
||||
this.captureTypedValues();
|
||||
rows.replaceChildren();
|
||||
|
||||
const active = this.getAttribute("active") === "true";
|
||||
const items = this.selectedItems();
|
||||
if (!active || items.length < this.props.minItems) return;
|
||||
|
||||
const prototype = template.content.firstElementChild;
|
||||
if (!prototype) return;
|
||||
|
||||
items.forEach(({ value, label }) => {
|
||||
const row = prototype.cloneNode(true) as HTMLElement;
|
||||
const labelElement = row.querySelector("[data-selection-fields-label]");
|
||||
const input = row.querySelector<HTMLInputElement>("input");
|
||||
if (labelElement) labelElement.textContent = label;
|
||||
if (input) {
|
||||
input.name = `${this.props.namePrefix}${value}`;
|
||||
input.setAttribute("data-item-id", value);
|
||||
const preserved = this.typedValues.get(value);
|
||||
if (preserved !== undefined) input.value = preserved;
|
||||
}
|
||||
rows.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("selection-fields", SelectionFieldsElement);
|
||||
Reference in New Issue
Block a user