I`ve faced the same issue. Looks like a bug.
My workaround was to move context parameters out of offer:
{
<#if (offers)??>
"decisionOffers": ${toJSON(offers)},
</#if>
"firstName": "${guest.firstName!}"
}
Experience response is:
{
"decisionOffers": [
{
"ref": "fcde595f-fd78-41db-8b0e-23b5af1f1185",
"name": "Offer ",
"description": "Offer ",
"status": "ACTIVE",
"attributes": {
"imageUrl": "https://icycoupons.com/img/single-offer/womensbest.jpg",
"title": "Dear ${guest.firstname}, 40% Off Best Coupon!",
"description": "Save money on things you want with a Women's Best promo code or coupon. Join the Women's Best Rewards Club for Birthday Offers, Early Access to Limited Editions & Surprise Gifts with Every Purchase.",
"linkUrl": "https://by.wildberries.ru/brands/womens-store"
}
}
],
"firstName": "Denis"
}
As you can see substitution doesn`t work inside offer, but works outside. And you can substitute your variablr in HTML markup, like this:
<h1>Dear {{firstName}},</h1>
{{#decisionOffers}}
{{#attributes}}
<div class="img-container">
<div class="img-container__image" style='background-image: url("{{imageUrl}}");'></div>
</div>
<div class="bx-transition-card__body">
<h3>[[Title | string | {{title}} | {required:true, group: Title, order: 1}]]</h3>
<p>[[Description | text | {{description}} | {required:true, group: Description, order: 1}]]</p>
<div class="options-container">
<a id="bx-transition-card--secondary" class="options-container__secondary">[[Dismiss Text | string | No Thanks | {required:true, group: Secondary Button, order: 1}]]</a>
<a id="bx-transition-card--primary" class="options-container__primary" href="{{linkUrl}}">[[CTA Text | string | Yes Please | {required:true, group: CTA Button, order: 1}]]</a>
</div>
</div>
{{/attributes}}
{{/decisionOffers}}
</div>
