feat(web): Multi-Copy modal earlier; Multi-Copy stage before lands; bump version to 2.1.1; update CHANGELOG\n\n- Modal triggers after commander selection (Step 2)\n- Multi-Copy applied first in Step 5, lands next\n- Keep mc_summary/clamp/adjustments wiring intact\n- Tests green

This commit is contained in:
matt 2025-08-29 09:19:03 -07:00
parent be672ac5d2
commit 341a216ed3
20 changed files with 1271 additions and 21 deletions

View file

@ -0,0 +1,79 @@
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="mcTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
<div class="modal-content" style="position:relative; max-width:620px; width:clamp(320px, 90vw, 620px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
<div class="modal-header" style="display:flex; align-items:center; justify-content:space-between; gap:.5rem;">
<h3 id="mcTitle" style="margin:0;">Consider a multi-copy package?</h3>
<button type="button" class="btn" aria-label="Close" onclick="try{this.closest('.modal').remove();}catch(_){ }">×</button>
</div>
<form hx-post="/build/multicopy/save" hx-target="closest .modal" hx-swap="outerHTML" onsubmit="return validateMultiCopyForm(this);">
<fieldset>
<legend>Choose one archetype</legend>
<div style="display:grid; gap:.5rem;">
{% for it in items %}
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
<input type="radio" name="choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
<div>
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>
{% if it.reasons %}
<div class="muted" style="font-size:12px;">Signals: {{ ', '.join(it.reasons) }}</div>
{% endif %}
</div>
</label>
{% endfor %}
</div>
</fieldset>
<fieldset style="margin-top:.5rem;">
<legend>How many copies?</legend>
{% set first = items[0] %}
{% set cap = first.printed_cap %}
{% set rec = first.rec_window if first.rec_window else (20,30) %}
<div id="mc-count-row" class="mc-count" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
<input type="number" min="1" name="count" value="{{ first.default_count or 25 }}" />
{% if cap %}
<small class="muted">Max {{ cap }}</small>
{% else %}
<small class="muted">Suggested {{ rec[0] }}{{ rec[1] }}</small>
{% endif %}
</div>
<div id="mc-thrum-row" style="margin-top:.35rem;">
<label title="Adds 1 copy of Thrumming Stone if applicable.">
<input type="checkbox" name="thrumming" value="1" {% if first.thrumming_stone_synergy %}checked{% endif %} /> Include Thrumming Stone
</label>
</div>
</fieldset>
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<button type="submit" class="btn-continue">Save</button>
<button type="submit" class="btn" name="skip" value="1" title="Don't ask again for this commander/theme combo">Skip</button>
</div>
</form>
</div>
</div>
<script>
(function(){
function qs(sel, root){ return (root||document).querySelector(sel); }
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
var form = modal ? modal.querySelector('form') : null;
function updateForChoice(choice){
try {
var countRow = qs('#mc-count-row', modal);
var thrumRow = qs('#mc-thrum-row', modal);
if (!choice || !countRow) return;
// Server provides only items array; embed metadata via dataset for dynamic hints when switching radio
var metaEl = choice.closest('label.mc-option');
var printedCap = metaEl && metaEl.querySelector('.muted') && metaEl.querySelector('.muted').textContent.match(/Cap: (\d+)/);
var cap = printedCap ? parseInt(printedCap[1], 10) : null;
var num = countRow.querySelector('input[name="count"]');
if (cap){ num.max = String(cap); if (parseInt(num.value||'0',10) > cap){ num.value = String(cap); } }
else { num.removeAttribute('max'); }
} catch(_){}
}
if (form){
var radios = form.querySelectorAll('input[name="choice_id"]');
Array.prototype.forEach.call(radios, function(r){ r.addEventListener('change', function(){ updateForChoice(r); }); });
if (radios.length){ updateForChoice(radios[0]); }
}
window.validateMultiCopyForm = function(f){ try{ return true; }catch(_){ return true; } };
document.addEventListener('keydown', function(e){ if (e.key === 'Escape'){ try{ modal && modal.remove(); }catch(_){ } } });
})();
</script>

View file

@ -8,6 +8,7 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="commander" value="{{ commander.name }}" />

View file

@ -9,6 +9,8 @@
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
{% if error %}

View file

@ -6,8 +6,9 @@
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
</a>
</aside>
<div class="grow" data-skeleton>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
{% if locks_restored and locks_restored > 0 %}
<div class="muted" style="margin:.35rem 0;">
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>

View file

@ -26,6 +26,7 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
@ -48,6 +49,12 @@
{% if added_total is not none %}
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
{% endif %}
{% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %}
<span class="chip" title="Trimmed overflow from this stage"><span class="dot" style="background: var(--red-main);"></span> Clamped {{ clamped_overflow }}</span>
{% endif %}
{% if stage_label and stage_label == 'Multi-Copy Package' and mc_summary is defined and mc_summary %}
<span class="chip" title="Multi-Copy package summary"><span class="dot" style="background: var(--purple-main);"></span> {{ mc_summary }}</span>
{% endif %}
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
<button type="button" class="btn" style="margin-left:auto;" title="Copy permalink"
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
@ -60,6 +67,10 @@
<div class="bar"></div>
</div>
{% if mc_adjustments is defined and mc_adjustments and stage_label and stage_label == 'Multi-Copy Package' %}
<div class="muted" style="margin:.35rem 0 .25rem 0;">Adjusted targets: {{ mc_adjustments|join(', ') }}</div>
{% endif %}
{% if status %}
<div style="margin-top:1rem;">
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
@ -216,7 +227,7 @@
sizes="160px" />
</button>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
@ -254,7 +265,7 @@
sizes="160px" />
</button>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"