Has anybody gone through this or might know a solution.
I’m building a custom Theme App Extension that displays a dropdown of team rosters (pulled from product metafields) on the product page. Everything looks and works great on the frontend UI, and we are tracking hidden player IDs behind the scenes.
The Problem: When a customer selects an option (or types in a custom name when choosing “Other”) and clicks “Add to Cart,” the data isn’t saving to the line_item custom properties. It just disappears before reaching the cart, checkout, or order details.
What I’m running into:
-
Almost every tutorial or guide I can find online only talks about hardcoding solutions directly into theme files (
main-product.liquid, etc.). However, I really need this to be handled cleanly through an app extension block for better modularity and synchronization with our external dashboard app. -
Apps like Magical Form Fields pull this off seamlessly and even display the selected athlete name right underneath the item in the cart/checkout.
-
I tested out code provided by the Shopify AI assistant. While the UI renders and behaves properly, it still fails to assign the entered values to the actual line item properties on submit.
Here is my code:
{% assign team_data = product.metafields.custom.team_athletes.value %}
<div class="athlete-selector-block" style="margin-top: {{ block.settings.margin_top }}px; margin-bottom: {{ block.settings.margin_bottom }}px;">
<label for="AthleteSelect-{{ block.id }}" style="font-weight: 600; display: block; margin-bottom: 6px;">
{{ block.settings.label_text }} {% if block.settings.is_required %}<span style="color: red;">*</span>{% endif %}
</label>
{% comment %}
Main dropdown attached to the product form.
{% endcomment %}
<select
id="AthleteSelect-{{ block.id }}"
name="properties[{{ block.settings.property_name }}]"
form="product-form-{{ section.id }}"
{% if block.settings.is_required %}required{% endif %}
onchange="handleAthleteChange_{{ block.id | replace: '-', '_' }}(this)"
style="width: 100%; padding: 10px; border-radius: 4px; border: 1px solid #ccc; font-size: 14px; background-color: #fff;"
>
<option value="" disabled selected>{{ block.settings.placeholder_text }}</option>
<option value="OTHER" data-player-id="" data-team-id="">Can't find my athlete / Other</option>
{% if team_data and team_data.teams %}
{% for team in team_data.teams %}
<optgroup label="--- {{ team.teamName }} ---">
{% for athlete in team.athletes %}
{% if athlete.id %}
{% comment %} Structured JSON with Backend IDs {% endcomment %}
<option
value="{{ athlete.name }}"
data-player-id="{{ athlete.id }}"
data-team-id="{{ team.teamId | default: '' }}"
>
{{ athlete.name }}
</option>
{% else %}
{% comment %} Legacy Plain String Fallback {% endcomment %}
<option
value="{{ athlete }} ({{ team.teamName }})"
data-player-id=""
data-team-id=""
>
{{ athlete }}
</option>
{% endif %}
{% endfor %}
</optgroup>
{% endfor %}
{% else %}
{% comment %} Demo Fallback {% endcomment %}
<optgroup label="--- Team 1 ---">
<option value="John (Team 1)" data-player-id="101" data-team-id="1">John</option>
<option value="Seth (Team 1)" data-player-id="102" data-team-id="1">Seth</option>
</optgroup>
{% endif %}
</select>
{% comment %} Hidden backend properties (_playerId and _teamId are sent to Webhooks/GraphQL) {% endcomment %}
<input type="hidden" id="HiddenPlayerId-{{ block.id }}" name="properties[_playerId]" value="" form="product-form-{{ section.id }}" />
<input type="hidden" id="HiddenTeamId-{{ block.id }}" name="properties[_teamId]" value="" form="product-form-{{ section.id }}" />
{% comment %} Custom Athlete Container when OTHER is selected {% endcomment %}
<div id="CustomAthleteContainer-{{ block.id }}" style="display: none; margin-top: 10px;">
<label for="CustomAthleteInput-{{ block.id }}" style="font-size: 13px; font-weight: 600; display: block; margin-bottom: 4px;">
Type Athlete Name <span style="color: red;">*</span>
</label>
<input
type="text"
id="CustomAthleteInput-{{ block.id }}"
name="properties[{{ block.settings.custom_property_name }}]"
placeholder="First & Last Name"
form="product-form-{{ section.id }}"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ccc; font-size: 14px;"
/>
</div>
</div>
<script>
function handleAthleteChange_{{ block.id | replace: '-', '_' }}(selectEl) {
const blockId = '{{ block.id }}';
const customContainer = document.getElementById('CustomAthleteContainer-' + blockId);
const customInput = document.getElementById('CustomAthleteInput-' + blockId);
const hiddenPlayerInput = document.getElementById('HiddenPlayerId-' + blockId);
const hiddenTeamInput = document.getElementById('HiddenTeamId-' + blockId);
const selectedOption = selectEl.options[selectEl.selectedIndex];
const playerId = selectedOption.getAttribute('data-player-id') || '';
const teamId = selectedOption.getAttribute('data-team-id') || '';
// Pass IDs to hidden properties
if (hiddenPlayerInput) hiddenPlayerInput.value = playerId;
if (hiddenTeamInput) hiddenTeamInput.value = teamId;
if (selectEl.value === 'OTHER') {
customContainer.style.display = 'block';
customInput.required = true;
customInput.focus();
} else {
customContainer.style.display = 'none';
customInput.required = false;
customInput.value = '';
}
}
// Ensure form attribute binds to product form on DOM ready
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form[action*="/cart/add"]');
if (form && !form.id) {
form.id = 'product-form-{{ section.id }}';
}
});
</script>
{% schema %}
{
"name": "Supported Athlete",
"target": "section",
"settings": [
{
"type": "text",
"id": "label_text",
"label": "Field Label",
"default": "Select Athlete to Support"
},
{
"type": "text",
"id": "placeholder_text",
"label": "Placeholder Text",
"default": "-- Select an Athlete --"
},
{
"type": "text",
"id": "property_name",
"label": "Cart Line Item Property Name",
"default": "Name of athlete you are supporting"
},
{
"type": "text",
"id": "custom_property_name",
"label": "Custom Name Property Name",
"default": "Custom Athlete Name"
},
{
"type": "checkbox",
"id": "is_required",
"label": "Make Selection Mandatory",
"default": true
},
{
"type": "range",
"id": "margin_top",
"min": 0,
"max": 40,
"step": 2,
"unit": "px",
"label": "Margin Top",
"default": 12
},
{
"type": "range",
"id": "margin_bottom",
"min": 0,
"max": 40,
"step": 2,
"unit": "px",
"label": "Margin Bottom",
"default": 12
}
]
}
{% endschema %}
Here is a code example Shopify AI assistant gave me which is facing the same issue:
{% comment %}
App block: Custom line item property
File: blocks/properties_example.liquid
{% endcomment %}
{%- if request.page_type == 'product' -%}
{%- comment -%} Capture the parent section's form ID {%- endcomment -%}
{%- assign product_form_id = 'product-form-' | append: section.id -%}
<div class="custom-line-item-property">
<label for="custom_property_input-{{ block.id }}">
{{ block.settings.label }}
</label>
<input
id="custom_property_input-{{ block.id }}"
type="text"
name="properties[{{ block.settings.property_key }}]"
form="{{ product_form_id }}"
value=""
>
</div>
{%- endif -%}
{% schema %}
{
"name": "Custom line item property",
"target": "section",
"templates": ["product"],
"settings": [
{
"type": "text",
"id": "label",
"label": "Input label",
"default": "Enter a custom value"
},
{
"type": "text",
"id": "property_key",
"label": "Property name (key)",
"default": "Custom value"
}
]
}
{% endschema %}
Any input or help will be greatly appreciated.