The free-month offer and the subscription card it unlocks. Two beats, one modal.
The crown gets one soft green glow — celebration without a collage or a countdown. Claiming flips the same modal to card B.
ClaimMonthModal · new — both beats in one component; claimed flips the content, never a second modalPopModal · ui/components/pop/PopModal.vue — background-class="upsell-black" for this beat (flat metal-950, never the doodle)PrimaryButton · ui/buttons/PrimaryButton.vue — variant="green"; size lg default renders f:body/l-heavy, exactly the stage's classesIconButton · ui/buttons/IconButton.vue — the white ✕, variant:white size:md1f451 — the free-month mark the Arcade already uses.claim-modal column · art, title, line, CTA, terms, skip on one axis — gap owns every space (R10, R11)<script setup>
import { ref } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import IconButton from '@/buttons/IconButton.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import SubscriptionSummary from '@/components/SubscriptionSummary.vue' // new — shared with the account area
import { useAccountStore } from '@/store/account'
const props = defineProps({ popped: Boolean })
const emit = defineEmits(['update:popped'])
const account = useAccountStore()
const claimed = ref(false)
const close = () => {
emit('update:popped', false)
}
/* the claim runs server-side: applies the free-month discount to the
subscription and returns the updated summary the second beat shows */
const claim = async () => {
await account.claimFreeMonth()
claimed.value = true
}
</script>
<template>
<PopModal :popped="props.popped" @unpop="close"
:background-class="claimed ? undefined : 'upsell-black'">
<div v-if="!claimed" class="claim-modal">
<span class="__glow" aria-hidden="true"></span>
<IconButton icon="x" size="md" variant="white" class="__close" @click="close" />
<img class="__art jp-font"
src="https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/1f451.png" alt="">
<div class="__title">A free month of Pro</div>
<Text type="body/l-reg" color="metal-300" text="Every Pro feature, on us, for a full month." />
<PrimaryButton variant="green" label="Claim my free month" @click="claim" />
<Text type="body/s-reg" color="metal-400" text="Then $5/mo. Cancel anytime." />
<button class="__skip" type="button" @click="close">Not now</button>
</div>
<div v-else class="claim-modal--done">
<IconButton icon="x" size="md" variant="black" class="__close" @click="close" />
<SubscriptionSummary :subscription="account.subscription" />
</div>
</PopModal>
</template>
<ClaimMonthModal v-model:popped="upsell.claimMonth.show" /> // opener: the free-month earn path (the Arcade's crown is the // planned source — the offer's origin is still an open call). // Never self-opens on load or a timer.
/* dark sales shell: flat metal-950, never the doodle. One
celebration cue — the glow — and nothing else moves. */
.claim-modal {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-4);
padding: 48px 28px 28px;
text-align: center;
overflow: hidden;
}
/* the glow — token-derived, sits behind the crown; pointer-events
off so it never eats a tap */
.claim-modal .__glow {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
width: 260px;
height: 200px;
background: radial-gradient(closest-side,
color-mix(in srgb, var(--green-500) 26%, transparent), transparent);
pointer-events: none;
}
.claim-modal .__art { position: relative; width: 72px; height: 72px; }
.claim-modal .__title {
position: relative;
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.15;
color: var(--white);
max-width: 18ch;
}
/* type + color ride the Text props — only the measure is scoped */
.claim-modal .__line { max-width: 36ch; }
.claim-modal .__skip {
border: none;
background: none;
font-size: 1.25rem;
color: var(--metal-400);
text-decoration: underline;
cursor: pointer;
}
The account area's subscription summary, dressed as a modal — one component, two mounts. Amount today is $0.00; the next billing date lands one month out.
SubscriptionSummary · new — the account area's subscription card as one component; this modal and the account page share itClaimMonthModal as card A — claimed = true is the whole difference; the PopModal background drops the upsell class for this beatPrimaryButton · ui/buttons/PrimaryButton.vue — Manage, variant="green" size="md"Icon rows · calendar-blank ships in IconIndex today; tag + circle-dollar are new registrations (Phosphor PhTag, PhCurrencyCircleDollar)assets/logo.svg mirror — the real build uses its own bundled mark asset.sub-card column · head, rule, rows, discount, rule, foot — gap owns every space (R10, R11).__foot row · tile, plan, Manage — the plan block takes margin-right: auto as the spacer<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import Icon from '@/components/Icon.vue'
import { getEnv } from '@shared/config/env'
/* subscription = { frequency, nextBillingDate, amount, discount,
planPrice, planNote } — the server's summary object, rendered
verbatim. The card computes nothing. */
const props = defineProps({ subscription: { type: Object, required: true } })
const manage = () => {
window.open(getEnv('OAUTH_BASE_URL') + '/account/subscription')
}
</script>
<template>
<div class="sub-summary">
<div class="__head">
<div class="__title">EmojiCopy Pro</div>
<Text type="body/m-reg" color="text-secondary" text="A look at your subscription." />
</div>
<div class="__rule"></div>
<div class="__rows">
<!-- calendar-blank is in IconIndex today; tag + circle-dollar are
NEW registrations (Phosphor PhTag, PhCurrencyCircleDollar) -->
<div class="__row"><Icon name="calendar-blank" /><span><b>Frequency:</b> {{ subscription.frequency }}</span></div>
<div class="__row"><Icon name="tag" /><span><b>Next billing date:</b> {{ subscription.nextBillingDate }}</span></div>
<div class="__row"><Icon name="circle-dollar" /><span><b>Amount:</b> {{ subscription.amount }}</span></div>
</div>
<div v-if="subscription.discount" class="__discount">
Discount applied: {{ subscription.discount }}
</div>
<div class="__rule"></div>
<div class="__foot">
<span class="__tile"><img src="@/assets/mark.svg" alt=""></span>
<span class="__plan">
<span class="__price">{{ subscription.planPrice }}</span>
<Text type="body/s-reg" color="text-secondary" :text="subscription.planNote" />
</span>
<PrimaryButton variant="green" size="md" label="Manage" @click="manage" />
</div>
</div>
</template>
<SubscriptionSummary :subscription="account.subscription" />
// after claimFreeMonth() resolves, account.subscription reads:
{ frequency: 'Monthly', nextBillingDate: 'September 14, 2026',
amount: '$0.00', discount: 'Free month',
planPrice: '$5/mo', planNote: 'Starts after your free month' }
/* theme-aware app surface — account UI, not a sales shell. The
modal mount and the account-page mount share every rule. */
.sub-summary {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: 28px;
text-align: left;
}
.sub-summary .__head { display: flex; flex-direction: column; gap: 2px; }
.sub-summary .__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.2rem;
color: var(--c-text-primary);
}
.sub-summary .__rule { height: 1px; background: var(--c-border-primary); }
.sub-summary .__rows { display: flex; flex-direction: column; gap: var(--sp-3); }
.sub-summary .__row {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.4rem;
color: var(--c-text-primary);
}
.sub-summary .__row .icon { width: 18px; height: 18px; flex: none; }
.sub-summary .__discount { font-weight: 700; font-size: 1.4rem; color: var(--c-text-primary); }
.sub-summary .__foot { display: flex; align-items: center; gap: 12px; }
.sub-summary .__tile {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex: none;
border-radius: var(--r-md);
background: var(--c-surface-secondary);
}
.sub-summary .__tile img { width: 26px; height: 26px; }
.sub-summary .__plan { display: flex; flex-direction: column; gap: 2px; margin-right: auto; }
.sub-summary .__price { font-weight: 700; font-size: 1.6rem; color: var(--c-text-primary); }